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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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