RedGifs Auto-Scroller

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.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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