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.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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