Ultimate Autoplay Blocker

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

目前為 2025-03-15 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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;
})();