Old Reddit Auto Expander

Automatically expands posts, optionally manages collapsed comments, and controls video autoplay

// ==UserScript==
// @name        Old Reddit Auto Expander
// @namespace   https://codeberg.org/TwilightAlicorn/OldReddit-AutoExpand
// @version     3.2
// @description Automatically expands posts, optionally manages collapsed comments, and controls video autoplay
// @author      TwilightAlicorn
// @match       https://*.reddit.com/*
// @grant       none
// @run-at      document-idle
// @license     MIT
// ==/UserScript==

(function() {
    'use strict';

    // Configuration
    const CONFIG = {
        // Set to true to automatically expand collapsed comments
        expandComments: false,

        // Set to true to manage video autoplay and pause when scrolling
        // Only enable this if you're using Reddit's internal player! If you use other players (like RES, that has its internal player), configure them separately.
        manageVideoPlayback: false,

        // [manageVideoPlayback] Video visibility threshold (0.0 to 1.0)
        videoVisibilityThreshold: 0.1,

        // [manageVideoPlayback] Advanced video control settings
        pauseCheckInterval: 1000,        // Check ALL videos every X milliseconds
        videoWatchDuration: 5000,        // Keep watching new videos closely for this long
        videoWatcherInterval: 300,       // Extra frequent checks for videos that just appeared
        respectUserInteraction: true,    // Don't interfere with videos the user has played manually
        pauseWhenOutOfView: true,        // Pause all videos when scrolled out of view, even user-played ones
        gracePeriodAfterPlay: 500,       // Grace period (ms) after user plays video to prevent immediate re-pause

        // Interval in ms for checking content to expand
        checkInterval: 1000,

        // Maximum attempts to find collapsed items before giving up
        maxAttempts: 10,

        // Enable logging
        enableLogging: false
    };

    // Logging function
    function log(message, obj = null) {
        if (!CONFIG.enableLogging) return;

        if (obj) {
            console.log(`[RedditExpander] ${message}`, obj);
        } else {
            console.log(`[RedditExpander] ${message}`);
        }
    }

    // Selectors for content to expand
    const SELECTORS = {
        posts: [
            '.selftext.collapsed',                   // Text posts
            '.expando-button.collapsed.video',       // Videos
            '.expando-button.collapsed.image',       // Images
            '.expando-button.crosspost.collapsed'    // Crossposts
        ],
        comments: 'a.expand',                        // Collapsed comments
        resImages: '.res-show-images > a'            // RES show all images button
    };

    // Only define video selectors if video management is enabled
    if (CONFIG.manageVideoPlayback) {
        SELECTORS.videos = 'video';                               // Video elements
        SELECTORS.playButton = '.play-pause[data-action="play"]'; // Play button when video is paused
        SELECTORS.pauseButton = '.play-pause[data-action="pause"]'; // Pause button when video is playing
        SELECTORS.videoPlayer = '.reddit-video-player-root';      // Reddit video player container
        SELECTORS.videoControls = '.video-controls';              // Video controls container
    }

    // Track expansion state
    let state = {
        expandAttempts: 0,
        imagesExpanded: false,
        isCommentsPage: location.pathname.includes('/comments/')
    };

    // Only initialize video state if video management is enabled
    if (CONFIG.manageVideoPlayback) {
        state.videoObserver = null;
        state.processedPlayers = new WeakSet();
        state.playerIds = new WeakMap();          // For easier identification in logs
        state.nextPlayerId = 1;
        state.pauseInterval = null;           // Interval for checking all videos
        state.watchedVideos = new Map();      // Videos that are being watched closely
        state.visibleVideos = new WeakSet();      // Videos currently in viewport
        state.userPlayedVideos = new WeakSet();   // Videos the user has played manually
        state.recentUserPlays = new Map();    // Timestamp of recent user play actions - Using regular Map as we need to iterate over it
    }

    // Video management functions - only defined if manageVideoPlayback is true
    let getPlayerId, shouldManageVideo, pauseVideo, ensurePaused, patrolVideos,
        watchNewVideo, setupVideoEventListeners, setupVideoManagement, cleanupVideoManagement,
        markVideoAsUserPlayed, isElementVisible;

    if (CONFIG.manageVideoPlayback) {
        /**
         * Determines if an element is sufficiently visible in the viewport
         * @param {Element} element - The element to check
         * @param {number} threshold - Visibility threshold (0.0 to 1.0)
         * @returns {boolean} - Whether the element is sufficiently visible
         */
        isElementVisible = function(element, threshold = 0.0) {
            if (!element) return false;

            try {
                const rect = element.getBoundingClientRect();
                const windowHeight = window.innerHeight || document.documentElement.clientHeight;

                // Calculate how much of the element is in the viewport
                const visibleHeight = Math.min(rect.bottom, windowHeight) - Math.max(rect.top, 0);
                const elementHeight = rect.bottom - rect.top;

                // Calculate the visible ratio
                const visibleRatio = Math.max(0, visibleHeight / elementHeight);

                return visibleRatio >= threshold;
            } catch (e) {
                log(`Error checking element visibility: ${e.message}`);
                return false;
            }
        };

        /**
         * Gets a unique ID for a player for logging purposes
         * @param {Element} videoPlayer - The video player element
         * @returns {number} - A unique ID for this player
         */
        getPlayerId = function(videoPlayer) {
            if (!state.playerIds.has(videoPlayer)) {
                state.playerIds.set(videoPlayer, state.nextPlayerId++);
            }
            return state.playerIds.get(videoPlayer);
        };

        /**
         * Marks a video as played by the user and establishes a grace period
         * @param {Element} videoPlayer - The video player element
         */
        markVideoAsUserPlayed = function(videoPlayer) {
            try {
                const playerId = getPlayerId(videoPlayer);

                // Add to user played videos set
                if (!state.userPlayedVideos.has(videoPlayer)) {
                    log(`[Player ${playerId}] Marking as user-played`);
                    state.userPlayedVideos.add(videoPlayer);
                }

                // Set grace period timestamp
                state.recentUserPlays.set(videoPlayer, Date.now());
            } catch (e) {
                log(`Error marking video as played: ${e.message}`);
            }
        };

        /**
         * Determines if we should manage this video based on user interaction and context
         * @param {Element} videoPlayer - The video player element
         * @param {boolean} isOutOfView - Whether the video is out of view (for scroll pausing)
         * @returns {boolean} - Whether this video should be managed
         */
        shouldManageVideo = function(videoPlayer, isOutOfView) {
            // For videos scrolled out of view, we always pause them if configured to do so
            if (isOutOfView && CONFIG.pauseWhenOutOfView) {
                return true;
            }

            // Check if we're in grace period after user interaction
            const lastUserPlay = state.recentUserPlays.get(videoPlayer);
            if (lastUserPlay && (Date.now() - lastUserPlay < CONFIG.gracePeriodAfterPlay)) {
                const playerId = getPlayerId(videoPlayer);
                log(`[Player ${playerId}] In grace period after user play, skipping management`);
                return false;
            }

            // For preventing autoplay, we check if user has manually played it
            if (CONFIG.respectUserInteraction && state.userPlayedVideos.has(videoPlayer)) {
                return false;
            }

            // By default, manage the video
            return true;
        };

        /**
         * Cleans up all video-related observers and state
         */
        cleanupVideoManagement = function() {
            log("Cleaning up video management");

            try {
                // Disconnect video observer
                if (state.videoObserver) {
                    state.videoObserver.disconnect();
                    state.videoObserver = null;
                }

                // Clear intervals
                if (state.pauseInterval) {
                    clearInterval(state.pauseInterval);
                    state.pauseInterval = null;
                }

                // Clear all video watchers
                state.watchedVideos.forEach((watcher) => {
                    clearInterval(watcher.interval);
                });
                state.watchedVideos.clear();
                state.recentUserPlays.clear();

                // WeakSets and WeakMaps will clean themselves as DOM elements are garbage collected
            } catch (e) {
                log(`Error during video management cleanup: ${e.message}`);
            }
        };

        /**
         * Sets up event listeners to detect user interaction with videos
         * @param {Element} videoPlayer - The video player element
         * @param {HTMLVideoElement} videoElement - The video element
         */
        setupVideoEventListeners = function(videoPlayer, videoElement) {
            try {
                const playerId = getPlayerId(videoPlayer);

                // Find the control elements
                const controls = videoPlayer.querySelector(SELECTORS.videoControls);

                if (controls) {
                    // Listen for clicks on the play button
                    controls.addEventListener('click', (e) => {
                        const playButton = e.target.closest(SELECTORS.playButton);
                        if (playButton) {
                            log(`[Player ${playerId}] User clicked play button`);
                            markVideoAsUserPlayed(videoPlayer);
                        }
                    }, true);
                }

                if (videoElement) {
                    // Listen for play events on the video element
                    videoElement.addEventListener('play', () => {
                        // If video is in the viewport and starts playing, consider it user-initiated
                        // unless we're actively trying to pause it
                        if (state.visibleVideos.has(videoPlayer) && !videoPlayer.dataset.activePause) {
                            log(`[Player ${playerId}] Video started playing while visible`);
                            markVideoAsUserPlayed(videoPlayer);
                        }
                    });
                }
            } catch (e) {
                log(`Error setting up video event listeners: ${e.message}`);
            }
        };

        /**
         * Pauses a video
         * @param {Element} videoPlayer - The video player element
         * @param {boolean} isOutOfView - Whether the video is out of view (for scroll pausing)
         * @returns {boolean} - Whether any pause action was attempted
         */
        pauseVideo = function(videoPlayer, isOutOfView = false) {
            // Skip if we shouldn't manage this video in the current context
            if (!shouldManageVideo(videoPlayer, isOutOfView)) {
                return false;
            }

            try {
                const playerId = getPlayerId(videoPlayer);
                let pauseAttempted = false;

                // Mark that we're actively trying to pause this video
                videoPlayer.dataset.activePause = 'true';

                // First find the video element
                const videoElement = videoPlayer.querySelector('video');

                // Try direct API pause
                if (videoElement) {
                    try {
                        videoElement.pause();
                        pauseAttempted = true;
                        if (isOutOfView) {
                            log(`[Player ${playerId}] Paused video that scrolled out of view`);
                        } else {
                            log(`[Player ${playerId}] Paused video via API`);
                        }
                    } catch (e) {
                        log(`[Player ${playerId}] Error pausing video: ${e.message}`);
                    }
                }

                // Then try UI approach
                const pauseButton = videoPlayer.querySelector(SELECTORS.pauseButton);
                if (pauseButton) {
                    pauseButton.click();
                    pauseAttempted = true;
                    log(`[Player ${playerId}] Clicked pause button`);
                }

                // Clear the active pause marker after a short delay
                setTimeout(() => {
                    delete videoPlayer.dataset.activePause;
                }, 500);

                return pauseAttempted;
            } catch (e) {
                log(`Error in pauseVideo: ${e.message}`);
                return false;
            }
        };

        /**
         * Ensure a video is paused based on its visibility state
         * @param {Element} videoPlayer - The video player container
         * @param {boolean} isVisible - Whether the video is currently visible
         */
        ensurePaused = function(videoPlayer, isVisible) {
            if (!videoPlayer) return;

            try {
                const playerId = getPlayerId(videoPlayer);

                // Update visibility state
                if (isVisible) {
                    if (!state.visibleVideos.has(videoPlayer)) {
                        log(`[Player ${playerId}] Video is now visible`);
                        state.visibleVideos.add(videoPlayer);
                    }

                    // Don't do anything else for visible videos
                    return;
                } else {
                    // Video is not visible
                    if (state.visibleVideos.has(videoPlayer)) {
                        log(`[Player ${playerId}] Video is no longer visible`);
                        state.visibleVideos.delete(videoPlayer);

                        // Pause video that scrolled out of view, even if user played it
                        pauseVideo(videoPlayer, true);
                    } else {
                        // Video was already out of view, just ensure it's still paused
                        pauseVideo(videoPlayer, true);
                    }
                }
            } catch (e) {
                log(`Error in ensurePaused: ${e.message}`);
            }
        };

        /**
         * Watch a new video closely for a period after it appears
         * @param {Element} videoPlayer - The video player to watch
         */
        watchNewVideo = function(videoPlayer) {
            if (state.watchedVideos.has(videoPlayer)) return;

            try {
                const playerId = getPlayerId(videoPlayer);
                log(`[Player ${playerId}] Setting up close watching for new video`);

                // Set up event listeners
                const videoElement = videoPlayer.querySelector('video');
                if (videoElement) {
                    setupVideoEventListeners(videoPlayer, videoElement);
                }

                // Initial pause if needed
                const isVisible = isElementVisible(videoPlayer, CONFIG.videoVisibilityThreshold);
                if (!isVisible) {
                    pauseVideo(videoPlayer, true); // Out of view
                } else {
                    // Update visible state
                    state.visibleVideos.add(videoPlayer);
                }

                // Set up interval to check this video frequently
                const watcher = {
                    startTime: Date.now(),
                    interval: setInterval(() => {
                        try {
                            // Check if video is visible
                            const isVisible = isElementVisible(videoPlayer, CONFIG.videoVisibilityThreshold);

                            // If video is not visible, always pause it
                            if (!isVisible) {
                                pauseVideo(videoPlayer, true); // Out of view
                            }
                            // If video is visible but hasn't been played by user, ensure autoplay is prevented
                            else if (!state.userPlayedVideos.has(videoPlayer)) {
                                pauseVideo(videoPlayer); // In view but preventing autoplay
                            }

                            // Stop watching after duration expires
                            if (Date.now() - watcher.startTime > CONFIG.videoWatchDuration) {
                                log(`[Player ${playerId}] Finished close watching period`);
                                clearInterval(watcher.interval);
                                state.watchedVideos.delete(videoPlayer);
                            }
                        } catch (e) {
                            log(`Error in video watcher: ${e.message}`);
                            clearInterval(watcher.interval);
                            state.watchedVideos.delete(videoPlayer);
                        }
                    }, CONFIG.videoWatcherInterval)
                };

                state.watchedVideos.set(videoPlayer, watcher);
            } catch (e) {
                log(`Error setting up video watcher: ${e.message}`);
            }
        };

        /**
         * Patrol all videos on the page to ensure they're in the correct play/pause state
         */
        patrolVideos = function() {
            try {
                // Clean up old entries from recentUserPlays map
                const now = Date.now();
                const expiredPlayers = [];

                // Find expired entries
                state.recentUserPlays.forEach((timestamp, player) => {
                    if (now - timestamp > CONFIG.gracePeriodAfterPlay) {
                        expiredPlayers.push(player);
                    }
                });

                // Remove expired entries
                expiredPlayers.forEach(player => {
                    state.recentUserPlays.delete(player);
                });
            } catch (e) {
                log(`Error patrolling videos: ${e.message}`);
            }
        };

        /**
         * Sets up video management to control autoplay and handle scrolling
         */
        setupVideoManagement = function() {
            try {
                // Start periodic patrols if not already started
                if (!state.pauseInterval) {
                    state.pauseInterval = setInterval(() => {
                        try {
                            // Check all videos periodically regardless of intersection observer
                            document.querySelectorAll(SELECTORS.videoPlayer).forEach(player => {
                                // Add to processed set if it's new
                                if (!state.processedPlayers.has(player)) {
                                    const playerId = getPlayerId(player);
                                    log(`[Player ${playerId}] Found new video player during patrol`);
                                    state.processedPlayers.add(player);

                                    // Set up event listeners
                                    const videoElement = player.querySelector('video');
                                    if (videoElement) {
                                        setupVideoEventListeners(player, videoElement);
                                    }

                                    // Watch new videos closely
                                    watchNewVideo(player);
                                }

                                // Check visibility manually
                                const isVisible = isElementVisible(player, CONFIG.videoVisibilityThreshold);
                                const wasVisible = state.visibleVideos.has(player);

                                // Only update if visibility changed
                                if (isVisible !== wasVisible) {
                                    ensurePaused(player, isVisible);
                                }
                            });

                            // Run additional patrol tasks
                            patrolVideos();
                        } catch (e) {
                            log(`Error in video patrol interval: ${e.message}`);
                        }
                    }, CONFIG.pauseCheckInterval);

                    log(`Set up video patrol every ${CONFIG.pauseCheckInterval}ms`);
                }

                // Initial patrol to catch existing videos
                patrolVideos();

                // We can disconnect the old observer if it exists
                if (state.videoObserver) {
                    state.videoObserver.disconnect();
                    state.videoObserver = null;
                }
            } catch (e) {
                log(`Error setting up video management: ${e.message}`);
            }
        };
    }

    /**
     * Expands post content (text, images, videos)
     * @returns {boolean} Whether any content was expanded
     */
    function expandPostContent() {
        try {
            // Find the first collapsed element of any type
            const firstCollapsed = document.querySelector(SELECTORS.posts.join(','));

            if (firstCollapsed) {
                log(`Expanding post content: ${firstCollapsed.className}`);
                firstCollapsed.click();

                // If video management is enabled, handle any newly expanded videos
                if (CONFIG.manageVideoPlayback) {
                    // Use setTimeout to allow the video to be created in the DOM
                    setTimeout(setupVideoManagement, 300);
                }

                return true;
            }

            // Handle RES show images button (only once)
            if (!state.imagesExpanded) {
                const showImagesButton = document.querySelector(SELECTORS.resImages);
                if (showImagesButton) {
                    log("Clicking RES 'show all images' button");
                    showImagesButton.click();
                    state.imagesExpanded = true;
                    return true;
                }
            }

            return false;
        } catch (e) {
            log(`Error expanding post content: ${e.message}`);
            return false;
        }
    }

    /**
     * Expands collapsed comments
     * @returns {boolean} Whether any comments were expanded
     */
    function expandCollapsedComments() {
        if (!CONFIG.expandComments) return false;

        try {
            const collapsedButtons = document.querySelectorAll(SELECTORS.comments);
            let expanded = false;

            collapsedButtons.forEach(button => {
                if (button.textContent.includes('[+]')) {
                    button.click();
                    expanded = true;
                }
            });

            return expanded;
        } catch (e) {
            log(`Error expanding comments: ${e.message}`);
            return false;
        }
    }

    /**
     * Main function to expand all collapsible content
     */
    function expandContent() {
        try {
            let expanded = expandPostContent();

            if (state.isCommentsPage) {
                expanded = expandCollapsedComments() || expanded;
            }

            // Track expansion attempts for auto-termination
            if (!expanded) {
                state.expandAttempts++;
                // Only log at max attempts to reduce spam
                if (state.expandAttempts === CONFIG.maxAttempts) {
                    log(`Reached max expansion attempts (${CONFIG.maxAttempts}), stopping periodic checks`);
                }
            } else {
                state.expandAttempts = 0;
            }
        } catch (e) {
            log(`Error in expandContent: ${e.message}`);
            state.expandAttempts++;
        }
    }

    /**
     * Initialize the content expander
     */
    function init() {
        log("Initializing Old Reddit Auto Expander");

        try {
            // Initial expansion
            expandContent();

            // Set up periodic checks
            const intervalId = setInterval(() => {
                try {
                    // Only continue if we haven't exceeded max attempts
                    if (state.expandAttempts <= CONFIG.maxAttempts) {
                        expandContent();
                    } else {
                        clearInterval(intervalId);
                    }
                } catch (e) {
                    log(`Error in expansion interval: ${e.message}`);
                    state.expandAttempts++;
                }
            }, CONFIG.checkInterval);

            // Set up mutation observer for dynamically loaded content
            const observer = new MutationObserver((mutations) => {
                try {
                    let shouldCheck = false;

                    for (const mutation of mutations) {
                        if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                            shouldCheck = true;
                            break;
                        }
                    }

                    if (shouldCheck) {
                        setTimeout(() => {
                            expandContent();

                            // Also check videos if enabled
                            if (CONFIG.manageVideoPlayback) {
                                setupVideoManagement();
                            }
                        }, 300);
                    }
                } catch (e) {
                    log(`Error in mutation observer: ${e.message}`);
                }
            });

            observer.observe(document.body, {
                childList: true,
                subtree: true
            });

            // Set up initial video management if enabled
            if (CONFIG.manageVideoPlayback) {
                setupVideoManagement();
            }
        } catch (e) {
            log(`Error during initialization: ${e.message}`);
        }
    }

    /**
     * Clean up observers when page is unloaded
     */
    function cleanup() {
        try {
            // Clean up video management if it was enabled
            if (CONFIG.manageVideoPlayback && typeof cleanupVideoManagement === 'function') {
                cleanupVideoManagement();
            }
        } catch (e) {
            log(`Error during cleanup: ${e.message}`);
        }
    }

    // Initialize when DOM is ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

    // Clean up when page is unloaded
    window.addEventListener('unload', cleanup);
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址