The last userscript you'll ever need to disable autoplay videos on news sites and elsewhere
当前为
// ==UserScript==
// @name Ultimate Autoplay Blocker
// @namespace https://www.androidacy.com/
// @version 3.0.0
// @description The last userscript you'll ever need to disable autoplay videos on news sites and elsewhere
// @author Androidacy (improved)
// @include *
// @icon https://www.androidacy.com/wp-content/uploads/cropped-cropped-cropped-cropped-New-Project-32-69C2A87-1-192x192.jpg
// @grant none
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
// Configuration
const config = {
debug: false,
logPrefix: '[AutoplayBlock]',
interactionProximity: 64, // px distance to consider valid for interaction
};
// Debug logger
const logger = {
log: (...args) => config.debug && console.log(config.logPrefix, ...args),
warn: (...args) => config.debug && console.warn(config.logPrefix, ...args),
error: (...args) => console.error(config.logPrefix, ...args)
};
// WeakMap to track user interaction status for each media element
const mediaInteractionMap = new WeakMap();
// WeakSet to track elements we've already processed to avoid duplicate work
const processedElements = new WeakSet();
// Track initialization status
let initialized = false;
// Store original prototypes to ensure we can revert if needed
const originalPrototypes = {
play: HTMLMediaElement.prototype.play,
setAttribute: Element.prototype.setAttribute,
addEventListener: EventTarget.prototype.addEventListener
};
// --------------------------------------------------
// Helper functions
// --------------------------------------------------
/**
* Safely executes a function handling any errors
* @param {Function} fn - Function to execute
* @param {string} errorMsg - Error message if it fails
*/
const safeExecute = (fn, errorMsg) => {
try {
return fn();
} catch (e) {
logger.error(errorMsg, e);
return null;
}
};
/**
* Check if an element is or contains a media player
* @param {HTMLElement} element - Element to check
* @returns {boolean} - True if element is/contains media
*/
const isOrContainsMediaElement = (element) => {
if (!element || !element.tagName) return false;
if (element.tagName.toLowerCase() === 'video' || element.tagName.toLowerCase() === 'audio') {
return true;
}
// Check for common video player classes/patterns
const classNameLower = (element.className || '').toLowerCase();
const idLower = (element.id || '').toLowerCase();
const videoPlayerPatterns = ['player', 'video', 'media', 'youtube', 'vimeo', 'jwplayer', 'videojs'];
for (const pattern of videoPlayerPatterns) {
if (classNameLower.includes(pattern) || idLower.includes(pattern)) {
return true;
}
}
// Check for iframe with video sources
if (element.tagName.toLowerCase() === 'iframe') {
const src = element.getAttribute('src') || '';
return /youtube|vimeo|dailymotion|hulu|vevo|twitch|facebook.*\/video/.test(src);
}
return false;
};
/**
* Extracts the clientX and clientY coordinates from the event based on its type.
* @param {Event} event - The event object.
* @returns {{x: number, y: number} | null} - The extracted coordinates or null if not available.
*/
const extractCoordinates = (event) => {
if (!event) return null;
try {
if (event.type === 'click' || event.type === 'mousedown') {
const { clientX: x, clientY: y } = event;
return isFinite(x) && isFinite(y) ? { x, y } : null;
} else if (event.type === 'touchstart' || event.type === 'touchend') {
if (event.touches && event.touches.length > 0) {
const touch = event.touches[0];
const { clientX: x, clientY: y } = touch;
return isFinite(x) && isFinite(y) ? { x, y } : null;
} else if (event.changedTouches && event.changedTouches.length > 0) {
const touch = event.changedTouches[0];
const { clientX: x, clientY: y } = touch;
return isFinite(x) && isFinite(y) ? { x, y } : null;
}
} else if (event.type === 'keydown' || event.type === 'keyup') {
// For keyboard events, try to find the active element's position
const activeElement = document.activeElement;
if (activeElement && (activeElement.tagName === 'VIDEO' || activeElement.tagName === 'AUDIO')) {
const rect = activeElement.getBoundingClientRect();
return {
x: rect.left + (rect.width / 2),
y: rect.top + (rect.height / 2)
};
}
}
} catch (e) {
logger.error('Error extracting coordinates:', e);
}
return null;
};
/**
* Checks if an element is a media control
* @param {HTMLElement} element - Element to check
* @returns {boolean} - True if element is a control
*/
const isMediaControl = (element) => {
if (!element || !element.tagName) return false;
const tagName = element.tagName.toLowerCase();
const classNameLower = (element.className || '').toString().toLowerCase();
const idLower = (element.id || '').toString().toLowerCase();
const roleLower = (element.getAttribute('role') || '').toLowerCase();
const ariaLabelLower = (element.getAttribute('aria-label') || '').toLowerCase();
// Check common button types
if (tagName === 'button' ||
element.getAttribute('type') === 'button' ||
roleLower === 'button') {
return true;
}
// Check common control patterns
const controlPatterns = [
'play', 'pause', 'toggle', 'control', 'button', 'player', 'start',
'video-control', 'media-control', 'play-button', 'play-pause'
];
for (const pattern of controlPatterns) {
if (classNameLower.includes(pattern) ||
idLower.includes(pattern) ||
ariaLabelLower.includes(pattern)) {
return true;
}
}
// Check for control icons
if (element.querySelector) {
const icons = element.querySelectorAll('svg, i.fa-play, i.play, .play-icon');
if (icons.length > 0) return true;
}
return false;
};
/**
* Detects if the interaction is trusted for the given media element.
* @param {Event} event - The user interaction event.
* @param {HTMLElement} media - The media element to check against.
* @returns {boolean} - True if the interaction is trusted, else false.
*/
const isTrustedInteraction = (event, media) => {
if (!event || !media || !event.isTrusted) return false;
try {
const coordinates = extractCoordinates(event);
if (!coordinates) {
// Special case for keyboard events on the media element itself
if ((event.type === 'keydown' || event.type === 'keyup') &&
document.activeElement === media) {
return true;
}
return false;
}
const { x, y } = coordinates;
if (!isFinite(x) || !isFinite(y)) return false;
const mediaRect = media.getBoundingClientRect();
// First, check if the interaction is with the media itself
if (x >= mediaRect.left && x <= mediaRect.right &&
y >= mediaRect.top && y <= mediaRect.bottom) {
return true;
}
// Check if within config.interactionProximity pixels of the sides
const proximity = config.interactionProximity;
const withinProximity = x >= mediaRect.left - proximity &&
x <= mediaRect.right + proximity &&
y >= mediaRect.top - proximity &&
y <= mediaRect.bottom + proximity;
if (withinProximity) {
// Get the element that was actually clicked
const elementsAtPoint = document.elementsFromPoint(x, y);
// Check if any of the elements at this point are media controls
for (const el of elementsAtPoint) {
if (isMediaControl(el)) {
return true;
}
}
// Check if any of the elements at this point contain or are contained by the media
for (const el of elementsAtPoint) {
if (el === media || media.contains(el) || el.contains(media)) {
return true;
}
}
// Even if not directly containing, check for overlapping elements
for (const el of elementsAtPoint) {
// Special case for shadow DOM - try to find the closest media element
const closestMedia = el.closest && el.closest('video, audio');
if (closestMedia === media) {
return true;
}
// Check if this might be a player control by its properties
if (isMediaControl(el)) {
const elRect = el.getBoundingClientRect();
// Check if the control is overlapping the media by at least 20%
const overlapX = Math.max(0, Math.min(elRect.right, mediaRect.right) - Math.max(elRect.left, mediaRect.left));
const overlapY = Math.max(0, Math.min(elRect.bottom, mediaRect.bottom) - Math.max(elRect.top, mediaRect.top));
if (overlapX > 0 && overlapY > 0) {
const elArea = elRect.width * elRect.height;
const overlapArea = overlapX * overlapY;
if (overlapArea >= 0.2 * elArea) {
return true;
}
}
}
}
}
return false;
} catch (e) {
logger.error('Error in isTrustedInteraction:', e);
return false;
}
};
// --------------------------------------------------
// Core functionality
// --------------------------------------------------
/**
* Removes autoplay attributes from a media element
* @param {HTMLMediaElement} media - The media element
*/
const removeAutoplay = (media) => {
if (!media) return;
try {
// Remove autoplay attribute
if (media.hasAttribute('autoplay')) {
media.removeAttribute('autoplay');
logger.log('Removed autoplay attribute from', media);
}
// Remove any inline autoplay settings
if (media.autoplay) {
media.autoplay = false;
logger.log('Disabled autoplay property on', media);
}
} catch (e) {
logger.error('Error removing autoplay:', e);
}
};
/**
* Overrides the play method for a media element to enforce user interaction
* @param {HTMLMediaElement} media - The media element to override
*/
const overridePlayMethod = (media) => {
if (!media || processedElements.has(media)) return;
try {
// Save original play method for this specific element (in case the prototype method changes)
const originalPlayMethod = media.play;
// Override play method
media.play = async function() {
if (mediaInteractionMap.get(this)) {
logger.log('Allowing playback for user-interacted media:', this);
try {
return await originalPlayMethod.apply(this);
} catch (e) {
logger.error('Playback failed despite allowing it:', e);
throw e;
}
} else {
this.pause();
logger.log('Blocked autoplay for:', this, this.currentSrc || this.src);
return Promise.reject(new DOMException('NotAllowedError', 'Autoplay is blocked by Autoplay Blocker userscript'));
}
};
// Mark as processed to avoid re-processing
processedElements.add(media);
logger.log('Overrode play method for:', media);
} catch (e) {
logger.error('Error overriding play method:', e);
}
};
/**
* Pauses all media elements on the page
*/
const pauseAllMedia = () => {
try {
document.querySelectorAll('video, audio').forEach(media => {
if (!mediaInteractionMap.get(media)) {
safeExecute(() => media.pause(), 'Error pausing media');
}
});
} catch (e) {
logger.error('Error in pauseAllMedia:', e);
}
};
/**
* Process a media element: remove autoplay and override play
* @param {HTMLMediaElement} media - The media element to process
*/
const processMediaElement = (media) => {
if (!media || processedElements.has(media)) return;
try {
// Skip if it's not a valid media element
if (!(media instanceof HTMLMediaElement)) return;
// Initialize interaction state if not already set
if (!mediaInteractionMap.has(media)) {
mediaInteractionMap.set(media, false);
}
// Remove autoplay attribute
removeAutoplay(media);
// Override play method
overridePlayMethod(media);
// Add event listeners to pause media if it tries to play without interaction
media.addEventListener('play', function(e) {
if (!mediaInteractionMap.get(this)) {
logger.log('Caught play event, pausing media due to no user interaction:', this);
this.pause();
}
}, true);
// Track loading of new media sources
media.addEventListener('loadedmetadata', function() {
if (!mediaInteractionMap.get(this)) {
this.pause();
}
}, true);
logger.log('Processed media element:', media);
} catch (e) {
logger.error('Error processing media element:', e);
}
};
/**
* Handle user interactions to allow playback for specific media elements
* @param {Event} event - The user interaction event
*/
const handleUserInteraction = (event) => {
if (!event || !event.isTrusted) return;
try {
// Get all media elements
const mediaElements = document.querySelectorAll('video, audio');
// Check each media element for trusted interaction
mediaElements.forEach(media => {
if (isTrustedInteraction(event, media)) {
if (!mediaInteractionMap.get(media)) {
logger.log('Detected user interaction with media:', media, 'Event:', event.type);
mediaInteractionMap.set(media, true);
// Allow playback if the event was an explicit play attempt
const isPlayAttempt =
event.type === 'click' ||
event.type === 'touchend' ||
(event.type === 'keydown' &&
(event.key === ' ' || event.key === 'Enter' || event.key === 'k'));
if (isPlayAttempt) {
// We don't want to immediately play the media here,
// as the click may be handled by player controls
setTimeout(() => {
// Only try to play if still paused after the click event propagated
if (media.paused) {
logger.log('Attempting playback after detected user interaction');
media.play().catch(e => {
logger.warn('Failed to play despite user interaction:', e);
});
}
}, 50);
}
}
}
});
} catch (e) {
logger.error('Error in handleUserInteraction:', e);
}
};
/**
* Override key methods to ensure autoplay blocking works reliably
*/
const overridePrototypeMethods = () => {
try {
// Override setAttribute to block autoplay attributes
const originalSetAttribute = Element.prototype.setAttribute;
Element.prototype.setAttribute = function(name, value) {
if (name === 'autoplay' && this instanceof HTMLMediaElement) {
logger.log('Blocked attempt to set autoplay attribute on:', this);
return;
}
return originalSetAttribute.call(this, name, value);
};
// Override global HTMLMediaElement.prototype.play
HTMLMediaElement.prototype.play = function() {
if (mediaInteractionMap.get(this)) {
logger.log('Allowing playback for user-interacted media (global):', this);
return originalPrototypes.play.apply(this);
} else {
this.pause();
logger.log('Blocked autoplay globally for:', this);
return Promise.reject(new DOMException('NotAllowedError', 'Autoplay is blocked by Autoplay Blocker userscript'));
}
};
logger.log('Overrode prototype methods');
} catch (e) {
logger.error('Error overriding prototype methods:', e);
}
};
/**
* Set up mutation observer to catch dynamically added media elements
*/
const setupMutationObserver = () => {
try {
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
// Check added nodes for new media elements
if (mutation.addedNodes) {
mutation.addedNodes.forEach(node => {
// Direct media elements
if (node instanceof HTMLMediaElement) {
processMediaElement(node);
}
// If node can contain other elements, look for media elements inside
if (node.querySelectorAll) {
node.querySelectorAll('video, audio').forEach(media => {
processMediaElement(media);
});
}
});
}
// Also check for attribute modifications (adding autoplay after the fact)
if (mutation.type === 'attributes' &&
mutation.attributeName === 'autoplay' &&
mutation.target instanceof HTMLMediaElement) {
removeAutoplay(mutation.target);
}
}
});
// Observe the entire document for changes
observer.observe(document.documentElement || document.body || document, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['autoplay']
});
logger.log('Set up mutation observer');
} catch (e) {
logger.error('Error setting up mutation observer:', e);
}
};
/**
* Initialize the script
*/
const initialize = () => {
if (initialized) return;
initialized = true;
logger.log('Initializing autoplay blocker');
// Override prototype methods first
overridePrototypeMethods();
// Add event listeners for user interactions
['click', 'touchend', 'mousedown', 'keydown'].forEach(eventType => {
window.addEventListener(eventType, handleUserInteraction, true);
});
// Process existing media elements
document.querySelectorAll('video, audio').forEach(processMediaElement);
// Initial pause of any playing media
pauseAllMedia();
// Set up mutation observer for dynamic elements
setupMutationObserver();
// Periodically check for new unprocessed media (in case mutation observer misses something)
setInterval(() => {
document.querySelectorAll('video, audio').forEach(media => {
if (!processedElements.has(media)) {
processMediaElement(media);
}
});
}, 1000);
logger.log('Initialization complete');
};
// Initialize immediately if document is already loaded
if (document.readyState === 'interactive' || document.readyState === 'complete') {
initialize();
} else {
// Otherwise initialize as soon as DOM is available
document.addEventListener('DOMContentLoaded', initialize, { once: true });
}
// Also initialize when window loads (backup)
window.addEventListener('load', initialize, { once: true });
// Expose the initialize function for manual invocation if needed
window._initAutoplayBlocker = initialize;
})();