Adds a menu with to apply filters, add sorting, player tweaks like hiding the cursor and auto-mute, and site-wide quality-of-life improvements like automatic age verification and always default to the english version of the site. Beta download button added! Actively maintained and used by me so updated regularly. Feel free to request features!
// ==UserScript==
// @name Pornhub Pro-ish
// @namespace https://www.reddit.com/user/Alpacinator
// @version 6.9.5
// @include *://*.pornhub.com/*
// @grant none
// @description Adds a menu with to apply filters, add sorting, player tweaks like hiding the cursor and auto-mute, and site-wide quality-of-life improvements like automatic age verification and always default to the english version of the site. Beta download button added! Actively maintained and used by me so updated regularly. Feel free to request features!
// ==/UserScript==
(function() {
'use strict';
// ===========================================================================
// PORNHUB PRO-ISH — Architecture overview
//
// Everything runs inside a single IIFE to avoid polluting the global scope.
//
// Execution order on page load:
// 1. CrossDomainStorage — cookie-based storage shared across subdomains
// 2. AgeGate.run() — immediately bypass the age disclaimer if enabled
// 3. CONFIG — central constants (timings, selectors, etc.)
// 4. handleError / Utils — shared helpers used by every class
// 5. EventEmitter — pub/sub bus that decouples classes from each other
// 6. StateManager — persists boolean settings via CrossDomainStorage
// 7. Feature — plain data descriptor for each menu toggle
// 8. AutoScroller — fetches and appends playlist pages automatically
// 9. VideoSorter — re-orders video items by duration or trophy
// 10. VideoHider — hides items matching active filter rules
// 11. VideoPlayer — mute helper and cursor-hide CSS injection
// 12. DownloadManager — injects a download button into the player
// 13. LanguageManager — forces the site to load in English
// 14. ElementHider — removes persistent site clutter (banners etc.)
// 15. PlaylistManager — adds delete-confirmation overlay on playlist items
// 16. ScrollToTop — smooth scroll helper
// 17. MenuManager — builds and manages the floating UI panel
// 18. App — wires everything together; runs the DOM observer
// 19. initializeApp() — entry point called at the bottom
//
// Communication between classes goes through the EventEmitter (pub/sub).
// Persistent settings are stored via CrossDomainStorage (cookies) and
// read/written through StateManager, which also caches values in memory.
// ===========================================================================
// ---------------------------------------------------------------------------
// CrossDomainStorage
//
// A localStorage-compatible API (getItem / setItem / removeItem) that stores
// values as cookies scoped to the root domain (e.g. ".pornhub.com") so that
// settings saved on www.pornhub.com are automatically visible on nl, de, fr,
// and every other subdomain.
//
// On first access of a key, any existing localStorage value is migrated into
// a cookie automatically so users don't lose their previous settings.
//
// Falls back to plain localStorage if cookie writes fail for any reason.
// ---------------------------------------------------------------------------
const CrossDomainStorage = (() => {
// Strip the subdomain to get the root domain with a leading dot,
// which is required for cookies to be shared across all subdomains.
// e.g. "nl.pornhub.com" → ".pornhub.com"
const _rootDomain = (() => {
const parts = window.location.hostname.split('.');
return '.' + parts.slice(-2).join('.');
})();
const COOKIE_MAX_AGE = 10 * 365 * 24 * 60 * 60; // 10 years
function _readAllCookies() {
const map = {};
document.cookie.split('; ').forEach(pair => {
const idx = pair.indexOf('=');
if (idx < 0) return;
const k = pair.slice(0, idx).trim();
const v = pair.slice(idx + 1).trim();
if (k) map[k] = v;
});
return map;
}
return {
// Returns the stored string for `key`, or null if not found.
// Checks cookies first; if missing, checks localStorage and migrates
// the value into a cookie so it's shared cross-subdomain going forward.
getItem(key) {
try {
const cookies = _readAllCookies();
if (Object.prototype.hasOwnProperty.call(cookies, key)) {
return decodeURIComponent(cookies[key]);
}
// Migrate from localStorage if present
const lsVal = localStorage.getItem(key);
if (lsVal !== null) {
this.setItem(key, lsVal);
localStorage.removeItem(key);
return lsVal;
}
return null;
} catch (err) {
try {
return localStorage.getItem(key);
} catch (_) {
return null;
}
}
},
// Writes `value` as a root-domain cookie that persists for 10 years.
// Falls back to localStorage if cookie writing fails.
setItem(key, value) {
try {
const encoded = encodeURIComponent(String(value));
document.cookie = [
`${key}=${encoded}`,
`domain=${_rootDomain}`,
'path=/',
`max-age=${COOKIE_MAX_AGE}`,
'SameSite=Lax',
].join('; ');
} catch (err) {
try {
localStorage.setItem(key, String(value));
} catch (_) {}
}
},
// Expires the cookie on both root domain and current subdomain,
// then also removes any localStorage entry for the same key.
removeItem(key) {
try {
document.cookie = [
`${key}=`,
`domain=${_rootDomain}`,
'path=/',
'max-age=0',
'SameSite=Lax',
].join('; ');
// Also clear any plain subdomain cookie that may have existed
document.cookie = `${key}=; path=/; max-age=0; SameSite=Lax`;
} catch (err) {}
try {
localStorage.removeItem(key);
} catch (_) {}
},
};
})();
// ---------------------------------------------------------------------------
// AgeGate
//
// Manages the age-verification cookie the site checks before showing content.
// When "Auto-confirm age" is enabled, this sets the cookie so the disclaimer
// never appears.
//
// Flow on each page load:
// run() checks if the feature is enabled, sets the cookie if missing, then
// reloads once (the site reads the cookie at page-load time). A sessionStorage
// flag prevents an infinite reload loop in case the cookie is blocked.
// ---------------------------------------------------------------------------
class AgeGate {
static _COOKIE_KEY = 'accessAgeDisclaimerPH';
static _COOKIE_VALUE = '2';
static _COOKIE_MAX_AGE = 10 * 365 * 24 * 60 * 60; // 10 years in seconds
// Writes the age-gate acceptance cookie to the current domain.
static set() {
document.cookie = [
`${AgeGate._COOKIE_KEY}=${AgeGate._COOKIE_VALUE}`,
'path=/',
`max-age=${AgeGate._COOKIE_MAX_AGE}`,
'SameSite=Lax',
].join('; ');
console.log('AgeGate: cookie set');
}
// Returns true if the age-gate cookie is already present.
static exists() {
return document.cookie
.split('; ')
.some(c => c.startsWith(`${AgeGate._COOKIE_KEY}=`));
}
// Entry point. Checks user preference and sets/reloads as needed.
static run() {
const enabled = CrossDomainStorage.getItem('autoConfirmAgeState') === 'true';
if (!enabled) return;
if (!AgeGate.exists()) {
AgeGate.set();
if (!sessionStorage.getItem('ageGateReloaded')) {
sessionStorage.setItem('ageGateReloaded', 'true');
location.reload();
}
} else {
sessionStorage.removeItem('ageGateReloaded');
}
}
// Removes the age-gate cookie and reloads so the disclaimer reappears.
// Called when the user disables "Auto-confirm age" in the menu.
static clear() {
document.cookie = [
`${AgeGate._COOKIE_KEY}=`,
'path=/',
'max-age=0',
'SameSite=Lax',
].join('; ');
console.log('AgeGate: cookie cleared');
sessionStorage.removeItem('ageGateReloaded');
location.reload();
}
}
// Run immediately so the disclaimer never flashes while the rest of the
// script initialises. Must be called before CONFIG/Utils are defined.
AgeGate.run();
// ---------------------------------------------------------------------------
// CONFIG
//
// Central place for all magic numbers, CSS selectors, and named constants.
//
// OPACITY — transparency levels for the "Enable transparency" toggle.
// TIMING — delays and thresholds used across multiple classes.
// SELECTORS — CSS selectors targeting site elements. Update here if the
// site's markup changes and features stop working.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
const CONFIG = {
SCRIPT_NAME: 'PH-PRO',
OPACITY: {
TRANSPARENT: 0.80,
DEFAULT: 1.0,
HOVER: 0.8,
},
TIMING: {
MUTATION_DEBOUNCE_MS: 300,
LANGUAGE_CHECK_DELAY_MS: 1000,
CURSOR_HIDE_DELAY_S: 3,
AUTOSCROLL_MIN_DELAY_MS: 800,
AUTOSCROLL_MAX_DELAY_MS: 2500,
AUTOSCROLL_MAX_CONSECUTIVE_EMPTY: 3,
BUTTON_FLASH_MS: 100,
OBSERVER_THROTTLE_MS: 1000,
FEATURE_INIT_DELAY_MS: 100,
ELEMENT_HIDE_LOAD_DELAY_MS: 500,
FILTER_WORDS_MAX_LENGTH: 255,
DOWNLOAD_BUTTON_DELAY_MS: 4000,
},
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',
FULLSCREEN_BUTTON: '.mgp_fullscreen',
DOWNLOAD_BUTTON_ID: 'phpro-download-btn',
PLAYLIST_CONTAINERS: [
'#videoPlaylist',
'#videoPlaylistSection',
'#playListSection',
'[id*="playlist"]',
'[class*="playlist"]',
'[data-context="playlist"]',
],
ELEMENTS_TO_HIDE: [
'#countryRedirectMessage',
'#js-abContainterMain',
'#welcome',
'div.pornInLangWrapper',
'#loadMoreRelatedVideosCenter',
'[data-label="recommended_load_more"]',
'.buttonClass.blackBtn.eudsaLink',
],
},
};
// ---------------------------------------------------------------------------
// handleError
//
// Centralised logger used throughout the script. Prefixes every message with
// the script name and calling context so console output is easy to filter.
// `level` can be 'warn' (non-fatal) or 'error' (unexpected failure).
// ---------------------------------------------------------------------------
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);
}
}
// ---------------------------------------------------------------------------
// Utils
//
// Stateless helper functions shared across the whole script.
// ---------------------------------------------------------------------------
const Utils = {
// Prefixed console wrapper. Use instead of bare console.log so all
// script output is groupable and filterable in DevTools.
log(message, level = 'info') {
const prefix = `${CONFIG.SCRIPT_NAME}:`;
if (level === 'error') console.error(prefix, message);
else if (level === 'warn') console.warn(prefix, message);
else console.log(prefix, message);
},
// Returns a version of `func` that delays execution until `wait` ms have
// passed since the last call. Useful for batching rapid DOM mutations.
debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
},
// Returns a version of `func` that fires at most once per `limit` ms.
// The first call fires immediately; subsequent calls within the window are
// dropped. Used for the MutationObserver callback.
throttle(func, limit) {
let inThrottle = false;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
}, limit);
}
};
},
// Converts "HH:MM:SS" or "MM:SS" to total seconds.
// Returns 0 for invalid input so sorting still works gracefully.
parseDuration(durationString) {
if (!durationString || typeof durationString !== 'string') return 0;
const parts = durationString.trim().split(':').map(Number);
return parts.reduce((acc, part) => (isNaN(part) ? acc : acc * 60 + part), 0);
},
// Creates a DOM element and applies an options object to it.
// Handles style (object), textContent, className, dataset, and any other
// property via setAttribute.
createElement(tag, options = {}) {
const element = document.createElement(tag);
for (const [key, value] of Object.entries(options)) {
try {
if (key === 'style' && typeof value === 'object') {
Object.assign(element.style, value);
} else if (key === 'textContent') {
element.textContent = value;
} else if (key === 'className') {
element.className = value;
} else if (key === 'dataset' && typeof value === 'object') {
Object.assign(element.dataset, value);
} else {
element.setAttribute(key, value);
}
} catch (err) {
handleError(`createElement(${tag}).${key}`, err, 'warn');
}
}
return element;
},
// querySelector with try/catch — returns null instead of throwing on
// invalid selectors (which can happen if the site changes its markup).
safeQuerySelector(selector, context = document) {
try {
return context.querySelector(selector);
} catch (err) {
handleError(`safeQuerySelector("${selector}")`, err, 'warn');
return null;
}
},
// querySelectorAll with try/catch — returns [] instead of throwing.
safeQuerySelectorAll(selector, context = document) {
try {
return Array.from(context.querySelectorAll(selector));
} catch (err) {
handleError(`safeQuerySelectorAll("${selector}")`, err, 'warn');
return [];
}
},
// Splits a comma-separated filter string into a cleaned array of words.
// Trims whitespace, lowercases, removes empty entries, and enforces the
// max-length cap from CONFIG to prevent oversized cookie values.
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);
},
// Injects a <style> tag into <head> and returns the element.
addStylesheet(css) {
const style = Utils.createElement('style', {
textContent: css
});
document.head.appendChild(style);
return style;
},
};
// ---------------------------------------------------------------------------
// EventEmitter
//
// Minimal publish/subscribe system used to decouple classes from each other.
// Instead of classes calling each other directly, they emit named events and
// subscribe to events they care about.
//
// Example: MenuManager emits 'hideVideos' when a filter toggle changes.
// App listens for 'hideVideos' and calls VideoHider.hideVideos().
// VideoHider never needs to know MenuManager exists.
// ---------------------------------------------------------------------------
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
//
// Single source of truth for all boolean feature flags (toggle states).
// Wraps CrossDomainStorage with an in-memory cache so repeated reads within
// a single page load don't hit the cookie jar every time.
//
// Validators (registered via addValidator) enforce that values are the correct
// type before they're persisted. Invalid writes are rejected.
//
// Emits a 'stateChanged' event on the EventEmitter whenever a value changes,
// so other parts of the script can react automatically.
// ---------------------------------------------------------------------------
class StateManager {
constructor(eventEmitter) {
this._cache = new Map();
this._eventEmitter = eventEmitter;
this._validators = new Map();
}
addValidator(key, validator) {
this._validators.set(key, validator);
}
// Read a boolean setting. Returns `defaultValue` if the key has never been
// set. On first read the value is also written back to storage to persist
// the default so it exists next time.
get(key, defaultValue = false) {
if (this._cache.has(key)) return this._cache.get(key);
try {
const raw = CrossDomainStorage.getItem(key);
const value = raw !== null ? raw === 'true' : defaultValue;
const validated = this._validate(key, value, defaultValue);
if (raw === null) this._persist(key, validated);
this._cache.set(key, validated);
return validated;
} catch (err) {
handleError(`StateManager.get("${key}")`, err);
return defaultValue;
}
}
// Write a boolean setting. Validates, updates storage and cache, then
// emits 'stateChanged' if the value actually changed.
set(key, value, emit = true) {
try {
const validated = this._validate(key, value, value);
if (validated !== value) {
Utils.log(`StateManager: invalid value for "${key}": ${value}`, 'warn');
return false;
}
const oldValue = this._cache.get(key);
this._persist(key, value);
this._cache.set(key, value);
if (emit && oldValue !== value) {
this._eventEmitter.emit('stateChanged', {
key,
oldValue,
newValue: value
});
}
return true;
} catch (err) {
handleError(`StateManager.set("${key}")`, err);
return false;
}
}
// Flips a boolean setting and returns the new value.
toggle(key) {
const newValue = !this.get(key);
this.set(key, newValue);
return newValue;
}
// Drops the in-memory cache so the next get() re-reads from storage.
// Called when the tab becomes visible again in case another tab changed a setting.
clearCache() {
this._cache.clear();
}
_validate(key, value, fallback) {
const validator = this._validators.get(key);
if (!validator) return value;
return validator(value) ? value : fallback;
}
_persist(key, value) {
CrossDomainStorage.setItem(key, String(value));
}
}
// ---------------------------------------------------------------------------
// Feature
//
// Plain data object describing a single toggle in the menu.
// label — text shown next to the toggle switch
// key — StateManager key used to persist the on/off state
// handler — optional function called after the state changes
// id — DOM id of the toggle track element
// defaultState — initial value if the key has never been set
// category — groups related features under a header in the menu
// ---------------------------------------------------------------------------
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
//
// Automatically loads all pages of a playlist by fetching each chunk from
// the site's internal API and appending results to the visible list, avoiding
// the need to manually click "Load more" repeatedly.
//
// Flow:
// start() → works out the starting page from items already loaded
// → emits 'autoscrollStateChanged' so App can pause the observer
// → kicks off _scheduleNext(0) immediately
// _scrollLoop() → fetches one page → deduplicates → appends items
// → scrolls new items into view → schedules the next fetch
// → stops after N consecutive pages with no new videos
// stop() → emits 'autoscrollStateChanged'
// → App resumes the observer and scrolls to the top
// ---------------------------------------------------------------------------
class AutoScroller {
constructor(eventEmitter) {
this._eventEmitter = eventEmitter;
this.isRunning = false;
this._timeoutId = null;
this._playlistPage = null;
this._fetchedPages = null;
}
// Starts autoscrolling. Calculates the starting page from the number of
// <li> items already on screen (each page holds 32 items).
start() {
if (this.isRunning) {
Utils.log('AutoScroll already running');
return false;
}
this.isRunning = true;
this._playlistPage = Math.floor(
document.querySelectorAll('ul.videos.row-5-thumbs li').length / 32
) + 1;
this._fetchedPages = new Set();
this._consecutiveEmpty = 0;
this._retriedCurrentPage = false;
Utils.log('AutoScroll started');
this._eventEmitter.emit('autoscrollStateChanged', {
isRunning: true
});
this._scheduleNext(0);
return true;
}
// Stops autoscrolling and clears the pending timeout.
stop() {
if (!this.isRunning) return false;
this.isRunning = false;
if (this._timeoutId) {
clearTimeout(this._timeoutId);
this._timeoutId = null;
}
Utils.log('AutoScroll stopped');
this._eventEmitter.emit('autoscrollStateChanged', {
isRunning: false
});
return true;
}
// Convenience method used by the menu button.
toggle() {
return this.isRunning ? this.stop() : this.start();
}
_scheduleNext(delayMs) {
this._timeoutId = setTimeout(() => this._scrollLoop(), delayMs);
}
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) {
if (!this._retriedCurrentPage) {
// First empty result for this page — undo the page increment and
// retry once before counting it as a genuinely empty response.
this._retriedCurrentPage = true;
this._playlistPage--;
this._fetchedPages.delete(this._playlistPage);
Utils.log(`AutoScroll: no new items on page ${this._playlistPage + 1}, retrying once`);
const {
AUTOSCROLL_MIN_DELAY_MS: min,
AUTOSCROLL_MAX_DELAY_MS: max
} = CONFIG.TIMING;
this._scheduleNext(min + Math.floor(Math.random() * (max - min)));
return;
}
// Already retried — count it as empty and move on.
this._retriedCurrentPage = false;
this._consecutiveEmpty++;
Utils.log(`AutoScroll: no new items after retry (${this._consecutiveEmpty}/${CONFIG.TIMING.AUTOSCROLL_MAX_CONSECUTIVE_EMPTY})`);
if (this._consecutiveEmpty >= CONFIG.TIMING.AUTOSCROLL_MAX_CONSECUTIVE_EMPTY) {
Utils.log('AutoScroll: max consecutive empty responses reached, stopping');
this.stop();
return;
}
} else {
this._consecutiveEmpty = 0;
this._retriedCurrentPage = false;
}
} catch (err) {
handleError('AutoScroller._scrollLoop', err);
const list = document.querySelector('ul.videos.row-5-thumbs');
const lastLi = list?.querySelector('li:last-child');
if (lastLi) lastLi.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
else window.scrollTo(0, document.body.scrollHeight);
}
const {
AUTOSCROLL_MIN_DELAY_MS: min,
AUTOSCROLL_MAX_DELAY_MS: max
} = CONFIG.TIMING;
this._scheduleNext(min + Math.floor(Math.random() * (max - min)));
}
}
// ---------------------------------------------------------------------------
// VideoSorter
//
// Re-orders video <li> items inside video list <ul> elements either by
// duration (longest first), trophy status (award-icon items first), or
// view count (most viewed first).
//
// findVideoLists() discovers all video grids on the page and optionally
// excludes playlist containers, so sorting only affects regular browsing
// pages unless "Sort within playlists" is enabled.
// ---------------------------------------------------------------------------
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));
}
// Sorts all applicable lists by view count (most viewed first).
sortByViews(forceIncludePlaylist = false) {
const lists = forceIncludePlaylist ?
[...new Set([...this.findPlaylistLists(), ...this.findVideoLists(true)])] :
this.findVideoLists();
Utils.log(`VideoSorter: sorting ${lists.length} list(s) by views`);
lists.forEach(list => this._sortListByViews(list));
}
// Parses a view count string like "62.8K", "1.2M", or "945" into a plain number.
// Handles K (thousands), M (millions), B (billions) suffixes.
_parseViews(viewString) {
if (!viewString || typeof viewString !== 'string') return 0;
const s = viewString.trim().replace(/,/g, '');
const num = parseFloat(s);
if (isNaN(num)) return 0;
if (s.endsWith('K') || s.endsWith('k')) return num * 1_000;
if (s.endsWith('M') || s.endsWith('m')) return num * 1_000_000;
if (s.endsWith('B') || s.endsWith('b')) return num * 1_000_000_000;
return num;
}
// Reads the .views var text from each <li> and re-appends in descending order.
// Items without a views element are treated as 0 and sorted to the bottom.
_sortListByViews(list) {
const items = Utils.safeQuerySelectorAll('li', list);
if (items.length === 0) return;
try {
items.sort((a, b) => {
const va = this._parseViews(a.querySelector('.views var')?.textContent ?? '0');
const vb = this._parseViews(b.querySelector('.views var')?.textContent ?? '0');
return vb - va; // descending: most viewed first
});
items.forEach(item => list.appendChild(item));
} catch (err) {
handleError('VideoSorter._sortListByViews', err);
}
}
}
// ---------------------------------------------------------------------------
// VideoHider
//
// Hides individual video <li> items matching any active filter:
// - Watched videos (have a watched overlay that isn't also hidden)
// - Paid content (price badge, premium icon, or private overlay)
// - VR videos (have a VR badge)
// - Word filters (title text contains one of the saved filter words)
// - Shorts section (the entire row, hidden as a unit)
//
// Operates in two modes:
// Full pass — scans every item in every video list (initial load / toggle)
// Incremental — only checks newly added DOM nodes (infinite scroll)
// ---------------------------------------------------------------------------
class VideoHider {
constructor(stateManager, videoSorter) {
this._state = stateManager;
this._videoSorter = videoSorter;
this._cachedFilterWords = null;
this._lastFilterString = null;
}
getFilterWords() {
const current = CrossDomainStorage.getItem('savedFilterWords') ?? '';
if (current !== this._lastFilterString) {
this._lastFilterString = current;
this._cachedFilterWords = Utils.sanitizeFilterWords(current);
}
return this._cachedFilterWords;
}
// Returns the show-only word list (videos NOT matching any of these are hidden).
getShowWords() {
return Utils.sanitizeFilterWords(CrossDomainStorage.getItem('savedShowWords') ?? '');
}
// Main entry point. Reads toggle states then iterates over items,
// setting display:none or clearing it based on _shouldHide().
// `addedNodes` limits processing to newly inserted elements (incremental mode).
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();
const showWords = this.getShowWords();
const shortsSection = Utils.safeQuerySelector(CONFIG.SELECTORS.SHORTS_SECTION);
if (shortsSection) {
shortsSection.style.display = hideShorts ? 'none' : '';
}
let items;
if (addedNodes && addedNodes.length > 0) {
items = addedNodes.flatMap(node => {
if (node.nodeType !== Node.ELEMENT_NODE) return [];
if (node.tagName === 'LI') return [node];
return Array.from(node.querySelectorAll('li'));
});
Utils.log(`VideoHider: incremental pass, ${items.length} new item(s)`);
} else {
const lists = this._videoSorter.findVideoLists(true);
items = lists.flatMap(list => Utils.safeQuerySelectorAll('li', list));
Utils.log(`VideoHider: full pass, ${items.length} item(s)`);
}
for (const item of items) {
try {
item.style.display = this._shouldHide(item, {
hideWatched,
hidePaid,
hideVR,
filterWords,
showWords
}) ?
'none' :
'';
} catch (err) {
handleError('VideoHider.hideVideos (item)', err, 'warn');
}
}
}
_shouldHide(item, {
hideWatched,
hidePaid,
hideVR,
filterWords,
showWords
}) {
if (hideWatched) {
const watched = item.querySelector(CONFIG.SELECTORS.WATCHED_INDICATORS);
if (watched && !watched.classList.contains('hidden')) return true;
}
if (hidePaid) {
const isPaid =
item.querySelector(CONFIG.SELECTORS.PAID_CONTENT) ||
item.querySelector('a')?.getAttribute('href') === 'javascript:void(0)';
if (isPaid) return true;
}
if (hideVR && item.querySelector(CONFIG.SELECTORS.VR_INDICATOR)) {
return true;
}
const text = item.textContent.toLowerCase();
if (filterWords.length > 0) {
if (filterWords.some(w => text.includes(w))) return true;
}
// Show-only: hide anything that doesn't match at least one show word
if (showWords.length > 0) {
if (!showWords.some(w => text.includes(w))) return true;
}
return false;
}
}
// ---------------------------------------------------------------------------
// VideoPlayer
//
// Static helpers that interact with the video player UI.
// mute() — simulates the full mouse-event sequence on the mute
// button (a plain click() alone doesn't work because
// the player uses custom internal events)
// resetMuteState() — clears the "already muted" guard, called when the
// tab goes to background
// toggleCursorHide() — injects/removes a CSS animation that hides the
// cursor after N seconds of inactivity over the video
// ---------------------------------------------------------------------------
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');
}
}
}
// ---------------------------------------------------------------------------
// DownloadManager
//
// Injects a download button into the video player toolbar so the user can
// save the highest-quality MP4 directly to their device.
//
// Because video URLs are exposed through window.mediaDefinitions (page JS
// context), the button logic is injected as an inline <script> tag that runs
// in page context rather than in the userscript sandbox.
//
// Click flow inside the injected script:
// findVideoUrl() → reads window.mediaDefinitions → picks best quality
// → if the URL is a remote quality manifest, fetches it
// and picks the highest-quality entry from the list
// startDownload() → streams the video with fetch(), shows live progress
// in a toast, collects chunks into a Blob, and triggers
// a download <a> link
//
// The button is placed just before the fullscreen button and retried up to
// 10 times (every 800 ms) in case the player hasn't finished rendering yet.
// ---------------------------------------------------------------------------
class DownloadManager {
static _buttonAdded = false;
static init(stateManager) {
// React to toggle changes from the menu
stateManager._eventEmitter.on('stateChanged', ({
key,
newValue
}) => {
if (key === 'downloadButtonState') {
if (newValue === true) {
setTimeout(() => DownloadManager.addButton(), 1500);
} else {
DownloadManager.removeButton();
}
}
});
// Initial load if already enabled
if (stateManager.get('downloadButtonState', false)) {
setTimeout(() => DownloadManager.addButton(), CONFIG.TIMING.DOWNLOAD_BUTTON_DELAY_MS);
}
}
static removeButton() {
document.querySelectorAll('.mgp_button[data-phpro-download]').forEach(b => b.remove());
DownloadManager._buttonAdded = false;
}
static addButton() {
if (DownloadManager._buttonAdded) return;
DownloadManager._buttonAdded = true;
const script = document.createElement('script');
script.textContent = `
(function() {
'use strict';
function log(msg) {
console.log('%c[PH-PRO Download] ' + msg, 'color:#ff9800;font-weight:bold');
}
async function findVideoUrl() {
log("Searching for mediaDefinitions...");
// This is the working method you had
const mediaObj = Object.values(window).find(v => v?.mediaDefinitions);
let media = mediaObj ? mediaObj.mediaDefinitions : null;
if (!media) {
log("Fallback: looking for window.mediaDefinitions directly");
media = window.mediaDefinitions;
}
if (!Array.isArray(media) || media.length === 0) {
throw new Error("mediaDefinitions not found or empty");
}
log("Found " + media.length + " media entries");
// Try direct mp4 first (non-remote)
let videoUrl = media.find(v => v.format === "mp4" && !v.remote)?.videoUrl;
if (!videoUrl) {
// Remote quality list fallback (very common)
const remote = media.find(v => v.remote && v.videoUrl);
if (remote) {
log("Fetching remote quality list...");
const res = await fetch(remote.videoUrl);
const list = await res.json();
if (Array.isArray(list) && list.length > 0) {
list.sort((a, b) => (b.quality || 0) - (a.quality || 0));
videoUrl = list[0].videoUrl;
log("Using highest quality: " + (list[0].quality || "unknown") + "p");
}
}
}
if (!videoUrl) throw new Error("No valid video URL found");
return videoUrl;
}
function showToast(text) {
let toast = document.getElementById('phpro-download-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'phpro-download-toast';
toast.style.cssText = 'position:fixed;bottom:25px;left:25px;padding:12px 18px;background:rgba(0,0,0,0.92);color:#fff;font-size:14px;border-radius:8px;z-index:2147483647;border:1px solid #ff9800;transition:opacity .3s;';
document.body.appendChild(toast);
}
toast.textContent = text;
toast.style.opacity = '1';
return toast;
}
function hideToast() {
const toast = document.getElementById('phpro-download-toast');
if (toast) toast.style.opacity = '0';
setTimeout(() => toast?.remove(), 400);
}
async function startDownload() {
const toast = showToast("Finding highest quality stream...");
try {
const videoUrl = await findVideoUrl();
toast.textContent = "Downloading...";
const res = await fetch(videoUrl);
const reader = res.body.getReader();
const total = +res.headers.get('Content-Length') || 0;
let received = 0;
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
if (total) {
const percent = ((received / total) * 100).toFixed(1);
toast.textContent = \`Downloading... \${percent}%\`;
} else {
toast.textContent = \`Downloading... \${(received / 1024 / 1024).toFixed(1)} MB\`;
}
}
const blob = new Blob(chunks);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = document.title.replace(/- Pornhub\\.com.*/i, '').trim() + ".mp4";
a.click();
URL.revokeObjectURL(url);
toast.textContent = "Download started ✓";
setTimeout(hideToast, 1800);
} catch (err) {
console.error(err);
toast.textContent = "Download failed – check console";
setTimeout(hideToast, 3000);
}
}
// Inject the button
function injectButton() {
document.querySelectorAll('.mgp_button[data-phpro-download]').forEach(b => b.remove());
const fullscreenBtn = document.querySelector('.mgp_fullscreen');
if (!fullscreenBtn) return false;
const btn = document.createElement('div');
btn.className = 'mgp_button';
btn.dataset.phproDownload = 'true';
btn.style.pointerEvents = 'auto';
btn.innerHTML = \`
<div class="mgp_icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M12 5V19M12 19L5 12M12 19L19 12"
stroke="#ffffff"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"/>
</svg>
</div>
\`;
btn.onclick = (e) => {
e.preventDefault();
e.stopImmediatePropagation();
startDownload();
};
// Tooltip
btn.addEventListener('mouseenter', () => {
const original = fullscreenBtn.getAttribute('data-text');
fullscreenBtn.setAttribute('data-text', "Download this video");
fullscreenBtn.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
setTimeout(() => fullscreenBtn.setAttribute('data-text', original), 60);
});
btn.addEventListener('mouseleave', () => {
fullscreenBtn.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
});
fullscreenBtn.parentNode.insertBefore(btn, fullscreenBtn);
log("Download button successfully injected");
return true;
}
// Try injecting with retries
let attempts = 0;
const interval = setInterval(() => {
attempts++;
if (injectButton() || attempts >= 10) {
clearInterval(interval);
}
}, 800);
})();
`;
document.documentElement.appendChild(script);
script.remove();
}
}
// ---------------------------------------------------------------------------
// LanguageManager
//
// Ensures the site always loads in English when the toggle is on.
//
// Strategy (tried in order, after a short delay for the site to settle):
// 1. Check the 'lang' cookie. If it isn't 'en', overwrite it with 'en'
// scoped to the root domain, then redirect to www (dropping any country
// subdomain prefix from the hostname).
// 2. If the cookie is already 'en' but the UI still shows a different
// language, click the English option in the language dropdown.
// ---------------------------------------------------------------------------
class LanguageManager {
constructor(stateManager) {
this._state = stateManager;
}
redirectToEnglish() {
if (!this._state.get('redirectToEnglishState')) return;
setTimeout(() => {
try {
const langCookie = this._getCookie('lang');
if (langCookie !== 'en') {
const hostParts = window.location.hostname.split('.');
const baseDomain = hostParts.slice(-2).join('.');
this._deleteCookie('lang');
this._deleteCookie('lang', baseDomain);
this._setCookie('lang', 'en', 365, baseDomain);
Utils.log(`LanguageManager: set lang cookie to English for ${baseDomain}, redirecting`);
const newUrl = `${window.location.protocol}//${baseDomain}${window.location.pathname}${window.location.search}`;
window.location.href = newUrl;
return;
}
const dropdown = Utils.safeQuerySelector(CONFIG.SELECTORS.LANGUAGE_DROPDOWN);
const currentLang = dropdown?.querySelector('span.networkTab')?.textContent.trim().toLowerCase();
if (currentLang !== 'en') {
const englishLink = Utils.safeQuerySelector(CONFIG.SELECTORS.ENGLISH_OPTION);
if (englishLink) {
englishLink.click();
Utils.log('LanguageManager: redirected to English');
}
}
} catch (err) {
handleError('LanguageManager.redirectToEnglish', err);
}
}, CONFIG.TIMING.LANGUAGE_CHECK_DELAY_MS);
}
_getCookie(name) {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? decodeURIComponent(match[2]) : null;
}
_setCookie(name, value, days, domain) {
const expires = new Date();
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
const domainPart = domain ? `;domain=${domain}` : '';
document.cookie = `${name}=${encodeURIComponent(value)};expires=${expires.toUTCString()};path=/${domainPart}`;
}
_deleteCookie(name, domain) {
const domainPart = domain ? `;domain=${domain}` : '';
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/${domainPart}`;
}
}
// ---------------------------------------------------------------------------
// ElementHider
//
// Hides persistent UI clutter injected by the site — country-redirect banners,
// A/B test containers, welcome modals, GDPR notices, etc.
// Runs once on DOMContentLoaded and again on window load since some elements
// are injected after the initial HTML parse.
// ---------------------------------------------------------------------------
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
//
// Adds a semi-transparent red overlay to playlist items the moment the user
// clicks the "delete from playlist" button on them. This gives instant visual
// feedback that the deletion is in progress, since the site takes a moment to
// actually remove the item from the DOM.
//
// Uses event delegation on document so it works even for dynamically-loaded
// playlist items.
// ---------------------------------------------------------------------------
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
//
// Simple helper that smoothly scrolls to the top of the page.
// Extracted into its own class so it can be injected into MenuManager
// without creating a direct dependency on the window object.
// ---------------------------------------------------------------------------
class ScrollToTop {
scrollToTop() {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}
}
// ---------------------------------------------------------------------------
// MenuManager
//
// Builds and manages the floating overlay UI: the draggable "☰ Menu" button
// and the panel that slides open from it.
//
// The panel contains:
// - Toggle rows (one per Feature, grouped by category)
// - Manual action buttons (sort, autoscroll, scroll-to-top)
// - A filter-words input with removable tag chips
//
// Button dragging (desktop + mobile):
// mousedown/touchstart → record start position and lock button size
// mousemove/touchmove → translate the button via CSS transform (rAF batched)
// mouseup/touchend → snap to the nearest edge if within SNAP_MARGIN px,
// then save the final position to CrossDomainStorage
//
// Drag threshold: 5 px for mouse, 15 px for touch (larger to survive normal
// finger wobble without accidentally triggering a drag on a tap).
//
// On mobile, touchend calls e.preventDefault() to suppress the synthetic
// 'click' the browser fires ~300 ms after a tap — without this, a tap would
// open the panel and then immediately close it again.
//
// Position persistence:
// Stored as distances from the nearest edges (horizontal/vertical) so the
// button re-appears in the same relative corner on any viewport size.
// ---------------------------------------------------------------------------
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._tagsContainer = null;
this._showInput = null; // "show only" filter input
this._showTagsContainer = null; // "show only" tag chips
this._styleSheet = null;
this._sections = []; // tracks all collapsible sections for state sync
this._features = this._buildFeatureDefinitions();
this._eventEmitter.on('autoscrollStateChanged', this._onAutoscrollStateChanged.bind(this));
}
// Public entry point. Builds all UI elements and appends them to <html>
// (not <body>, so they survive any body replacements by the site).
create() {
Utils.log('MenuManager: creating menu');
try {
this._styleSheet = this._addMenuStyles();
this._menu = this._createMenuContainer();
this._applyOpacityToMenuAndButton();
this._addCloseButton();
this._addGeneralSection();
this._addPlayerSection();
this._addSortingSection();
this._addManualButtons();
this._addFilteringSection();
this._addFilterSection();
this._addScrollButtons();
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;
}
.phpro-section-header {
cursor: pointer;
user-select: none;
display: flex;
justify-content: space-between;
align-items: center;
}
.phpro-section-header:hover {
background-color: #2a2a2a;
}
.phpro-section-arrow {
font-style: normal;
font-size: 13px;
transition: transform 0.2s;
margin-left: 8px;
flex-shrink: 0;
}
.phpro-section-content {
overflow: hidden;
}
.phpro-sort-row {
display: flex;
align-items: center;
margin-bottom: 10px;
width: 100%;
gap: 10px;
}
.phpro-sort-row label {
color: white;
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
}
.phpro-sort-select {
flex: 1;
background-color: #222;
color: white;
border: 1px solid #666;
border-radius: 6px;
padding: 4px 8px;
font-size: 13px;
cursor: pointer;
outline: none;
min-width: 0;
}
.phpro-sort-select:focus {
border-color: orange;
}
.phpro-sort-select option {
background-color: #222;
color: white;
}
`);
}
_createMenuContainer() {
return Utils.createElement('div', {
id: 'sideMenu',
style: {
position: 'fixed',
top: '5px',
left: '5px',
padding: '15px',
maxHeight: '90vh',
width: 'min-content',
minWidth: '240px',
backgroundColor: 'rgba(0,0,0,0.95)',
zIndex: '999999999',
display: 'none',
borderRadius: '10px',
border: '1px solid orange',
boxSizing: 'border-box',
overflowY: 'auto',
fontFamily: 'Arial, sans-serif',
fontSize: '13px',
boxShadow: '0 8px 25px rgba(0,0,0,0.8)',
},
});
}
// Applies the correct opacity based on the "Enable transparency" toggle.
// Called on create and whenever the toggle changes.
_applyOpacityToMenuAndButton() {
const isOpaque = this._state.get('opaqueMenuButtonState');
const opacity = isOpaque ? CONFIG.OPACITY.TRANSPARENT : CONFIG.OPACITY.DEFAULT;
if (this._toggleButton) {
this._toggleButton.style.opacity = opacity;
}
if (this._menu) {
this._menu.style.opacity = opacity;
}
Utils.log(`Menu opacity set to ${Math.round(opacity * 100)}%`);
}
// Restores the button to its saved position. Stored as edge distances so
// it scales correctly across different viewport sizes.
// `animate` adds a smooth CSS transition (used after window resize).
_applySavedPosition(button, animate = false) {
const MIN_DISTANCE = 12;
try {
const saved = CrossDomainStorage.getItem('phpro_menuButtonPos');
const preferRight = this._state.get('menuButtonRightSideState', false);
let left, top;
if (saved) {
const data = JSON.parse(saved);
const vw = window.innerWidth;
const vh = window.innerHeight;
if (data.horizontal === 'left') {
left = Math.max(MIN_DISTANCE, data.hDistance);
} else {
left = vw - button.offsetWidth - Math.max(MIN_DISTANCE, data.hDistance);
}
if (data.vertical === 'top') {
top = Math.max(MIN_DISTANCE, data.vDistance);
} else {
top = vh - button.offsetHeight - Math.max(MIN_DISTANCE, data.vDistance);
}
if (preferRight && (!saved || Date.now() - (data.timestamp || 0) < 2000)) {
left = vw - button.offsetWidth - MIN_DISTANCE;
}
} else {
left = preferRight ?
window.innerWidth - button.offsetWidth - MIN_DISTANCE :
MIN_DISTANCE;
top = MIN_DISTANCE;
}
left = Math.max(MIN_DISTANCE, Math.min(left, window.innerWidth - button.offsetWidth - MIN_DISTANCE));
top = Math.max(MIN_DISTANCE, Math.min(top, window.innerHeight - button.offsetHeight - MIN_DISTANCE));
if (animate) {
button.style.transition = 'left 0.6s cubic-bezier(0.25, 0.1, 0.25, 1), top 0.6s cubic-bezier(0.25, 0.1, 0.25, 1), opacity 0.25s';
} else {
button.style.transition = 'opacity 0.25s, background-color 0.25s';
}
button.style.left = `${left}px`;
button.style.top = `${top}px`;
button.style.transform = 'translate(0px, 0px)';
} catch (err) {
handleError('MenuManager._applySavedPosition', err, 'warn');
button.style.left = '12px';
button.style.top = '12px';
}
}
// Saves the button position as relative edge distances so it can be
// restored correctly at any viewport size.
_saveButtonPosition(button) {
try {
const rect = button.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const distLeft = rect.left;
const distRight = vw - rect.right;
const distTop = rect.top;
const distBottom = vh - rect.bottom;
const positionData = {
horizontal: distLeft < distRight ? 'left' : 'right',
vertical: distTop < distBottom ? 'top' : 'bottom',
hDistance: Math.min(distLeft, distRight),
vDistance: Math.min(distTop, distBottom),
timestamp: Date.now()
};
CrossDomainStorage.setItem('phpro_menuButtonPos', JSON.stringify(positionData));
Utils.log(`Menu button position saved: ${positionData.vertical}-${positionData.horizontal}`);
} catch (err) {
handleError('MenuManager._saveButtonPosition', err, 'warn');
}
}
// Returns the array of Feature objects defining every toggle in the menu.
// Each feature specifies its label, storage key, change handler, and category.
_buildFeatureDefinitions() {
return [
new Feature({
label: 'Auto-confirm age',
key: 'autoConfirmAgeState',
handler: () => {
if (this._state.get('autoConfirmAgeState')) {
AgeGate.run();
} else {
AgeGate.clear();
}
},
id: 'autoConfirmAgeToggle',
defaultState: false,
category: 'general',
}),
new Feature({
label: 'Always use English',
key: 'redirectToEnglishState',
handler: () => this._eventEmitter.emit('redirectToEnglish'),
id: 'redirectToEnglishToggle',
defaultState: true,
category: 'general',
}),
new Feature({
label: 'Enable transparancy',
key: 'opaqueMenuButtonState',
handler: () => {
this._applyOpacityToMenuAndButton();
},
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',
}),
// Sort-by mode is rendered as a dropdown, not individual toggles.
// See _addFeatureToggles → _createSortDropdown().
new Feature({
label: 'Mute by default',
key: 'muteState',
handler: () => {
if (this._state.get('muteState')) {
VideoPlayer.resetMuteState();
VideoPlayer.mute(true);
}
},
id: 'muteToggle',
defaultState: false,
category: 'player',
}),
new Feature({
label: 'Hide cursor on video',
key: 'cursorHideState',
id: 'cursorHideToggle',
defaultState: true,
category: 'player',
}),
new Feature({
label: 'Enable download button',
key: 'downloadButtonState',
id: 'downloadButtonToggle',
defaultState: false,
category: 'player',
}),
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 Feature({
label: 'Hide VR videos',
key: 'hideVRState',
handler: () => this._eventEmitter.emit('hideVideos'),
id: 'hideVRToggle',
defaultState: false,
category: 'filtering',
}),
new Feature({
label: 'Hide Shorts section',
key: 'hideShortsState',
handler: () => this._eventEmitter.emit('hideVideos'),
id: 'hideShortsToggle',
defaultState: false,
category: 'filtering',
}),
];
}
// Builds the "Sort by" dropdown row for the sorting category.
// Stores the selected value as a string under 'sortModeState' in
// CrossDomainStorage ('none' | 'duration' | 'trophy' | 'views').
// Selecting a mode immediately applies the sort; selecting 'none' does nothing.
_createSortDropdown() {
const row = Utils.createElement('div', {
className: 'phpro-sort-row'
});
const label = Utils.createElement('label', {
textContent: 'Auto-sort:'
});
const select = Utils.createElement('select', {
id: 'phpro-sort-select',
className: 'phpro-sort-select',
});
const options = [{
value: 'none',
label: 'Off'
},
{
value: 'duration',
label: 'By duration'
},
{
value: 'trophy',
label: 'By 🏆 trophy'
},
{
value: 'views',
label: 'By views'
},
];
const savedMode = CrossDomainStorage.getItem('sortModeState') ?? 'none';
for (const opt of options) {
const el = Utils.createElement('option', {
value: opt.value,
textContent: opt.label
});
if (opt.value === savedMode) el.selected = true;
select.appendChild(el);
}
select.addEventListener('change', () => {
const mode = select.value;
CrossDomainStorage.setItem('sortModeState', mode);
Utils.log(`Sort mode set to: ${mode}`);
if (mode === 'duration') this._eventEmitter.emit('sortByDuration');
else if (mode === 'trophy') this._eventEmitter.emit('sortByTrophy');
else if (mode === 'views') this._eventEmitter.emit('sortByViews');
});
row.appendChild(label);
row.appendChild(select);
return row;
}
// Creates a collapsible section: an orange header that toggles the content
// div on click. Collapse state is persisted in CrossDomainStorage so it
// survives page reloads and is synced when a tab regains focus.
// Returns the content div so the caller can populate it.
_createSection(title) {
const storageKey = `phpro_section_${title.replace(/\s+/g, '_').toLowerCase()}_collapsed`;
const isCollapsed = CrossDomainStorage.getItem(storageKey) === 'true';
const header = Utils.createElement('h3', {
className: 'phpro-category-header phpro-section-header',
});
const titleSpan = Utils.createElement('span', {
textContent: title
});
const arrow = Utils.createElement('span', {
textContent: isCollapsed ? '▸' : '▾',
className: 'phpro-section-arrow',
});
header.appendChild(titleSpan);
header.appendChild(arrow);
const content = Utils.createElement('div', {
className: 'phpro-section-content',
style: {
display: isCollapsed ? 'none' : 'block',
width: '100%'
},
});
header.addEventListener('click', () => {
const nowCollapsed = content.style.display !== 'none';
content.style.display = nowCollapsed ? 'none' : 'block';
arrow.textContent = nowCollapsed ? '▸' : '▾';
CrossDomainStorage.setItem(storageKey, String(nowCollapsed));
});
this._menu.appendChild(header);
this._menu.appendChild(content);
// Register so updateToggleStates() can sync this section across tabs.
this._sections.push({
storageKey,
content,
arrow
});
return content;
}
// Builds and appends the × close button (positioned absolute inside the panel).
_addCloseButton() {
const closeButton = Utils.createElement('div', {
textContent: '×',
style: {
position: 'absolute',
top: '12px',
right: '12px',
fontSize: '28px',
fontWeight: 'bold',
color: '#ff9800',
cursor: 'pointer',
lineHeight: '1',
width: '32px',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
backgroundColor: '#1e1e1e',
transition: 'all 0.2s ease',
zIndex: '10',
},
});
closeButton.addEventListener('mouseenter', () => {
closeButton.style.color = '#ff0000';
closeButton.style.transform = 'scale(1.15)';
});
closeButton.addEventListener('mouseleave', () => {
closeButton.style.color = '#ff9800';
closeButton.style.transform = 'scale(1)';
});
closeButton.addEventListener('click', (e) => {
e.stopPropagation();
this._hide();
});
this._menu.style.position = 'fixed';
this._menu.style.top = '40px';
this._menu.style.left = '5px';
this._menu.appendChild(closeButton);
}
// ── Per-category section builders ───────────────────────────────────────
// Each method creates a collapsible section for its feature category.
// To reorder or rename sections, edit create() and the title string here.
_addGeneralSection() {
const content = this._createSection('General');
for (const feature of this._features.filter(f => f.category === 'general')) {
content.appendChild(this._createToggleRow(feature));
}
}
_addSortingSection() {
const content = this._createSection('Sorting');
content.appendChild(this._createSortDropdown());
for (const feature of this._features.filter(f => f.category === 'sorting')) {
content.appendChild(this._createToggleRow(feature));
}
}
_addPlayerSection() {
const content = this._createSection('Player');
for (const feature of this._features.filter(f => f.category === 'player')) {
content.appendChild(this._createToggleRow(feature));
}
}
_addFilteringSection() {
const content = this._createSection('Filtering');
for (const feature of this._features.filter(f => f.category === 'filtering')) {
content.appendChild(this._createToggleRow(feature));
}
}
// Builds the autoscroll and scroll-to-top button inside a collapsible section.
_addScrollButtons() {
const content = this._createSection('Autoscroll');
content.appendChild(this._createAutoscrollButton());
content.appendChild(this._createScrollToTopButton());
}
// Builds the manual one-shot sort buttons inside a collapsible section.
_addManualButtons() {
const content = this._createSection('Manual Sort');
const manualButtons = [{
text: 'Sort by duration manually',
handler: () => this._eventEmitter.emit('sortByDuration', true)
},
{
text: 'Sort by views manually',
handler: () => this._eventEmitter.emit('sortByViews', true)
},
{
text: 'Put 🏆 first manually',
handler: () => this._eventEmitter.emit('sortByTrophy', true)
},
];
for (const {
text,
handler
}
of manualButtons) {
content.appendChild(this._createActionButton(text, handler));
}
}
// Builds the filter-words section inside a collapsible section.
// Contains two sub-blocks:
// 1. "Hide videos matching" — existing hide behaviour (savedFilterWords)
// 2. "Show only matching" — new inverse filter (savedShowWords)
_addFilterSection() {
const content = this._createSection('Filter by Words');
// ── Block 1: Hide videos matching ───────────────────────────────────────
const hideContainer = Utils.createElement('div', {
style: {
marginTop: '10px',
width: '100%',
display: 'flex',
flexDirection: 'column'
},
});
const hideLabel = Utils.createElement('label', {
textContent: 'Hide videos matching:',
style: {
color: 'white',
display: 'block',
marginBottom: '6px',
fontSize: '14px'
},
});
this._filterInput = Utils.createElement('input', {
type: 'text',
id: 'inputFilterWords',
placeholder: 'Type word(s) and press Enter or , to add',
style: {
display: 'block',
padding: '8px 12px',
border: '1px solid #666',
borderRadius: '5px',
fontSize: '14px',
backgroundColor: '#222',
color: 'white',
width: '100%',
boxSizing: 'border-box',
},
});
this._tagsContainer = Utils.createElement('div', {
style: {
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginTop: '8px',
minHeight: '0px'
},
});
hideContainer.appendChild(hideLabel);
hideContainer.appendChild(this._filterInput);
hideContainer.appendChild(this._tagsContainer);
content.appendChild(hideContainer);
this._loadAndRenderTags();
this._filterInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const value = this._filterInput.value.trim();
if (value) this._addTag(value);
}
});
this._filterInput.addEventListener('blur', () => {
const value = this._filterInput.value.trim();
if (value) this._addTag(value);
});
// ── Block 2: Show only matching ─────────────────────────────────────────
const showContainer = Utils.createElement('div', {
style: {
marginTop: '10px',
width: '100%',
display: 'flex',
flexDirection: 'column'
},
});
const showLabel = Utils.createElement('label', {
textContent: 'Show only matching:',
style: {
color: 'white',
display: 'block',
marginBottom: '6px',
fontSize: '14px'
},
});
this._showInput = Utils.createElement('input', {
type: 'text',
id: 'inputShowWords',
placeholder: 'Type word(s) and press Enter or , to add',
style: {
display: 'block',
padding: '8px 12px',
border: '1px solid #666',
borderRadius: '5px',
fontSize: '14px',
backgroundColor: '#222',
color: 'white',
width: '100%',
boxSizing: 'border-box',
},
});
this._showTagsContainer = Utils.createElement('div', {
style: {
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginTop: '8px',
minHeight: '0px'
},
});
showContainer.appendChild(showLabel);
showContainer.appendChild(this._showInput);
showContainer.appendChild(this._showTagsContainer);
content.appendChild(showContainer);
this._loadAndRenderShowTags();
this._showInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const value = this._showInput.value.trim();
if (value) this._addShowTag(value);
}
});
this._showInput.addEventListener('blur', () => {
const value = this._showInput.value.trim();
if (value) this._addShowTag(value);
});
}
// Reads saved filter words from storage and renders a chip for each one.
_loadAndRenderTags() {
const saved = CrossDomainStorage.getItem('savedFilterWords') ?? '';
const words = Utils.sanitizeFilterWords(saved);
this._tagsContainer.innerHTML = '';
words.forEach(word => this._createTagElement(word));
}
// Adds a new word to the filter list if not already present, saves it,
// renders a chip, and triggers a hide pass.
_addTag(word) {
word = word.trim().toLowerCase();
if (!word) return;
const currentWords = this._getCurrentFilterWords();
if (currentWords.includes(word)) {
this._filterInput.value = '';
return;
}
currentWords.push(word);
this._saveFilterWords(currentWords);
this._createTagElement(word);
this._filterInput.value = '';
this._eventEmitter.emit('hideVideos');
}
// Creates an orange pill chip for a filter word with an × remove button.
_createTagElement(word) {
const tag = Utils.createElement('div', {
style: {
display: 'inline-flex',
alignItems: 'center',
backgroundColor: '#ff9800',
color: 'black',
padding: '4px 10px',
borderRadius: '20px',
fontSize: '13px',
fontWeight: 'bold',
whiteSpace: 'nowrap',
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
},
});
const text = Utils.createElement('span', {
textContent: word,
style: {
marginRight: '6px'
}
});
const removeBtn = Utils.createElement('span', {
textContent: '×',
style: {
cursor: 'pointer',
fontSize: '16px',
fontWeight: 'bold',
lineHeight: '1',
padding: '0 2px',
},
});
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this._removeTag(word, tag);
});
tag.appendChild(text);
tag.appendChild(removeBtn);
this._tagsContainer.appendChild(tag);
}
// Removes a word from storage, removes its chip, and re-runs the hide pass.
_removeTag(wordToRemove, tagElement) {
const currentWords = this._getCurrentFilterWords()
.filter(w => w !== wordToRemove);
this._saveFilterWords(currentWords);
tagElement.remove();
this._eventEmitter.emit('hideVideos');
}
_getCurrentFilterWords() {
const saved = CrossDomainStorage.getItem('savedFilterWords') ?? '';
return Utils.sanitizeFilterWords(saved);
}
_saveFilterWords(wordsArray) {
const newValue = wordsArray.join(', ');
CrossDomainStorage.setItem('savedFilterWords', newValue);
}
// ── Show-only filter methods ────────────────────────────────────────────
// Reads saved show-only words from storage and renders a chip for each one.
_loadAndRenderShowTags() {
const saved = CrossDomainStorage.getItem('savedShowWords') ?? '';
const words = Utils.sanitizeFilterWords(saved);
this._showTagsContainer.innerHTML = '';
words.forEach(word => this._createShowTagElement(word));
}
// Adds a new word to the show-only list if not already present.
_addShowTag(word) {
word = word.trim().toLowerCase();
if (!word) return;
const currentWords = this._getCurrentShowWords();
if (currentWords.includes(word)) {
this._showInput.value = '';
return;
}
currentWords.push(word);
this._saveShowWords(currentWords);
this._createShowTagElement(word);
this._showInput.value = '';
this._eventEmitter.emit('hideVideos');
}
// Creates a blue pill chip for a show-only word with an × remove button.
// Blue colour distinguishes it clearly from the orange hide-filter chips.
_createShowTagElement(word) {
const tag = Utils.createElement('div', {
style: {
display: 'inline-flex',
alignItems: 'center',
backgroundColor: '#1565c0',
color: 'white',
padding: '4px 10px',
borderRadius: '20px',
fontSize: '13px',
fontWeight: 'bold',
whiteSpace: 'nowrap',
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
},
});
const text = Utils.createElement('span', {
textContent: word,
style: {
marginRight: '6px'
},
});
const removeBtn = Utils.createElement('span', {
textContent: '×',
style: {
cursor: 'pointer',
fontSize: '16px',
fontWeight: 'bold',
lineHeight: '1',
padding: '0 2px'
},
});
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this._removeShowTag(word, tag);
});
tag.appendChild(text);
tag.appendChild(removeBtn);
this._showTagsContainer.appendChild(tag);
}
// Removes a show-only word from storage and re-runs the hide pass.
_removeShowTag(wordToRemove, tagElement) {
const currentWords = this._getCurrentShowWords().filter(w => w !== wordToRemove);
this._saveShowWords(currentWords);
tagElement.remove();
this._eventEmitter.emit('hideVideos');
}
_getCurrentShowWords() {
const saved = CrossDomainStorage.getItem('savedShowWords') ?? '';
return Utils.sanitizeFilterWords(saved);
}
_saveShowWords(wordsArray) {
CrossDomainStorage.setItem('savedShowWords', wordsArray.join(', '));
}
// Builds a single toggle row: pill-shaped track with sliding thumb + label.
// Both the track and label are clickable. Track carries the feature id.
_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;
}
// Creates the draggable floating "☰ Menu" button.
// See the MenuManager class comment for the full drag/tap event flow.
_createToggleButton() {
const button = Utils.createElement('div', {
id: 'menuToggle',
textContent: '☰ Menu',
style: {
position: 'fixed',
left: '12px',
top: '12px',
fontSize: '13px',
color: 'orange',
cursor: 'grab',
zIndex: '999999998',
padding: '8px 15px',
backgroundColor: 'rgba(0, 0, 0, 0.9)',
border: '2px solid orange',
borderRadius: '9999px',
fontWeight: 'bold',
fontFamily: 'Arial, sans-serif',
userSelect: 'none',
boxShadow: '0 4px 15px rgba(0,0,0,0.7)',
opacity: this._state.get('opaqueMenuButtonState', false) ? '0.45' : '1',
transition: 'opacity 0.25s, background-color 0.25s',
willChange: 'transform',
whiteSpace: 'nowrap',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 'auto',
boxSizing: 'border-box',
},
});
const applyInitialPosition = () => {
this._applySavedPosition(button, false);
};
applyInitialPosition();
requestAnimationFrame(applyInitialPosition);
window.addEventListener('load', applyInitialPosition, {
once: true
});
button.addEventListener('click', e => {
if (this._wasDragged) return;
e.stopPropagation();
this._togglePanel();
});
button.addEventListener('mouseenter', () => {
button.style.backgroundColor = 'rgba(255, 140, 0, 0.3)';
if (this._state.get('opaqueMenuButtonState')) {
button.style.opacity = CONFIG.OPACITY.HOVER;
} else {
button.style.opacity = '1';
}
});
button.addEventListener('mouseleave', () => {
button.style.backgroundColor = 'rgba(0, 0, 0, 0.9)';
if (this._state.get('opaqueMenuButtonState')) {
button.style.opacity = CONFIG.OPACITY.TRANSPARENT;
} else {
button.style.opacity = '1';
}
});
let isDragging = false;
let rafId = null;
let startX = 0,
startY = 0;
let baseLeft = 0,
baseTop = 0;
let currentTx = 0,
currentTy = 0;
this._wasDragged = false;
const SNAP_MARGIN = 90;
const snapToEdge = (rawLeft, rawTop) => {
const vw = window.innerWidth;
const vh = window.innerHeight;
const rect = button.getBoundingClientRect();
const w = rect.width;
const h = rect.height;
let finalLeft = rawLeft;
let finalTop = rawTop;
if (rawLeft < SNAP_MARGIN) finalLeft = 12;
else if (rawLeft > vw - w - SNAP_MARGIN) finalLeft = vw - w - 12;
if (rawTop < SNAP_MARGIN) finalTop = 12;
else if (rawTop > vh - h - SNAP_MARGIN) finalTop = vh - h - 12;
return {
left: finalLeft,
top: finalTop
};
};
const applyTransform = () => {
button.style.transform = `translate(${currentTx}px, ${currentTy}px)`;
};
// Drag threshold: mouse is precise (5px), touch needs more
// room to avoid normal finger wobble being mistaken for a drag
const MOUSE_DRAG_THRESHOLD = 5;
const TOUCH_DRAG_THRESHOLD = 15;
const onDragStart = (clientX, clientY) => {
this._wasDragged = false;
isDragging = false;
startX = clientX;
startY = clientY;
const rect = button.getBoundingClientRect();
button.style.width = `${rect.width}px`;
button.style.height = `${rect.height}px`;
baseLeft = parseFloat(getComputedStyle(button).left) || 12;
baseTop = parseFloat(getComputedStyle(button).top) || 12;
button.style.transition = 'none';
button.style.transform = 'translate(0px, 0px)';
};
const onDragMove = (clientX, clientY, threshold) => {
const dx = clientX - startX;
const dy = clientY - startY;
if (!isDragging && (Math.abs(dx) > threshold || Math.abs(dy) > threshold)) {
isDragging = true;
this._wasDragged = true;
button.style.cursor = 'grabbing';
}
if (isDragging) {
currentTx = dx;
currentTy = dy;
if (rafId === null) {
rafId = requestAnimationFrame(() => {
applyTransform();
rafId = null;
});
}
}
};
const onDragEnd = () => {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (isDragging) {
const rawLeft = baseLeft + currentTx;
const rawTop = baseTop + currentTy;
const snapped = snapToEdge(rawLeft, rawTop);
button.style.width = '';
button.style.height = '';
button.style.transition = 'opacity 0.25s, background-color 0.25s';
button.style.left = `${snapped.left}px`;
button.style.top = `${snapped.top}px`;
button.style.transform = 'translate(0px, 0px)';
this._saveButtonPosition(button);
}
button.style.cursor = 'grab';
button.style.backgroundColor = 'rgba(0, 0, 0, 0.9)';
isDragging = false;
};
// --- Mouse drag ---
button.addEventListener('mousedown', e => {
if (e.button !== 0) return;
onDragStart(e.clientX, e.clientY);
const onMouseMove = (moveEvent) => onDragMove(moveEvent.clientX, moveEvent.clientY, MOUSE_DRAG_THRESHOLD);
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
onDragEnd();
};
document.addEventListener('mousemove', onMouseMove, {
passive: true
});
document.addEventListener('mouseup', onMouseUp);
});
// --- Touch drag ---
// Uses a larger threshold so normal tap wobble doesn't register as a drag
button.addEventListener('touchstart', e => {
if (e.touches.length !== 1) return;
const t = e.touches[0];
onDragStart(t.clientX, t.clientY);
}, {
passive: true
});
button.addEventListener('touchmove', e => {
if (e.touches.length !== 1) return;
const t = e.touches[0];
onDragMove(t.clientX, t.clientY, TOUCH_DRAG_THRESHOLD);
if (isDragging) e.preventDefault();
}, {
passive: false
});
button.addEventListener('touchend', e => {
const wasDragged = this._wasDragged;
onDragEnd();
if (!wasDragged) {
e.preventDefault(); // suppress the synthetic click that fires ~300ms later
this._togglePanel();
}
}, {
passive: false
});
window.addEventListener('resize', Utils.debounce(() => {
const btn = document.getElementById('menuToggle');
if (btn) this._applySavedPosition(btn, true);
}, 300));
return button;
}
// Creates a generic action button that flashes orange briefly on click.
_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;
}
// Creates the Start/Stop Autoscroll button. Label and colour are updated
// by _onAutoscrollStateChanged when the running state changes.
_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;
}
// Creates the "Scroll to top" 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;
}
// Called when a toggle is clicked. Updates state, animates the track/thumb,
// then calls the feature handler via setTimeout so the UI paint happens first.
_handleToggleClick(feature, track, thumb) {
try {
const newState = this._state.toggle(feature.key);
track.style.backgroundColor = newState ? 'orange' : '#666';
thumb.style.left = newState ? '22px' : '2px';
setTimeout(() => feature.handler(), 0);
} catch (err) {
handleError(`MenuManager._handleToggleClick("${feature.key}")`, err);
}
}
// Updates the autoscroll button label and colour to reflect running state.
// Subscribed to 'autoscrollStateChanged' in the constructor.
_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';
}
}
// Toggles the panel open/closed. `force` true/false overrides the current
// state (used by the dismiss handler to always close).
_togglePanel(force) {
const willOpen = force !== undefined ? force : this._menu.style.display === 'none';
willOpen ? this._show() : this._hide();
}
_show() {
if (!this._menu) return;
this._menu.style.display = 'block';
this._panelOpen = true;
}
_hide() {
if (!this._menu) return;
this._menu.style.display = 'none';
this._panelOpen = false;
}
_toggleVisibility() {
this._togglePanel();
}
// Closes the panel when the user clicks or taps outside it.
// Listens on both mousedown (desktop) and touchstart (mobile).
_setupPanelDismiss() {
const handler = e => {
if (!this._panelOpen) return;
const clickedInsideMenu = this._menu.contains(e.target);
const clickedToggleButton = e.target === this._toggleButton ||
this._toggleButton.contains(e.target);
if (!clickedInsideMenu && !clickedToggleButton) {
this._togglePanel(false);
}
};
document.addEventListener('mousedown', handler);
document.addEventListener('touchstart', handler, {
passive: true
});
}
// Re-reads all toggle states directly from CrossDomainStorage (bypassing the
// StateManager cache entirely) and updates the UI to match.
// Called when the tab becomes visible so changes made in another tab are
// always reflected without relying on the cache being fresh.
updateToggleStates() {
try {
for (const feature of this._features) {
const track = document.getElementById(feature.id);
if (!track) continue;
// Read straight from storage — skips the in-memory cache so another
// tab's writes are picked up immediately on visibility restore.
const raw = CrossDomainStorage.getItem(feature.key);
const isActive = raw !== null ? raw === 'true' : feature.defaultState;
const thumb = track.querySelector('div');
track.style.backgroundColor = isActive ? 'orange' : '#666';
if (thumb) thumb.style.left = isActive ? '22px' : '2px';
}
// Sync the sort dropdown
const select = document.getElementById('phpro-sort-select');
if (select) {
select.value = CrossDomainStorage.getItem('sortModeState') ?? 'none';
}
// Sync section collapse states — picks up changes made in another tab.
for (const section of this._sections) {
const isCollapsed = CrossDomainStorage.getItem(section.storageKey) === 'true';
section.content.style.display = isCollapsed ? 'none' : 'block';
section.arrow.textContent = isCollapsed ? '▸' : '▾';
}
// Sync filter tag chips — re-render both lists from storage.
this._loadAndRenderTags();
this._loadAndRenderShowTags();
} catch (err) {
handleError('MenuManager.updateToggleStates', err);
}
}
// Removes the injected stylesheet. Called by App._cleanup().
cleanup() {
this._styleSheet?.remove();
}
// Adds orange hover colour, guarding against overriding the red "active"
// state of the autoscroll button.
_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
//
// Top-level controller that wires all feature classes together and manages
// the DOM observer that watches for new video items added by infinite scroll.
//
// Initialisation sequence (App.init):
// 1. Hide unwanted site elements
// 2. Start language redirect if enabled
// 3. Apply cursor-hide CSS if enabled
// 4. Initialise PlaylistManager (attaches delete-overlay listener)
// 5. Build the menu UI
// 6. Set the age-gate cookie if enabled
// 7. Initialise the download button if enabled
// 8. Run all active features after a short delay
// 9. Start the MutationObserver
// 10. Set up window-level listeners (visibility, load, unload, errors)
//
// MutationObserver strategy:
// Watches video list <ul> containers when available, falls back to
// document.body. When new <li> items are added, runs an incremental hide
// pass on just those nodes, then debounces a full feature re-run. If new
// containers appear it re-scopes to watch them directly. During autoscroll
// the observer is paused to avoid fighting with the fetch loop.
// ---------------------------------------------------------------------------
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();
}
// Registers boolean validators for every known state key so StateManager
// rejects non-boolean writes before they reach storage.
_setupStateValidators() {
const boolKeys = [
'sortWithinPlaylistsState', 'sortByTrophyState', 'sortByDurationState',
'sortByViewsState',
'hideWatchedState', 'hidePaidContentState', 'redirectToEnglishState',
'muteState', 'cursorHideState',
'hideVRState', 'hideShortsState',
'opaqueMenuButtonState',
'autoConfirmAgeState',
'menuButtonRightSideState',
'downloadButtonState',
];
for (const key of boolKeys) {
this._state.addValidator(key, v => typeof v === 'boolean');
}
}
// Wires up all EventEmitter listeners — the central event routing table.
_setupEventHandlers() {
this._eventEmitter.on('sortByTrophy', data => this._videoSorter.sortByTrophy(data === true));
this._eventEmitter.on('sortByDuration', data => this._videoSorter.sortByDuration(data === true));
this._eventEmitter.on('sortByViews', data => this._videoSorter.sortByViews(data === true));
this._eventEmitter.on('hideVideos', () => this._videoHider.hideVideos());
this._eventEmitter.on('redirectToEnglish', () => this._languageManager.redirectToEnglish());
this._eventEmitter.on('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 {
this._scrollToTop.scrollToTop();
Utils.log('App: autoscroll stopped, running features & resuming observer');
this._initializeFeatures();
this._setupObserver();
}
});
}
// Main initialisation — called once the DOM is ready.
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();
if (this._state.get('autoConfirmAgeState', false)) {
AgeGate.set();
}
// Kick off download button if already enabled (e.g. on video pages)
DownloadManager.init(this._state);
setTimeout(() => this._initializeFeatures(), CONFIG.TIMING.FEATURE_INIT_DELAY_MS);
this._setupObserver();
this._setupWindowListeners();
Utils.log('App: initialized successfully');
} catch (err) {
handleError('App.init', err);
}
}
// Applies all currently-enabled features to the page.
// Called on init and after DOM mutations settle (via _debouncedInit).
_initializeFeatures() {
try {
if (this._state.get('sortByTrophyState')) this._videoSorter.sortByTrophy();
if (this._state.get('sortByDurationState')) this._videoSorter.sortByDuration();
if (this._state.get('sortByViewsState')) this._videoSorter.sortByViews();
// New unified sort mode — takes precedence over legacy boolean keys
const sortMode = CrossDomainStorage.getItem('sortModeState') ?? 'none';
if (sortMode === 'duration') this._videoSorter.sortByDuration();
else if (sortMode === 'trophy') this._videoSorter.sortByTrophy();
else if (sortMode === 'views') this._videoSorter.sortByViews();
if (
this._state.get('hideWatchedState') ||
this._state.get('hidePaidContentState') ||
this._state.get('hideVRState') ||
this._state.get('hideShortsState')
) {
this._videoHider.hideVideos();
}
if (this._state.get('muteState')) VideoPlayer.mute();
Utils.log('App: features initialized');
} catch (err) {
handleError('App._initializeFeatures', err);
}
}
// Returns all ul.videos elements currently in the DOM.
_getScopeTargets() {
return Utils.safeQuerySelectorAll('ul.videos');
}
// Counts total <li> items across a set of root elements.
// Used to detect when the site has added or removed video items.
_countLisIn(roots) {
return roots.reduce((sum, root) => sum + root.querySelectorAll('li').length, 0);
}
// Sets up (or re-scopes) the MutationObserver. Prefers specific video
// list containers for efficiency; falls back to document.body if none exist.
_setupObserver() {
try {
this._observer?.disconnect();
this._observedTargets.clear();
const scopeTargets = this._getScopeTargets();
const observeBody = scopeTargets.length === 0;
const roots = observeBody ? [document.body] : scopeTargets;
for (const el of roots) this._observedTargets.add(el);
this._lastLiCount = this._countLisIn(roots);
const observeOptions = {
childList: true,
subtree: true,
attributes: false,
characterData: false,
};
this._observer = new MutationObserver(
Utils.throttle(
mutations => this._onMutations(mutations, observeBody),
CONFIG.TIMING.OBSERVER_THROTTLE_MS
)
);
for (const root of roots) {
this._observer.observe(root, observeOptions);
}
Utils.log(
observeBody ?
'App: observer watching document.body (fallback — no containers found yet)' :
`App: observer scoped to ${roots.length} container(s)`
);
} catch (err) {
handleError('App._setupObserver', err);
}
}
// MutationObserver callback.
// Case 1: new video list containers appeared → re-scope the observer.
// Case 2: <li> count changed → incremental hide pass + debounced full re-run.
_onMutations(mutations, watchingBody) {
try {
const addedNodes = mutations.flatMap(m =>
Array.from(m.addedNodes).filter(n => n.nodeType === Node.ELEMENT_NODE)
);
const newTargets = this._getScopeTargets().filter(
el => !this._observedTargets.has(el)
);
if (newTargets.length > 0) {
Utils.log(`App: ${newTargets.length} new scopeable container(s) found — re-scoping observer`);
this._setupObserver();
this._debouncedInit();
return;
}
const observedRoots = Array.from(this._observedTargets);
const currentLiCount = this._countLisIn(observedRoots);
if (currentLiCount !== this._lastLiCount) {
Utils.log(`App: li count changed ${this._lastLiCount} → ${currentLiCount}`);
this._lastLiCount = currentLiCount;
if (
addedNodes.length > 0 &&
(
this._state.get('hideWatchedState') ||
this._state.get('hidePaidContentState') ||
this._state.get('hideVRState')
)
) {
this._videoHider.hideVideos(addedNodes);
}
this._debouncedInit();
}
} catch (err) {
handleError('App._onMutations', err);
}
}
// Registers window-level listeners needed for the lifetime of the page.
_setupWindowListeners() {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
Utils.log('App: tab visible — syncing state');
try {
this._state.clearCache();
this._menu.updateToggleStates();
const menuButton = document.getElementById('menuToggle');
if (menuButton) {
this._menu._applySavedPosition(menuButton, true);
}
setTimeout(() => this._initializeFeatures(), CONFIG.TIMING.FEATURE_INIT_DELAY_MS);
} catch (err) {
handleError('App.visibilitychange', err);
}
} else {
VideoPlayer.resetMuteState();
}
});
window.addEventListener('load', () => {
setTimeout(() => ElementHider.hideElements(), CONFIG.TIMING.ELEMENT_HIDE_LOAD_DELAY_MS);
});
window.addEventListener('beforeunload', () => {
this._cleanup();
});
window.addEventListener('error', event => {
if (event.filename?.includes('Pornhub Pro-ish')) {
handleError('window.onerror', new Error(event.message));
}
});
}
// Tears down observers, stops autoscroll, removes menu, clears listeners.
// Called on beforeunload to avoid memory leaks.
_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
//
// Entry point. Creates the App and calls init() either immediately (if the
// DOM is already parsed) or after DOMContentLoaded. Wrapped in try/catch so
// a fatal startup error is logged clearly rather than silently swallowed.
// ---------------------------------------------------------------------------
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();
})();