Ultimate Autoplay Blocker

The last userscript you'll ever need to disable autoplay videos on news sites and elsewhere

当前为 2025-03-15 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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;
})();