Restores native context menus, hides known intrusive ad UI, consolidates chunked video grids.
// ==UserScript==
// @name PMVHaven
// @namespace https://pmvhaven.com/
// @version 1.3.0
// @description Restores native context menus, hides known intrusive ad UI, consolidates chunked video grids.
// @match https://pmvhaven.com/*
// @match https://*.pmvhaven.com/*
// @run-at document-start
// @sandbox raw
// @noframes
// @author Cat-Ling
// @license MIT
// ==/UserScript==
(() => {
'use strict';
const STYLE_ID = 'pmvhaven-optimizer-styles-v110';
const BLOCKED_MENU_MARKER = 'data-pmvhaven-hidden-context-menu';
/*
* Evidence-backed menu fingerprint from PMVHaven's captured VideoCard chunk:
* div.fixed ... z-[100] min-w-[200px] overflow-hidden
* > a[target="_blank"][rel~="noopener"]
* > span "Open in new tab"
*
* Vue attaches its own `contextmenu` listener with a prevent modifier. The
* window capture listener below stops that listener before it can cancel the
* browser default. The DOM/CSS fallback catches the menu if another handler,
* long-press path, or site update still manages to create it.
*/
const VIDEO_CONTEXT_MENU_SELECTOR =
'div.fixed[class~="z-[100]"][class~="min-w-[200px]"].overflow-hidden:has(> a[target="_blank"][rel~="noopener"] > span)';
const AD_SELECTORS = [
// Confirmed from the captured NativeAdCard / InlineScrollBanner chunks.
'a.native-ad-card:not([data-video-id])',
'a.ad-link:not([data-video-id])',
'a[rel~="sponsored"]:not([data-video-id])',
'a.inline-scroll-banner:not([data-video-id])',
// Site-specific header/mobile ad variants retained from v1.0.0.
'a.undress-ai-animated-btn:not([data-video-id])',
'a[href*="happyleafmotion.com"]:not([data-video-id])',
'a[href*="s.eunow4u.com"]:not([data-video-id])',
'a[href*="rr.rndomnm.com"]:not([data-video-id])',
'a[href*="fluxion-lab"]:not([data-video-id])',
'a[href*="realxxx.com"]:not([data-video-id])',
'.mobile-undress-ad-container',
'.mobile-undress-ad-banner',
'.mobile-header-ad',
'.ads-container',
'.video-player-banner',
'.sidebar-ad-container',
'.sidebar-ad-wrapper',
'.sidebar-ad-iframe',
'.ad-banner',
'.ad-content-mobile',
// Known ad/creative iframe hosts retained as defensive fallbacks.
'iframe[src*="sadbaguette.com"]',
'iframe[src*="clearsky-rhythm.com"]',
'iframe[src*="exacdn.com"]',
'iframe[src*="happyleafmotion.com"]',
'iframe[src*="fluxion-lab"]',
'iframe[src*="rr.rndomnm.com"]',
'iframe[src*="s.eunow4u.com"]',
'iframe[src*="realxxx.com"]'
];
const AD_CLICK_SELECTOR = [
'a.native-ad-card',
'a.ad-link',
'a[rel~="sponsored"]',
'a.inline-scroll-banner',
'a.undress-ai-animated-btn',
'a[href*="happyleafmotion.com"]',
'a[href*="s.eunow4u.com"]',
'a[href*="rr.rndomnm.com"]',
'a[href*="fluxion-lab"]',
'a[href*="realxxx.com"]',
'[class*="native-ad"]',
'[class*="inline-scroll-banner"]'
].join(',');
const GRID_WRAPPER_SELECTOR = [
'div:has(> .grid[class~="cols-4-1500"])',
'div:has(> .grid[class~="cols-3-1200"])'
].join(',\n');
const GRID_CHUNK_SELECTOR = [
'div:has(> .grid[class~="cols-4-1500"]) > .grid',
'div:has(> .grid[class~="cols-3-1200"]) > .grid'
].join(',\n');
const GRID_SPACER_SELECTOR = [
'div:has(> .grid[class~="cols-4-1500"]) > div.my-6:not(:has([data-video-id]))',
'div:has(> .grid[class~="cols-3-1200"]) > div.my-6:not(:has([data-video-id]))'
].join(',\n');
const css = `
/* Hide known ad UI without hiding real video cards. */
${AD_SELECTORS.join(',\n ')} {
display: none !important;
visibility: hidden !important;
pointer-events: none !important;
}
/* The site uses a custom Vue/Teleport menu, not a Radix/Reka menu. */
${VIDEO_CONTEXT_MENU_SELECTOR},
[data-reka-context-menu-content],
[data-radix-context-menu-content] {
display: none !important;
visibility: hidden !important;
pointer-events: none !important;
}
/* Merge the split video-grid chunks into one responsive grid. */
${GRID_WRAPPER_SELECTOR} {
display: grid !important;
grid-template-columns: repeat(1, minmax(0, 1fr)) !important;
gap: 1rem !important;
align-items: stretch !important;
}
@media (min-width: 640px) {
${GRID_WRAPPER_SELECTOR} {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
}
}
@media (min-width: 1200px) and (max-width: 1499.98px) {
${GRID_WRAPPER_SELECTOR} {
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
}
}
@media (min-width: 1500px) {
${GRID_WRAPPER_SELECTOR} {
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
}
}
/* Let individual video cards participate directly in the outer grid. */
${GRID_CHUNK_SELECTOR} {
display: contents !important;
contain: none !important;
}
/* Only remove the empty inter-chunk spacer inside the affected wrapper. */
${GRID_SPACER_SELECTOR} {
display: none !important;
margin: 0 !important;
padding: 0 !important;
height: 0 !important;
min-height: 0 !important;
}
/* Reduce backdrop-filter work on video-card overlays only. Do not reset
transforms or global transitions: the site uses those for card/preview UI. */
a[data-video-id] [class*="backdrop-blur"] {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
`;
function injectStyles() {
if (document.getElementById(STYLE_ID)) return;
const target = document.head || document.documentElement;
if (!target) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = css;
target.appendChild(style);
}
// Do not call preventDefault(): that would suppress the native browser menu.
function restoreNativeContextMenu(event) {
event.stopImmediatePropagation();
}
window.addEventListener('contextmenu', restoreNativeContextMenu, true);
// Defensive fallback for synthetic/non-composed events that do not reach window.
document.addEventListener('contextmenu', restoreNativeContextMenu, true);
// Keep core menu restoration alive even if a browser/manager blocks API hooks.
try {
blockObservedPopunderRequest();
} catch (error) {
console.warn('[PMVHaven] Could not install the narrow popunder request guard:', error);
}
function normalizeText(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function hideSiteVideoContextMenu(anchor) {
if (!(anchor instanceof HTMLAnchorElement)) return;
if (normalizeText(anchor.textContent) !== 'Open in new tab') return;
if (anchor.target !== '_blank' || !anchor.relList.contains('noopener')) return;
let url;
try {
url = new URL(anchor.href, location.href);
} catch {
return;
}
// VideoCard links in the captured site use /video/<slug>_<id> routes.
if (url.origin !== location.origin || !url.pathname.startsWith('/video/')) return;
let candidate = anchor.parentElement;
for (let depth = 0; candidate && depth < 4; depth += 1, candidate = candidate.parentElement) {
const classes = candidate.classList;
if (
classes.contains('fixed') &&
classes.contains('overflow-hidden') &&
classes.contains('z-[100]') &&
classes.contains('min-w-[200px]')
) {
if (!candidate.hasAttribute(BLOCKED_MENU_MARKER)) {
candidate.setAttribute(BLOCKED_MENU_MARKER, '');
candidate.setAttribute('aria-hidden', 'true');
candidate.setAttribute('inert', '');
candidate.style.setProperty('display', 'none', 'important');
candidate.style.setProperty('visibility', 'hidden', 'important');
candidate.style.setProperty('pointer-events', 'none', 'important');
}
return;
}
}
}
function inspectForMenus(root) {
if (!(root instanceof Element || root instanceof Document)) return;
if (root instanceof HTMLAnchorElement) hideSiteVideoContextMenu(root);
if (typeof root.querySelectorAll === 'function') {
root.querySelectorAll('a[target="_blank"][rel~="noopener"]').forEach(hideSiteVideoContextMenu);
}
}
/*
* The captured network trace records GET https://s.eunow4u.com/venor.php
* from a preparePop initiator while the site's ad config enables an
* inline-v6 popunder. Block only that exact observed endpoint, not the whole
* host or ordinary fetch/XHR traffic. @sandbox raw makes these hooks run in
* the page's JS world in Tampermonkey, where the site's requests originate.
*/
function isObservedPopunderEndpoint(candidate) {
try {
const raw = candidate instanceof Request ? candidate.url : String(candidate);
const url = new URL(raw, location.href);
return url.hostname.toLowerCase() === 's.eunow4u.com' && url.pathname === '/venor.php';
} catch {
return false;
}
}
function blockObservedPopunderRequest() {
const nativeFetch = window.fetch;
if (typeof nativeFetch === 'function') {
window.fetch = function pmvhavenFetchGuard(input, init) {
if (isObservedPopunderEndpoint(input)) {
return Promise.reject(new DOMException('Blocked known PMVHaven popunder endpoint', 'AbortError'));
}
return Reflect.apply(nativeFetch, this, arguments);
};
}
const xhrPrototype = XMLHttpRequest.prototype;
const nativeOpen = xhrPrototype.open;
const nativeSend = xhrPrototype.send;
const blockedRequests = new WeakSet();
xhrPrototype.open = function pmvhavenXHROpenGuard(method, url, ...rest) {
if (isObservedPopunderEndpoint(url)) blockedRequests.add(this);
else blockedRequests.delete(this);
return Reflect.apply(nativeOpen, this, [method, url, ...rest]);
};
xhrPrototype.send = function pmvhavenXHRSendGuard(...args) {
if (blockedRequests.has(this)) {
blockedRequests.delete(this);
// XHR.open does not send anything; returning here prevents the network
// request entirely. Emit the failure events that an ad-specific network
// error would normally produce, without inventing a successful response.
const xhr = this;
queueMicrotask(() => {
try {
xhr.dispatchEvent(new ProgressEvent('error', {
lengthComputable: false,
loaded: 0,
total: 0
}));
xhr.dispatchEvent(new ProgressEvent('loadend', {
lengthComputable: false,
loaded: 0,
total: 0
}));
} catch {
// The request is already suppressed; event synthesis is best-effort.
}
});
return undefined;
}
return Reflect.apply(nativeSend, this, args);
};
}
function interceptAdClicks(event) {
const target = event.target instanceof Element
? event.target
: event.target instanceof Node
? event.target.parentElement
: null;
if (!target) return;
// Explicitly protect legitimate video cards, including their nested controls.
if (target.closest('a[data-video-id]')) return;
const ad = target.closest(AD_CLICK_SELECTOR);
if (!ad || ad.hasAttribute('data-video-id')) return;
event.preventDefault();
event.stopImmediatePropagation();
}
window.addEventListener('click', interceptAdClicks, true);
// Handles Vue/Teleport menus and ads that appear after the initial page render.
const observer = new MutationObserver((records) => {
injectStyles();
for (const record of records) {
for (const node of record.addedNodes) inspectForMenus(node);
}
});
observer.observe(document, { childList: true, subtree: true });
injectStyles();
inspectForMenus(document);
})();