Sleazy Fork is available in English.
Auto-scrolls through RedGifs videos and images. Press : to start/stop, r to reverse direction, h to hide/show UI, [ / ] to adjust image delay. Drag the UI to reposition.
// ==UserScript==
// @name RedGifs Auto-Scroller
// @namespace Redgifs
// @version 4.1
// @description Auto-scrolls through RedGifs videos and images. Press : to start/stop, r to reverse direction, h to hide/show UI, [ / ] to adjust image delay. Drag the UI to reposition.
// @author KurtRiver
// @match https://www.redgifs.com/*
// @grant none
// @license GPL-3.0-or-later
// ==/UserScript==
(function () {
'use strict';
// ─── State ───────────────────────────────────────────────
let running = false;
let reverse = false;
let pollId = null;
let observer = null;
let processedVideos = new WeakSet();
let navigatedSet = new WeakSet();
let uiHidden = false;
let imageDelay = 5; // seconds to wait on images
let imageTimerStart = null; // timestamp when current image became active
let lastActiveItem = null; // track active item changes
// ─── Selectors ───────────────────────────────────────────
const SEL = {
feedContainer: '.previewFeed',
item: '.GifPreview',
activeItem: '.GifPreview.GifPreview_isActive',
activeVideo: '.GifPreview.GifPreview_isActive',
video: 'video.isLoaded',
imageMarker: '.GifPreview_isImage',
videoMarker: '.GifPreview_isVideo',
imageEl: '.ImageGif',
};
// ─── Helpers ─────────────────────────────────────────────
function log(...args) {
console.log('%c[RG-AutoScroll]', 'color:#e44;font-weight:bold', ...args);
}
function getFeedContainer() {
return document.querySelector(SEL.feedContainer);
}
function getActiveItem() {
return document.querySelector(SEL.activeItem);
}
function getActiveVideo() {
const active = getActiveItem();
return active ? active.querySelector(SEL.video) : null;
}
function isImageActive() {
const active = getActiveItem();
return active?.classList.contains('GifPreview_isImage') || false;
}
function isVideoActive() {
const active = getActiveItem();
return active?.classList.contains('GifPreview_isVideo') || false;
}
function getAllItems() {
return Array.from(document.querySelectorAll(SEL.item));
}
// ─── Video End Detection ─────────────────────────────────
function isVideoFinished(video) {
if (!video || video.tagName !== 'VIDEO') return false;
if (video.ended) return true;
if (!video.duration || !isFinite(video.duration) || video.duration === 0) return false;
return video.currentTime >= video.duration - 0.2;
}
// ─── Image Timer ─────────────────────────────────────────
function resetImageTimer() {
imageTimerStart = Date.now();
}
function hasImageTimerExpired() {
if (!imageTimerStart) return false;
return (Date.now() - imageTimerStart) / 1000 >= imageDelay;
}
function getImageTimerRemaining() {
if (!imageTimerStart) return imageDelay;
const elapsed = (Date.now() - imageTimerStart) / 1000;
return Math.max(0, imageDelay - elapsed);
}
// ─── Navigation ──────────────────────────────────────────
function navigateToNext() {
if (!running) return;
const feed = getFeedContainer();
if (!feed) {
log('Feed container (.previewFeed) not found');
return;
}
const items = getAllItems();
if (items.length === 0) {
log('No .GifPreview items found');
return;
}
const activeItem = getActiveItem();
if (!activeItem) {
log('No active item found');
return;
}
const activeIdx = items.indexOf(activeItem);
if (activeIdx === -1) {
log('Active item not in items list');
return;
}
let targetIdx;
if (reverse) {
targetIdx = activeIdx - 1;
} else {
targetIdx = activeIdx + 1;
}
if (targetIdx < 0) {
log('Already at top of feed');
return;
}
if (targetIdx >= items.length) {
log('Reached end of feed');
return;
}
const targetItem = items[targetIdx];
const targetId = targetItem.dataset?.feedItemId || `idx-${targetIdx}`;
log('Navigating to', targetId, `(item ${targetIdx}/${items.length})`);
navigatedSet.add(activeItem);
const feedRect = feed.getBoundingClientRect();
const targetRect = targetItem.getBoundingClientRect();
const offset = targetRect.top - feedRect.top - (feedRect.height / 2) + (targetRect.height / 2);
feed.scrollBy({
top: offset,
behavior: 'smooth',
});
// Reset image timer for the incoming item
resetImageTimer();
}
// ─── Polling Loop ─────────────────────────────────────────
function startPolling() {
if (pollId) return;
pollId = setInterval(() => {
if (!running) return;
const currentActive = getActiveItem();
// Detect active item change → reset tracking
if (currentActive !== lastActiveItem) {
if (currentActive && !navigatedSet.has(currentActive)) {
navigatedSet = new WeakSet();
}
// Reset image timer when content changes
if (currentActive !== lastActiveItem) {
resetImageTimer();
}
lastActiveItem = currentActive;
}
// ── Image handling ──
if (isImageActive()) {
if (hasImageTimerExpired() && !navigatedSet.has(getActiveItem())) {
log('Image timer expired (', imageDelay, 's) →', reverse ? '◄ PREV' : '► NEXT');
navigateToNext();
}
updateCountdown();
return;
}
// ── Video handling ──
const video = getActiveVideo();
if (!video) return;
ensureVideoPrepared(video);
if (isVideoFinished(video)) {
if (!navigatedSet.has(getActiveItem())) {
log('Video ended →', reverse ? '◄ PREV' : '► NEXT');
navigateToNext();
}
}
}, 250);
log('Polling started (250ms)');
}
function stopPolling() {
if (pollId) {
clearInterval(pollId);
pollId = null;
log('Polling stopped');
}
}
// ─── Prepare Video ───────────────────────────────────────
function ensureVideoPrepared(video) {
if (processedVideos.has(video)) return;
processedVideos.add(video);
video.removeAttribute('loop');
const attrObs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName === 'loop' && video.hasAttribute('loop')) {
video.removeAttribute('loop');
}
}
});
attrObs.observe(video, { attributes: true });
video.addEventListener('ended', () => {
if (!running) return;
if (navigatedSet.has(getActiveItem())) return;
log('Native ended event →', reverse ? '◄ PREV' : '► NEXT');
navigateToNext();
});
log('Prepared video', video.src?.substring(0, 50) || '(blob)');
}
// ─── MutationObserver ────────────────────────────────────
function startObserver() {
if (observer) return;
observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'childList') {
for (const node of m.addedNodes) {
if (node.tagName === 'VIDEO') {
ensureVideoPrepared(node);
}
if (node.querySelectorAll) {
node.querySelectorAll('video').forEach(ensureVideoPrepared);
}
}
}
}
const video = getActiveVideo();
if (video) ensureVideoPrepared(video);
});
observer.observe(document.body, { childList: true, subtree: true });
log('MutationObserver started');
}
function stopObserver() {
if (observer) {
observer.disconnect();
observer = null;
log('MutationObserver stopped');
}
}
// ─── Controls ────────────────────────────────────────────
function setImageDelay(val) {
imageDelay = Math.max(1, Math.min(30, val));
log('Image delay set to', imageDelay, 's');
updateStatusUI();
}
function adjustImageDelay(delta) {
setImageDelay(imageDelay + delta);
}
function start() {
const feed = getFeedContainer();
if (!feed) {
log('ERROR: Could not find .previewFeed container.');
updateStatusUI('NO FEED');
return;
}
running = true;
navigatedSet = new WeakSet();
lastActiveItem = null;
resetImageTimer();
startObserver();
startPolling();
const video = getActiveVideo();
if (video) ensureVideoPrepared(video);
updateStatusUI();
log('▶ Started — direction:', reverse ? '◄ REVERSE' : '► FORWARD', '| IMG delay:', imageDelay + 's');
}
function stop() {
running = false;
stopPolling();
stopObserver();
imageTimerStart = null;
updateStatusUI();
log('⏹ Stopped');
}
function toggleDirection() {
reverse = !reverse;
navigatedSet = new WeakSet();
resetImageTimer();
updateStatusUI();
log('Direction:', reverse ? '◄ REVERSE' : '► FORWARD');
}
function toggleUI() {
uiHidden = !uiHidden;
overlayEl.style.opacity = uiHidden ? '0' : '1';
overlayEl.style.pointerEvents = uiHidden ? 'none' : 'auto';
log('UI', uiHidden ? 'hidden' : 'visible');
}
// ─── UI Overlay ──────────────────────────────────────────
let overlayEl;
let countdownEl;
let delayValueEl;
function createStatusOverlay() {
if (overlayEl) return;
overlayEl = document.createElement('div');
overlayEl.id = 'rg-autoscroll-ui';
// ── Row 1: Main status ──
const statusLine = document.createElement('div');
statusLine.className = 'rgas-row rgas-status';
const statusText = document.createElement('span');
statusText.id = 'rgas-status-text';
statusText.textContent = 'RG AutoScroll: OFF';
statusLine.appendChild(statusText);
// ── Row 2: Image delay controls ──
const delayLine = document.createElement('div');
delayLine.className = 'rgas-row rgas-delay';
delayLine.style.marginTop = '4px';
const label = document.createElement('span');
label.textContent = 'IMG delay:';
const btnMinus = document.createElement('button');
btnMinus.textContent = ' - ';
btnMinus.className = 'rgas-btn';
btnMinus.title = 'Decrease delay ([ key)';
btnMinus.addEventListener('click', (e) => { e.stopPropagation(); adjustImageDelay(-1); });
delayValueEl = document.createElement('span');
delayValueEl.className = 'rgas-delay-value';
delayValueEl.textContent = imageDelay + 's';
const btnPlus = document.createElement('button');
btnPlus.textContent = ' + ';
btnPlus.className = 'rgas-btn';
btnPlus.title = 'Increase delay (] key)';
btnPlus.addEventListener('click', (e) => { e.stopPropagation(); adjustImageDelay(1); });
delayLine.appendChild(label);
delayLine.appendChild(btnMinus);
delayLine.appendChild(delayValueEl);
delayLine.appendChild(btnPlus);
// ── Row 3: Countdown (shown only on images) ──
countdownEl = document.createElement('div');
countdownEl.className = 'rgas-row rgas-countdown';
countdownEl.style.display = 'none';
const countdownText = document.createElement('span');
countdownText.id = 'rgas-countdown-text';
countdownText.textContent = 'advancing in...';
countdownEl.appendChild(countdownText);
// ── Row 4: Drag hint ──
const dragHint = document.createElement('div');
dragHint.className = 'rgas-drag-hint';
dragHint.textContent = 'drag to move';
overlayEl.appendChild(statusLine);
overlayEl.appendChild(delayLine);
overlayEl.appendChild(countdownEl);
overlayEl.appendChild(dragHint);
// ── Inject styles ──
const style = document.createElement('style');
style.textContent = `
#rg-autoscroll-ui {
position: fixed;
top: 20px;
right: 20px;
padding: 10px 14px;
border-radius: 10px;
font-size: 12px;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
font-weight: 600;
color: #fff;
background: rgba(0, 0, 0, 0.75);
border: 1px solid rgba(255,255,255,0.12);
z-index: 999999;
user-select: none;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: opacity 0.3s ease, background 0.3s;
line-height: 1.4;
min-width: 210px;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
}
#rg-autoscroll-ui .rgas-row { white-space: nowrap; }
#rg-autoscroll-ui .rgas-delay {
display: flex;
align-items: center;
gap: 6px;
opacity: 0.7;
}
#rg-autoscroll-ui .rgas-btn {
background: rgba(255,255,255,0.12);
color: #fff;
border: 1px solid rgba(255,255,255,0.2);
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
font-weight: 700;
padding: 1px 8px;
line-height: 1.4;
transition: background 0.15s;
}
#rg-autoscroll-ui .rgas-btn:hover {
background: rgba(255,255,255,0.25);
}
#rg-autoscroll-ui .rgas-btn:active {
background: rgba(255,255,255,0.35);
transform: scale(0.95);
}
#rg-autoscroll-ui .rgas-drag-hint {
font-size: 9px;
opacity: 0.4;
text-align: center;
margin-top: 2px;
pointer-events: none;
}
#rg-autoscroll-ui .rgas-delay-value {
display: inline-block;
min-width: 28px;
text-align: center;
font-variant-numeric: tabular-nums;
}
#rg-autoscroll-ui .rgas-countdown {
margin-top: 4px;
font-size: 11px;
opacity: 0.8;
text-align: center;
}
#rg-autoscroll-ui .rgas-state-on { color: #ff8a80; }
#rg-autoscroll-ui .rgas-state-off { color: rgba(255,255,255,0.6); }
`;
document.head.appendChild(style);
// ── Make draggable ──
makeDraggable(overlayEl);
document.body.appendChild(overlayEl);
updateStatusUI();
}
// ─── Drag System ─────────────────────────────────────────
function makeDraggable(el) {
let dragging = false;
let offsetX = 0, offsetY = 0;
function onStart(clientX, clientY) {
dragging = true;
const rect = el.getBoundingClientRect();
offsetX = clientX - rect.left;
offsetY = clientY - rect.top;
// Switch from right-based to left/top positioning so drag feels natural
el.style.left = rect.left + 'px';
el.style.top = rect.top + 'px';
el.style.right = 'auto';
el.style.cursor = 'grabbing';
el.style.transition = 'background 0.3s'; // keep bg transition, drop the one that would lag the position
}
function onMove(clientX, clientY) {
if (!dragging) return;
const x = Math.max(0, Math.min(window.innerWidth - 40, clientX - offsetX));
const y = Math.max(0, Math.min(window.innerHeight - 40, clientY - offsetY));
el.style.left = x + 'px';
el.style.top = y + 'px';
}
function onEnd() {
if (!dragging) return;
dragging = false;
el.style.cursor = 'grab';
}
// Mouse
el.addEventListener('mousedown', (e) => {
// Don't drag when clicking buttons
if (e.target.closest('.rgas-btn')) return;
e.preventDefault();
onStart(e.clientX, e.clientY);
});
document.addEventListener('mousemove', (e) => onMove(e.clientX, e.clientY));
document.addEventListener('mouseup', onEnd);
// Touch
el.addEventListener('touchstart', (e) => {
if (e.target.closest('.rgas-btn')) return;
const t = e.touches[0];
onStart(t.clientX, t.clientY);
}, { passive: true });
document.addEventListener('touchmove', (e) => {
if (!dragging) return;
const t = e.touches[0];
onMove(t.clientX, t.clientY);
}, { passive: true });
document.addEventListener('touchend', onEnd);
// Initial cursor
el.style.cursor = 'grab';
}
function updateStatusUI(errorMsg) {
if (!overlayEl) return;
const statusText = document.getElementById('rgas-status-text');
if (errorMsg) {
statusText.textContent = `RG AutoScroll: ${errorMsg}`;
statusText.className = '';
overlayEl.style.background = 'rgba(160, 120, 0, 0.75)';
} else if (running) {
const arrow = reverse ? '◄ REVERSE' : '► FORWARD';
statusText.textContent = `RG AutoScroll: ON ${arrow}`;
statusText.className = 'rgas-state-on';
overlayEl.style.background = 'rgba(180, 40, 40, 0.7)';
} else {
statusText.textContent = 'RG AutoScroll: OFF';
statusText.className = 'rgas-state-off';
overlayEl.style.background = 'rgba(0, 0, 0, 0.75)';
}
if (delayValueEl) {
delayValueEl.textContent = imageDelay + 's';
}
}
function updateCountdown() {
if (!countdownEl || !running) {
if (countdownEl) countdownEl.style.display = 'none';
return;
}
if (isImageActive()) {
const remaining = getImageTimerRemaining();
const countdownText = document.getElementById('rgas-countdown-text');
if (countdownText) {
countdownText.textContent = `advancing in ${remaining.toFixed(1)}s...`;
}
countdownEl.style.display = 'block';
} else {
countdownEl.style.display = 'none';
}
}
// ─── Key Listener ────────────────────────────────────────
document.addEventListener('keydown', (e) => {
if (
e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable
) return;
// : → toggle start/stop
if (e.key === ':') {
e.preventDefault();
running ? stop() : start();
}
// r → toggle direction
if (e.key === 'r' || e.key === 'R') {
e.preventDefault();
toggleDirection();
}
// h → hide/show UI
if (e.key === 'h' || e.key === 'H') {
e.preventDefault();
toggleUI();
}
// [ → decrease image delay
if (e.key === '[') {
e.preventDefault();
adjustImageDelay(-1);
}
// ] → increase image delay
if (e.key === ']') {
e.preventDefault();
adjustImageDelay(1);
}
// 1-9 → set image delay directly
if (e.key >= '1' && e.key <= '9') {
e.preventDefault();
setImageDelay(parseInt(e.key, 10));
}
});
// ─── Init ────────────────────────────────────────────────
createStatusOverlay();
log('Script v4.1 loaded.');
log('Keys: : start/stop | r reverse | h hide UI | [ ] adjust img delay | 1-9 set img delay');
})();