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