Auto-play video thumbnails

Auto-play video thumbnails with playback speed control and pause functionality on adult sites

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Auto-play video thumbnails 
// @namespace    https://greasyfork.org/users/1168969
// @version      2.0
// @description  Auto-play video thumbnails with playback speed control and pause functionality on adult sites
// @author       6969RandomGuy6969
// @match        https://sxyprn.com/*
// @match        https://watchporn.to/*
// @match        https://yesporn.vip/*
// @match        https://www.theyarehuge.com/*
// @match        https://www.eporner.com/*
// @match        https://www.shyfap.net/*
// @match        https://www.wow.xxx/*
// @match        https://pornone.com/*
// @match        https://www.tnaflix.com/*
// @match        https://www.pornhits.com/*
// @match        https://hqporner.com/*
// @match        https://www.hqporner.com/*
// @match        https://m.hqporner.com/*
// @match        https://pornmz.com/*
// @grant        GM_registerMenuCommand
// @grant        GM_openInTab
// @grant        GM_setValue
// @grant        GM_getValue
// @icon         https://cdn-icons-png.flaticon.com/512/3998/3998861.png
// ==/UserScript==

(function () {
  'use strict';

  const SETTINGS = {
    playbackSpeed: GM_getValue('playbackSpeed', 1),
    isPaused: GM_getValue('isPaused', false),
    panelX: GM_getValue('panelX', null),
    panelY: GM_getValue('panelY', null)
  };

  const videoRegistry = new Set();
  let configPanel = null;

  function updateAllVideos() {
    videoRegistry.forEach(video => {
      if (video && video.parentNode) {
        video.playbackRate = SETTINGS.playbackSpeed;
        if (SETTINGS.isPaused) {
          video.pause();
        } else {
          video.play().catch(() => {});
        }
      }
    });
  }

  function setPlaybackSpeed(speed) {
    speed = Math.max(0.25, Math.min(2, speed));
    SETTINGS.playbackSpeed = speed;
    GM_setValue('playbackSpeed', speed);
    updateAllVideos();
    updatePanelDisplay();
  }

  function togglePause() {
    SETTINGS.isPaused = !SETTINGS.isPaused;
    GM_setValue('isPaused', SETTINGS.isPaused);
    updateAllVideos();
    updatePanelDisplay();
  }

  function updatePanelDisplay() {
    if (!configPanel) return;
    const speedDisplay = configPanel.querySelector('.speed-display');
    const pauseBtn = configPanel.querySelector('.pause-btn');
    if (speedDisplay) speedDisplay.textContent = `${SETTINGS.playbackSpeed.toFixed(2)}x`;
    if (pauseBtn) pauseBtn.textContent = SETTINGS.isPaused ? '▶️ Play' : '⏸️ Pause';
  }

  function createConfigPanel() {
    if (configPanel) {
      configPanel.style.display = 'block';
      return;
    }

    const panel = document.createElement('div');
    panel.id = 'video-config-panel';
    panel.innerHTML = `
      <div class="panel-header">
        <span class="panel-title">Thumbnail Control</span>
        <button class="close-btn">×</button>
      </div>
      <div class="panel-body">
        <div class="speed-control">
          <button class="speed-btn minus-btn">−</button>
          <span class="speed-display">${SETTINGS.playbackSpeed.toFixed(2)}x</span>
          <button class="speed-btn plus-btn">+</button>
        </div>
        <button class="pause-btn">${SETTINGS.isPaused ? '▶️ Play' : '⏸️ Pause'}</button>
      </div>
    `;

    const style = document.createElement('style');
    style.textContent = `
      #video-config-panel {
        position: fixed;
        z-index: 999999;
        background: rgba(20, 20, 20, 0.95);
        backdrop-filter: blur(10px);
        -webkit-backdrop-filter: blur(10px);
        border: 1px solid rgba(255, 255, 255, 0.1);
        border-radius: 12px;
        padding: 0;
        font-family: Arial, sans-serif;
        box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
        user-select: none;
        min-width: 160px;
        max-width: 200px;
      }
      #video-config-panel .panel-header {
        background: rgba(40, 40, 40, 0.8);
        padding: 8px 10px;
        border-bottom: 1px solid rgba(255, 255, 255, 0.1);
        display: flex;
        justify-content: space-between;
        align-items: center;
        cursor: move;
        border-radius: 11px 11px 0 0;
      }
      #video-config-panel .panel-title {
        color: #fff;
        font-size: 12px;
        font-weight: bold;
        text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
        white-space: nowrap;
      }
      #video-config-panel .close-btn {
        background: rgba(255, 255, 255, 0.1);
        border: none;
        color: #fff;
        font-size: 20px;
        font-weight: bold;
        cursor: pointer;
        padding: 0;
        width: 22px;
        height: 22px;
        line-height: 20px;
        border-radius: 4px;
        transition: all 0.2s;
        flex-shrink: 0;
      }
      #video-config-panel .close-btn:hover {
        background: rgba(255, 68, 68, 0.8);
        box-shadow: 0 0 10px rgba(255, 68, 68, 0.5);
      }
      #video-config-panel .panel-body {
        padding: 12px;
        background: rgba(30, 30, 30, 0.5);
        border-radius: 0 0 11px 11px;
      }
      #video-config-panel .speed-control {
        display: flex;
        align-items: center;
        justify-content: center;
        gap: 8px;
        margin-bottom: 10px;
      }
      #video-config-panel .speed-btn {
        background: rgba(255, 255, 255, 0.15);
        backdrop-filter: blur(5px);
        -webkit-backdrop-filter: blur(5px);
        border: 1px solid rgba(255, 255, 255, 0.2);
        color: #fff;
        font-size: 18px;
        font-weight: bold;
        width: 32px;
        height: 32px;
        border-radius: 6px;
        cursor: pointer;
        transition: all 0.2s;
        padding: 0;
        text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
        display: flex;
        align-items: center;
        justify-content: center;
      }
      #video-config-panel .speed-btn:hover {
        background: rgba(255, 255, 255, 0.2);
        border-color: rgba(255, 255, 255, 0.3);
        box-shadow: 0 0 10px rgba(255, 255, 255, 0.2);
      }
      #video-config-panel .speed-btn:active { transform: scale(0.95); }
      #video-config-panel .speed-display {
        color: #fff;
        font-size: 14px;
        font-weight: bold;
        min-width: 50px;
        text-align: center;
        text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
      }
      #video-config-panel .pause-btn {
        width: 100%;
        background: rgba(255, 255, 255, 0.15);
        backdrop-filter: blur(5px);
        -webkit-backdrop-filter: blur(5px);
        border: 1px solid rgba(255, 255, 255, 0.2);
        color: #fff;
        padding: 8px;
        font-size: 13px;
        font-weight: 600;
        border-radius: 6px;
        cursor: pointer;
        transition: all 0.2s;
        text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
      }
      #video-config-panel .pause-btn:hover {
        background: rgba(255, 255, 255, 0.2);
        border-color: rgba(255, 255, 255, 0.3);
        box-shadow: 0 0 10px rgba(255, 255, 255, 0.2);
      }
      #video-config-panel .pause-btn:active { transform: scale(0.98); }
      @media (max-width: 600px) {
        #video-config-panel { min-width: 140px; max-width: 160px; }
        #video-config-panel .panel-header { padding: 6px 8px; }
        #video-config-panel .panel-title { font-size: 11px; }
        #video-config-panel .close-btn { width: 20px; height: 20px; font-size: 18px; }
        #video-config-panel .panel-body { padding: 8px; }
        #video-config-panel .speed-btn { width: 28px; height: 28px; font-size: 16px; }
        #video-config-panel .speed-display { font-size: 12px; min-width: 40px; }
        #video-config-panel .pause-btn { padding: 6px; font-size: 12px; }
      }
      @media (max-width: 400px) {
        #video-config-panel { min-width: 120px; max-width: 140px; }
        #video-config-panel .speed-control { gap: 4px; }
        #video-config-panel .speed-btn { width: 24px; height: 24px; font-size: 14px; }
        #video-config-panel .speed-display { font-size: 11px; min-width: 35px; }
      }
    `;
    document.head.appendChild(style);

    document.body.appendChild(panel);
    configPanel = panel;

    const panelWidth = 200;
    const panelHeight = 120;
    const margin = 10;

    if (SETTINGS.panelX !== null && SETTINGS.panelY !== null) {
      const maxX = window.innerWidth - panelWidth - margin;
      const maxY = window.innerHeight - panelHeight - margin;
      panel.style.left = Math.max(margin, Math.min(SETTINGS.panelX, maxX)) + 'px';
      panel.style.top  = Math.max(margin, Math.min(SETTINGS.panelY, maxY)) + 'px';
    } else {
      panel.style.right  = Math.min(20, window.innerWidth  - panelWidth  - margin) + 'px';
      panel.style.bottom = Math.min(20, window.innerHeight - panelHeight - margin) + 'px';
    }

    panel.querySelector('.close-btn').addEventListener('click', () => { panel.style.display = 'none'; });
    panel.querySelector('.minus-btn').addEventListener('click', () => { setPlaybackSpeed(SETTINGS.playbackSpeed - 0.25); });
    panel.querySelector('.plus-btn').addEventListener('click',  () => { setPlaybackSpeed(SETTINGS.playbackSpeed + 0.25); });
    panel.querySelector('.pause-btn').addEventListener('click', togglePause);

    // Draggable
    let isDragging = false, currentX, currentY, initialX, initialY;
    const header = panel.querySelector('.panel-header');

    header.addEventListener('mousedown', e => {
      if (e.target.classList.contains('close-btn')) return;
      isDragging = true;
      initialX = e.clientX - (SETTINGS.panelX || panel.offsetLeft);
      initialY = e.clientY - (SETTINGS.panelY || panel.offsetTop);
    });

    document.addEventListener('mousemove', e => {
      if (!isDragging) return;
      e.preventDefault();
      const rect = panel.getBoundingClientRect();
      const maxX = window.innerWidth  - rect.width  - margin;
      const maxY = window.innerHeight - rect.height - margin;
      currentX = Math.max(margin, Math.min(e.clientX - initialX, maxX));
      currentY = Math.max(margin, Math.min(e.clientY - initialY, maxY));
      panel.style.left   = currentX + 'px';
      panel.style.top    = currentY + 'px';
      panel.style.right  = 'auto';
      panel.style.bottom = 'auto';
    });

    document.addEventListener('mouseup', () => {
      if (isDragging) {
        SETTINGS.panelX = currentX || panel.offsetLeft;
        SETTINGS.panelY = currentY || panel.offsetTop;
        GM_setValue('panelX', SETTINGS.panelX);
        GM_setValue('panelY', SETTINGS.panelY);
      }
      isDragging = false;
    });

    window.addEventListener('resize', () => {
      if (!configPanel || configPanel.style.display === 'none') return;
      const rect = configPanel.getBoundingClientRect();
      let newX = rect.left, newY = rect.top, needs = false;
      if (rect.right  > window.innerWidth  - margin) { newX = window.innerWidth  - rect.width  - margin; needs = true; }
      if (rect.left   < margin)                       { newX = margin;                                     needs = true; }
      if (rect.bottom > window.innerHeight - margin)  { newY = window.innerHeight - rect.height - margin; needs = true; }
      if (rect.top    < margin)                       { newY = margin;                                     needs = true; }
      if (needs) {
        configPanel.style.left = newX + 'px'; configPanel.style.top = newY + 'px';
        configPanel.style.right = 'auto';     configPanel.style.bottom = 'auto';
        SETTINGS.panelX = newX; SETTINGS.panelY = newY;
        GM_setValue('panelX', newX); GM_setValue('panelY', newY);
      }
    });
  }

  // ==================== GM MENU ====================
  GM_registerMenuCommand('⚙️ Open Config UI', createConfigPanel);

  // ── Supported Sites ──
  GM_registerMenuCommand('🌐 Sxyprn', () => GM_openInTab('https://sxyprn.com', { active: true }));
  GM_registerMenuCommand('🌐 WatchPorn', () => GM_openInTab('https://watchporn.to', { active: true }));
  GM_registerMenuCommand('🌐 YesPorn', () => GM_openInTab('https://yesporn.vip', { active: true }));
  GM_registerMenuCommand('🌐 TheyAreHuge', () => GM_openInTab('https://www.theyarehuge.com', { active: true }));
  GM_registerMenuCommand('🌐 Eporner', () => GM_openInTab('https://www.eporner.com', { active: true }));
  GM_registerMenuCommand('🌐 ShyFap', () => GM_openInTab('https://www.shyfap.net', { active: true }));
  GM_registerMenuCommand('🌐 Wow.xxx', () => GM_openInTab('https://www.wow.xxx', { active: true }));
  GM_registerMenuCommand('🌐 Pornone', () => GM_openInTab('https://pornone.com', { active: true }));
  GM_registerMenuCommand('🌐 Tnaflix', () => GM_openInTab('https://www.tnaflix.com', { active: true }));
  GM_registerMenuCommand('🌐 PornHits', () => GM_openInTab('https://www.pornhits.com', { active: true }));
  GM_registerMenuCommand('🌐 HQPorner', () => GM_openInTab('https://hqporner.com', { active: true }));
  GM_registerMenuCommand('🌐 PornMZ', () => GM_openInTab('https://pornmz.com', { active: true }));

  // ── Script Links ──
  GM_registerMenuCommand('🔞 More Scripts (Sleazyfork)', () => {
    GM_openInTab('https://sleazyfork.org/en/users/1168969-6969randomguy6969', { active: true });
  });
  GM_registerMenuCommand('📜 More Scripts (Greasyfork)', () => {
    GM_openInTab('https://greasyfork.org/en/users/1168969-6969randomguy6969', { active: true });
  });

  // ==================== CORE FUNCTIONALITY ====================
  const hostname = window.location.hostname;

  // ---- FIX 1: Don't hide the image until the video is actually ready to play ----
  // ---- FIX 2: Keep the image visible as fallback if video fails ----
  function insertPreviewVideo(image, videoUrl) {
    if (!videoUrl) return;
    if (image.dataset.previewAttached) return;
    image.dataset.previewAttached = '1';

    const video = document.createElement('video');
    video.src = videoUrl;
    video.muted = true;
    video.loop = true;
    video.autoplay = !SETTINGS.isPaused;
    video.playsInline = true;
    video.preload = 'auto';
    video.playbackRate = SETTINGS.playbackSpeed;
    video.style.cssText = 'width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;';

    video.setAttribute('importance', 'high');
    video.setAttribute('fetchpriority', 'high');

    videoRegistry.add(video);

    const parent = image.parentNode;
    parent.style.position = 'relative';
    parent.appendChild(video);

    const showVideo = () => {
      video.style.opacity = '1';
      image.style.opacity = '0';
      if (!SETTINGS.isPaused) video.play().catch(() => {});
    };

    // Try canplay first
    video.addEventListener('canplay', showVideo, { once: true });

    // Fallback: if canplay doesn't fire in 800ms, show anyway
    // (site JS may suppress the event by manipulating the element)
    const fallbackTimer = setTimeout(() => {
      video.removeEventListener('canplay', showVideo);
      showVideo();
    }, 800);

    video.addEventListener('canplay', () => clearTimeout(fallbackTimer), { once: true });

    video.load();
    if (!SETTINGS.isPaused) video.play().catch(() => {});

    video.onerror = () => {
      clearTimeout(fallbackTimer);
      videoRegistry.delete(video);
      video.remove();
      image.style.opacity = '1';
    };
  }

  // IntersectionObserver wrapper
  const activeObservers = new Set();

  function observeElements(selector, getUrl) {
    // Don't set up duplicate observers for the same selector
    if (activeObservers.has(selector)) return;
    activeObservers.add(selector);
    const observer = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const el = entry.target;
          const url = getUrl(el);
          if (url) insertPreviewVideo(el, url);
          observer.unobserve(el);
        }
      });
    }, { rootMargin: '500px', threshold: 0.01 });

    const observedElements = new WeakSet();

    function watch() {
      document.querySelectorAll(selector).forEach(el => {
        if (!observedElements.has(el)) {
          observer.observe(el);
          observedElements.add(el);
        }
      });
    }

    watch();

    let mutationTimeout;
    new MutationObserver(() => {
      clearTimeout(mutationTimeout);
      mutationTimeout = setTimeout(watch, 100);
    }).observe(document.body, { childList: true, subtree: true });

    setTimeout(watch, 500);
    setTimeout(watch, 1500);
  }

  // ==================== SITE HANDLERS ====================
  const siteHandlers = {

    "eporner.com": function () {
      function getPreviewUrl(id) {
        const s = id.toString();
        if (s.length < 5) return null;
        return `https://static-eu-cdn.eporner.com/thumbs/static4/${s[0]}/${s.slice(0,2)}/${s.slice(0,3)}/${s}/${s}-preview.webm`;
      }

      function initThumb(thumb) {
        if (thumb.dataset.epInit) return;
        thumb.dataset.epInit = '1';

        const url = getPreviewUrl(thumb.dataset.id);
        if (!url) return;

        let vid = thumb.querySelector('video');
        if (!vid) {
          vid = document.createElement('video');
          vid.muted = true;
          vid.loop = true;
          vid.playsInline = true;
          vid.preload = 'auto';
          vid.style.cssText = 'position:absolute !important;inset:0 !important;width:100% !important;height:100% !important;object-fit:cover !important;z-index:10 !important;background:#000;opacity:1 !important;';
          const cont = thumb.querySelector('.mbimg, .mbcontent') || thumb;
          cont.style.position = 'relative';
          cont.style.overflow = 'hidden';
          cont.appendChild(vid);
        }

        if (vid.src !== url) {
          vid.removeAttribute('src');
          vid.src = url;
          vid.load();
        }

        // Register for global pause/speed control
        videoRegistry.add(vid);
        vid.playbackRate = SETTINGS.playbackSpeed;

        // Play only when visible, pause when scrolled away (anti-throttle)
        const obs = new IntersectionObserver(([entry]) => {
          if (entry.isIntersecting) {
            if (vid.paused && !SETTINGS.isPaused) vid.play().catch(() => {});
          } else {
            if (!vid.paused) vid.pause();
          }
        }, { threshold: [0.01, 0.3] });
        obs.observe(thumb);
      }

      // Hide static img/badges so video shows through
      const style = document.createElement('style');
      style.textContent = `.mb img,.mvhdico,[style*="ajax_loader"]{opacity:0 !important;pointer-events:none !important}.mb .mbimg,.mb .mbcontent{background:#000 !important}`;
      document.head.appendChild(style);

      // Process existing cards
      document.querySelectorAll('div.mb[data-id]').forEach(initThumb);

      // Watch for new cards (infinite scroll / AJAX)
      new MutationObserver(() => {
        document.querySelectorAll('div.mb[data-id]:not([data-ep-init])').forEach(initThumb);
      }).observe(document.body, { childList: true, subtree: true });

      // Safety interval for any missed cards
      setInterval(() => {
        document.querySelectorAll('div.mb[data-id]').forEach(initThumb);
      }, 4000);
    },

    "sxyprn.com": function () {
      document.querySelectorAll('.mini_post_vid_thumb').forEach(img => {
        const video = img.nextElementSibling;
        if (video && (video.tagName === 'VIDEO' || video.tagName === 'IFRAME')) {
          video.playbackRate = SETTINGS.playbackSpeed;
          videoRegistry.add(video);
          if (SETTINGS.isPaused) { video.pause(); } else { video.play(); }
        }
      });
    },

    "watchporn.to": () =>
      observeElements('img.thumb.lazy-load[data-preview]', el => el.getAttribute('data-preview')),

    "yesporn.vip": () =>
      observeElements('img.lazy-load[data-preview]', el => el.getAttribute('data-preview')),

    "theyarehuge.com": function () {
      observeElements('img[data-preview]', el => el.getAttribute('data-preview'));

      const style = document.createElement('style');
      style.textContent = `
        body, html { background-color:#000 !important; color:#d1d1d1 !important; }
        * { background-color:transparent !important; border-color:#444 !important; color:inherit !important; }
        a, p, h1, h2, h3, h4, h5, h6, span, div { color:#d1d1d1 !important; }
        img, video { filter:brightness(0.95) contrast(1.1); }
        .header, .footer, .sidebar, .navbar, .top-menu, .main-header { background-color:#000 !important; }
      `;
      document.head.appendChild(style);

      const logo = document.querySelector('img[src*="tah-logo-m.png"]');
      if (logo) logo.style.filter = 'invert(1) hue-rotate(-180deg) brightness(1) saturate(10)';
    },

    "shyfap.net": function () {
      document.querySelectorAll('.media-card_preview').forEach(card => {
        const videoUrl = card.getAttribute('data-preview');
        const video = card.querySelector('video');
        const img   = card.querySelector('img');

        if (video) {
          video.muted = true;
          video.loop = true;
          video.autoplay = !SETTINGS.isPaused;
          video.playsInline = true;
          video.preload = 'auto';
          video.playbackRate = SETTINGS.playbackSpeed;
          video.style.width = '100%';
          video.style.height = '100%';
          video.style.objectFit = 'cover';
          videoRegistry.add(video);
          if (img) img.style.display = 'none';
          if (SETTINGS.isPaused) video.pause();
        } else if (img && videoUrl) {
          insertPreviewVideo(img, videoUrl);
        }
      });
    },

    "wow.xxx": function () {
      observeElements('.thumb__img[data-preview] img.thumb', img => {
        const container = img.closest('.thumb__img');
        return container ? container.getAttribute('data-preview') : null;
      });
    },

    "pornone.com": function () {
      // pornone.com has NO mp4 previews — it cycles JPG frames from data-thumbs.
      // Frame URL: {data-path}d{thumbNum}.jpg
      // The site's own showImage() cycles every 800ms; we replicate it autonomously,
      // respecting isPaused and mapping playbackSpeed -> interval delay.
      // Speed 1x=800ms, 2x=400ms, 0.5x=1600ms, etc.

      const pnCyclers = new Map(); // img el -> { timer }

      function getIntervalMs() {
        return Math.round(800 / Math.max(0.25, SETTINGS.playbackSpeed));
      }

      function startCycler(img) {
        if (img.dataset.pnInit) return;
        img.dataset.pnInit = '1';

        const path = img.getAttribute('data-path');
        const thumbs = JSON.parse(img.getAttribute('data-thumbs') || '[]');
        if (!path || thumbs.length < 2) return;

        // Preload first few frames
        thumbs.slice(0, 5).forEach(n => { (new Image()).src = path + 'd' + n + '.jpg'; });

        let idx = 1;
        img.dataset.pnIdx = '1';

        const state = { timer: null };
        pnCyclers.set(img, state);

        function tick() {
          if (!SETTINGS.isPaused) {
            img.src = path + 'd' + thumbs[idx] + '.jpg';
            // Preload next
            const next = thumbs[(idx + 1) % thumbs.length];
            (new Image()).src = path + 'd' + next + '.jpg';
            idx = (idx + 1) % thumbs.length;
            img.dataset.pnIdx = String(idx);
          }
          state.timer = setTimeout(tick, getIntervalMs());
        }

        if (!SETTINGS.isPaused) {
          state.timer = setTimeout(tick, getIntervalMs());
        } else {
          // Still schedule, but tick() will skip frame when paused
          state.timer = setTimeout(tick, getIntervalMs());
        }
      }

      // IntersectionObserver: only cycle visible thumbs
      const pnObserver = new IntersectionObserver(entries => {
        entries.forEach(entry => {
          const img = entry.target;
          const state = pnCyclers.get(img);
          if (entry.isIntersecting) {
            startCycler(img);
            // If previously stopped (scrolled away), restart
            if (state && !state.timer) {
              const path = img.getAttribute('data-path');
              const thumbs = JSON.parse(img.getAttribute('data-thumbs') || '[]');
              let idx = parseInt(img.dataset.pnIdx || '1', 10);
              function tick() {
                if (!SETTINGS.isPaused) {
                  img.src = path + 'd' + thumbs[idx] + '.jpg';
                  const next = thumbs[(idx + 1) % thumbs.length];
                  (new Image()).src = path + 'd' + next + '.jpg';
                  idx = (idx + 1) % thumbs.length;
                  img.dataset.pnIdx = String(idx);
                }
                state.timer = setTimeout(tick, getIntervalMs());
              }
              state.timer = setTimeout(tick, getIntervalMs());
            }
          } else {
            // Pause cycling off-screen to save resources
            if (state && state.timer) {
              clearTimeout(state.timer);
              state.timer = null;
            }
          }
        });
      }, { rootMargin: '300px', threshold: 0.01 });

      function watchThumbs() {
        document.querySelectorAll('img.thumbimg[data-thumbs][data-path]').forEach(img => {
          if (!img.dataset.pnObserved) {
            img.dataset.pnObserved = '1';
            pnObserver.observe(img);
          }
        });
      }

      watchThumbs();
      new MutationObserver(watchThumbs).observe(document.body, { childList: true, subtree: true });
      setTimeout(watchThumbs, 500);
      setTimeout(watchThumbs, 2000);
      setInterval(watchThumbs, 5000);
    },

    "tnaflix.com": function () {
      // tnaflix.com provides a direct `data-trailer` mp4 URL on the <a> tag.
      // Selector: a.thumb-chrome[data-trailer]  →  img inside it is the thumbnail.
      // Just grab data-trailer and hand it to insertPreviewVideo — cleanest possible.

      function initCard(anchor) {
        if (anchor.dataset.tnInit) return;
        anchor.dataset.tnInit = '1';

        const url = anchor.getAttribute('data-trailer');
        const img = anchor.querySelector('img');
        if (!url || !img) return;

        insertPreviewVideo(img, url);
      }

      // IntersectionObserver — only load trailers near the viewport
      const tnObserver = new IntersectionObserver(entries => {
        entries.forEach(entry => {
          if (entry.isIntersecting) {
            initCard(entry.target);
            tnObserver.unobserve(entry.target);
          }
        });
      }, { rootMargin: '400px', threshold: 0.01 });

      function watchCards() {
        document.querySelectorAll('a.thumb-chrome[data-trailer]').forEach(anchor => {
          if (!anchor.dataset.tnObserved) {
            anchor.dataset.tnObserved = '1';
            tnObserver.observe(anchor);
          }
        });
      }

      watchCards();
      new MutationObserver(watchCards).observe(document.body, { childList: true, subtree: true });
      setTimeout(watchCards, 500);
      setTimeout(watchCards, 2000);
      setInterval(watchCards, 5000);
    },

    "pornhits.com": function () {
      // pornhits.com: img.thumb[data-preview] carries the preview URL directly.
      // URL scheme: //pv2.pornhits.com/v2/preview/ID.mp4  (protocol-relative)
      // Just prefix https: and feed to insertPreviewVideo — done.

      observeElements('img.thumb[data-preview]', img => {
        const raw = img.getAttribute('data-preview');
        if (!raw) return null;
        return raw.startsWith('//') ? 'https:' + raw : raw;
      });
    },

    "hqporner.com": function () {
      // hqporner.com has NO mp4 previews — it cycles 10 JPG frames per card.
      // Each img[id^="slide"] has src like: //cdn.../imgs/AA/BB/HASH_main.jpg
      // Frame URLs: //cdn.../imgs/AA/BB/HASH_1.jpg ... HASH_10.jpg  (10 frames)
      // Site cycles at 600ms; we replicate autonomously respecting isPaused/speed.
      // Speed 1x=600ms, 2x=300ms, 0.5x=1200ms etc.
      // The "play images" button just enables slideShowPlayer on click — we bypass
      // it entirely and start cycling immediately on IntersectionObserver trigger.

      const hqCyclers = new Map(); // img el -> { timer }

      function getIntervalMs() {
        return Math.round(600 / Math.max(0.25, SETTINGS.playbackSpeed));
      }

      function getFrameUrls(img) {
        // src: //fastporndelivery.hqporner.com/imgs/AA/BB/HASH_main.jpg
        // OR after "play images" click: HASH_10.jpg
        const src = img.getAttribute('src') || '';
        const base = src.replace(/https?:/, '').replace(/_(?:main|\d+)\.jpg$/, '');
        if (!base || !base.includes('/imgs/')) return null;
        const frames = [];
        for (let i = 1; i <= 10; i++) frames.push('https:' + base + '_' + i + '.jpg');
        return frames;
      }

      function startCycler(img) {
        if (img.dataset.hqInit) return;
        img.dataset.hqInit = '1';

        const frames = getFrameUrls(img);
        if (!frames) return;

        // Preload first 3 frames eagerly
        frames.slice(0, 3).forEach(u => { (new Image()).src = u; });

        let idx = 0;
        const state = { timer: null };
        hqCyclers.set(img, state);

        function tick() {
          if (!SETTINGS.isPaused) {
            img.setAttribute('src', frames[idx]);
            idx = (idx + 1) % frames.length;
            // Preload next
            (new Image()).src = frames[idx];
          }
          state.timer = setTimeout(tick, getIntervalMs());
        }
        state.timer = setTimeout(tick, getIntervalMs());
      }

      function stopCycler(img) {
        const state = hqCyclers.get(img);
        if (state && state.timer) {
          clearTimeout(state.timer);
          state.timer = null;
        }
      }

      function resumeCycler(img) {
        const state = hqCyclers.get(img);
        if (!state || state.timer) return;
        const frames = getFrameUrls(img);
        if (!frames) return;
        let idx = 0;
        function tick() {
          if (!SETTINGS.isPaused) {
            img.setAttribute('src', frames[idx]);
            idx = (idx + 1) % frames.length;
            (new Image()).src = frames[idx];
          }
          state.timer = setTimeout(tick, getIntervalMs());
        }
        state.timer = setTimeout(tick, getIntervalMs());
      }

      const hqObserver = new IntersectionObserver(entries => {
        entries.forEach(entry => {
          const img = entry.target;
          if (entry.isIntersecting) {
            startCycler(img);
            resumeCycler(img);
          } else {
            stopCycler(img);
          }
        });
      }, { rootMargin: '300px', threshold: 0.01 });

      function watchThumbs() {
        document.querySelectorAll('img[id^="slide"]').forEach(img => {
          if (!img.dataset.hqObserved) {
            img.dataset.hqObserved = '1';
            hqObserver.observe(img);
          }
        });
      }

      watchThumbs();
      new MutationObserver(watchThumbs).observe(document.body, { childList: true, subtree: true });
      setTimeout(watchThumbs, 500);
      setTimeout(watchThumbs, 2000);
      setInterval(watchThumbs, 5000);
    },

    "pornmz.com": function () {
      // pornmz.com: data-trailer on <article> carries a direct mp4 URL.
      // The thumbnail img.video-main-thumb sits inside .post-thumbnail-container
      // inside .post-thumbnail inside the <a> inside the <article>.
      // Strategy: find article[data-trailer], grab img.video-main-thumb inside it,
      // feed data-trailer to insertPreviewVideo — identical pattern to tnaflix.

      function initCard(article) {
        if (article.dataset.pmzInit) return;
        article.dataset.pmzInit = '1';

        const url = article.getAttribute('data-trailer');
        const img = article.querySelector('img.video-main-thumb');
        if (!url || !img) return;

        insertPreviewVideo(img, url);
      }

      const pmzObserver = new IntersectionObserver(entries => {
        entries.forEach(entry => {
          if (entry.isIntersecting) {
            initCard(entry.target);
            pmzObserver.unobserve(entry.target);
          }
        });
      }, { rootMargin: '400px', threshold: 0.01 });

      function watchCards() {
        document.querySelectorAll('article[data-trailer]').forEach(article => {
          if (!article.dataset.pmzObserved) {
            article.dataset.pmzObserved = '1';
            pmzObserver.observe(article);
          }
        });
      }

      watchCards();
      new MutationObserver(watchCards).observe(document.body, { childList: true, subtree: true });
      setTimeout(watchCards, 500);
      setTimeout(watchCards, 2000);
      setInterval(watchCards, 5000);
    }
  };

  // Dispatcher — run immediately AND on DOMContentLoaded as fallback
  function dispatch() {
    for (const domain in siteHandlers) {
      if (hostname.includes(domain)) {
        siteHandlers[domain]();
        break;
      }
    }
  }

  // Run now (for scripts injected after DOM is ready)
  dispatch();

  // Also run on DOMContentLoaded in case we injected before DOM was ready
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', dispatch);
  }

})();