Pornhub Progress Bar Thumbnail Preview

Pornhub 视频进度条悬停缩略图预览

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

Advertisement:

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

Advertisement:

// ==UserScript==
// @name         Pornhub Progress Bar Thumbnail Preview
// @namespace    https://github.com/knight
// @version      4.0
// @description  Pornhub 视频进度条悬停缩略图预览
// @author       Jone Snow
// @match        *://*.pornhub.com/view_video.php*
// @match        *://*.pornhubpremium.com/view_video.php*
// @grant        none
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const CFG = {
    previewWidth: 200,
    previewHeight: 112,
    hoverDelay: 0,
    debug: false,
  };

  function log(...args) { if (CFG.debug) console.log('[PH-Thumb]', ...args); }
  function warn(...args) { console.warn('[PH-Thumb]', ...args); }

  const fmtTime = (s) => {
    if (!isFinite(s) || s < 0) return '0:00';
    const h = Math.floor(s / 3600);
    const m = Math.floor((s % 3600) / 60);
    const sec = Math.floor(s % 60);
    return h > 0
      ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
      : `${m}:${String(sec).padStart(2, '0')}`;
  };

  // ======================== CSS ========================
  const STYLE_ID = 'ph-thumb-style';
  function injectStyles() {
    if (document.getElementById(STYLE_ID)) return;
    const s = document.createElement('style');
    s.id = STYLE_ID;
    s.textContent = `
.ph-thumb-root{position:fixed;z-index:2147483647;pointer-events:none;display:none;transform:translate(-50%,-100%);margin-top:-10px;}
.ph-thumb-root.on{display:block;}
.ph-thumb-inner{position:relative;background:#000;border:2px solid rgba(255,255,255,.85);border-radius:6px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.6);}
.ph-thumb-inner canvas{display:block;}
.ph-thumb-label{position:absolute;bottom:4px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.75);color:#fff;font-size:11px;font-family:Arial,Helvetica,sans-serif;padding:2px 8px;border-radius:10px;white-space:nowrap;pointer-events:none;line-height:1.4;}
.ph-thumb-loading{color:#999;text-align:center;padding:20px 30px;font-size:12px;font-family:Arial,Helvetica,sans-serif;}
.ph-thumb-arrow{position:absolute;left:50%;bottom:-6px;transform:translateX(-50%);width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-top:6px solid rgba(255,255,255,.85);}`;
    document.head.appendChild(s);
  }

  // ======================== 进度条查找 ========================
  function findProgressBar(videoEl) {
    const container = videoEl.closest('[class*="player" i],.mgp_player') || videoEl.parentElement || document;
    const selectors = ['.mgp_seekbar','.mgp_progressWrapper','.mgp_progressBar','.mgp_progressElapsed'];
    for (const sel of selectors) {
      try {
        for (const el of container.querySelectorAll(sel)) {
          const r = el.getBoundingClientRect();
          if (r.width >= 50 && r.height >= 3) { log('Progress bar:', sel); return el; }
        }
      } catch (_) {}
    }
    return null;
  }

  // ======================== Pornhub 缩略图数据源 ========================
  function createPornhubSource(videoEl) {
    const fvKey = Object.keys(window).find(k => k.startsWith('flashvars_'));
    if (!fvKey) return null;
    const fv = window[fvKey];
    const thumbs = fv?.thumbs;
    if (!thumbs?.spritePatterns?.length) return null;

    const dur = videoEl.duration || fv.video_duration;
    if (!dur || !isFinite(dur)) return null;

    const samplingFreq = parseInt(thumbs.samplingFrequency, 10) || 4;
    const frameW = parseInt(thumbs.thumbWidth, 10) || 160;
    const frameH = parseInt(thumbs.thumbHeight, 10) || 90;
    const totalFrames = Math.ceil(dur / samplingFreq);

    // 有 urlPattern → 单帧模式
    if (thumbs.urlPattern) {
      log('Mode: single-frame');
      return buildSingleFrameSource(dur, samplingFreq, frameW, frameH, totalFrames, thumbs.urlPattern);
    }

    // 扫描 spritePatterns 获取各 sheet 的 URL
    // 格式1: .../S{n}.jpg        → S 编号即 sheet 索引
    // 格式2: .../vts:{n}?hash=... → n/(samplingFreq*25) 得 sheet 索引
    const sheetUrlMap = new Map();
    const fps = samplingFreq * 25;

    for (const url of thumbs.spritePatterns) {
      let sheetIdx = null;
      const sm = url.match(/S(\d+)\.jpg/);
      if (sm) { sheetIdx = parseInt(sm[1], 10); }
      if (sheetIdx === null) {
        const vm = url.match(/vts:(\d+)/);
        if (vm) { sheetIdx = Math.round(parseInt(vm[1], 10) / fps); }
      }
      if (sheetIdx !== null && sheetIdx < 50) {
        if (!sheetUrlMap.has(sheetIdx)) sheetUrlMap.set(sheetIdx, []);
        sheetUrlMap.get(sheetIdx).push(url);
      }
    }

    const templateUrl = thumbs.spritePatterns[0];
    const base = templateUrl.replace(/S\d+\.jpg/, '___SHEET___');
    const templateWorks = base !== templateUrl;

    log('Mode: sprite, sheets:', Array.from(sheetUrlMap.keys()).sort().join(','),
        templateWorks ? '+template' : '');

    return buildSpriteSource(dur, samplingFreq, frameW, frameH, totalFrames,
      templateUrl, base, templateWorks, sheetUrlMap);
  }

  // --- 单帧模式 ---
  function buildSingleFrameSource(dur, samplingFreq, frameW, frameH, totalFrames, urlPattern) {
    const frameCache = new Map();
    return {
      getFrame(time) {
        if (time < 0 || time > dur) return null;
        const fi = Math.min(Math.floor(time / samplingFreq), totalFrames - 1);
        if (frameCache.has(fi)) return frameCache.get(fi);
        const img = new Image();
        img.crossOrigin = 'anonymous';
        img.src = urlPattern.replace(/S\{(\d+)\}/, (_, n) =>
          'S' + String(fi).padStart(parseInt(n, 10), '0'));
        const r = { image: img, sx: 0, sy: 0, sw: frameW, sh: frameH };
        frameCache.set(fi, r);
        return r;
      },
    };
  }

  // --- 雪碧图模式 ---
  function buildSpriteSource(dur, samplingFreq, frameW, frameH, totalFrames,
                              templateUrl, base, templateWorks, sheetUrlMap) {
    const probeImg = new Image();
    probeImg.crossOrigin = 'anonymous';
    let cols = 5, rows = 5, framesPerSheet = 25;

    probeImg.onload = () => {
      cols = Math.floor(probeImg.naturalWidth / frameW) || 5;
      rows = Math.floor(probeImg.naturalHeight / frameH) || 5;
      framesPerSheet = cols * rows;
    };
    probeImg.src = templateUrl;

    const sheetCache = new Map();

    function getSheetUrl(sheetIdx) {
      const urls = sheetUrlMap.get(sheetIdx);
      if (urls && urls.length) return urls[0];
      if (templateWorks) return base.replace('___SHEET___', 'S' + sheetIdx + '.jpg');
      return null;
    }

    return {
      getFrame(time) {
        if (time < 0 || time > dur) return null;
        if (probeImg.complete && probeImg.naturalWidth > 0) {
          cols = Math.floor(probeImg.naturalWidth / frameW) || 5;
          rows = Math.floor(probeImg.naturalHeight / frameH) || 5;
          framesPerSheet = cols * rows;
        }

        const frameIdx = Math.min(Math.floor(time / samplingFreq), totalFrames - 1);
        const sheetIdx = Math.floor(frameIdx / framesPerSheet);
        const sheetUrl = getSheetUrl(sheetIdx);
        if (!sheetUrl) return null;

        if (!sheetCache.has(sheetIdx)) {
          const img = new Image();
          img.crossOrigin = 'anonymous';
          img.src = sheetUrl;
          sheetCache.set(sheetIdx, img);
        }

        const inSheet = frameIdx % framesPerSheet;
        return {
          image: sheetCache.get(sheetIdx),
          sx: (inSheet % cols) * frameW,
          sy: Math.floor(inSheet / cols) * frameH,
          sw: frameW,
          sh: frameH,
        };
      },
    };
  }

  // ======================== 预览浮层 UI ========================
  class ThumbnailPreview {
    constructor() {
      this.root = null; this.canvas = null; this.label = null;
      this.loadingEl = null; this.visible = false;
      this._lastTime = 0; this._lastFrameData = null;
      this._buildDOM();
    }

    _buildDOM() {
      const root = document.createElement('div');
      root.className = 'ph-thumb-root';
      const inner = document.createElement('div');
      inner.className = 'ph-thumb-inner';
      inner.style.width = CFG.previewWidth + 'px';
      inner.style.height = CFG.previewHeight + 'px';

      const canvas = document.createElement('canvas');
      canvas.width = CFG.previewWidth;
      canvas.height = CFG.previewHeight;
      canvas.style.width = CFG.previewWidth + 'px';
      canvas.style.height = CFG.previewHeight + 'px';
      inner.appendChild(canvas);

      const loading = document.createElement('div');
      loading.className = 'ph-thumb-loading';
      loading.textContent = '...';
      loading.style.display = 'none';
      inner.appendChild(loading);

      const label = document.createElement('div');
      label.className = 'ph-thumb-label';
      inner.appendChild(label);

      const arrow = document.createElement('div');
      arrow.className = 'ph-thumb-arrow';
      root.appendChild(inner);
      root.appendChild(arrow);
      document.body.appendChild(root);

      this.root = root; this.canvas = canvas;
      this.label = label; this.loadingEl = loading;
    }

    show(x, y) {
      this.root.style.left = x + 'px';
      this.root.style.top = y + 'px';
      if (!this.visible) { this.root.classList.add('on'); this.visible = true; }
    }

    hide() {
      if (this.visible) { this.root.classList.remove('on'); this.visible = false; }
    }

    update(time, frameData) {
      const ctx = this.canvas.getContext('2d');
      ctx.clearRect(0, 0, CFG.previewWidth, CFG.previewHeight);
      this.label.textContent = fmtTime(time);
      this._lastTime = time;
      this._lastFrameData = frameData;

      if (!frameData) { this.loadingEl.style.display = 'block'; return; }

      const img = frameData.image;
      if (!img || !img.complete || img.naturalWidth === 0) {
        if (img && !img.complete) {
          this.loadingEl.style.display = 'block';
          if (!img._phOnloadSet) {
            img._phOnloadSet = true;
            img.addEventListener('load', () => this._redraw());
            img.addEventListener('error', () => { this.loadingEl.style.display = 'block'; });
          }
        } else {
          this.loadingEl.style.display = 'block';
        }
        return;
      }

      this.loadingEl.style.display = 'none';
      try {
        ctx.drawImage(img, frameData.sx, frameData.sy, frameData.sw, frameData.sh,
                      0, 0, CFG.previewWidth, CFG.previewHeight);
      } catch (e) { this.loadingEl.style.display = 'block'; }
    }

    _redraw() {
      if (!this.visible || !this._lastFrameData) return;
      const ctx = this.canvas.getContext('2d');
      ctx.clearRect(0, 0, CFG.previewWidth, CFG.previewHeight);
      this.loadingEl.style.display = 'none';
      try {
        const d = this._lastFrameData;
        ctx.drawImage(d.image, d.sx, d.sy, d.sw, d.sh, 0, 0, CFG.previewWidth, CFG.previewHeight);
      } catch (e) {}
    }

    destroy() {
      if (this.root?.parentNode) this.root.parentNode.removeChild(this.root);
      this.root = this.canvas = this.label = null;
    }
  }

  // ======================== 预览管理器 ========================
  class PreviewManager {
    constructor(videoEl, barEl) {
      this.videoEl = videoEl; this.barEl = barEl;
      this.source = null; this.ui = null; this._hoverTimer = null;
    }

    init() {
      this.source = createPornhubSource(this.videoEl);
      if (!this.source) { log('No source'); return; }
      this.ui = new ThumbnailPreview();
      this._bindEvents();
    }

    _bindEvents() {
      const bar = this.barEl;
      bar.addEventListener('mousemove', e => this._onMouseMove(e));
      bar.addEventListener('mouseleave', () => this._onMouseLeave());
      bar.addEventListener('touchmove', e => this._onMouseMove(e.touches[0]), { passive: true });
      bar.addEventListener('touchend', () => this._onMouseLeave());
    }

    _getTime(clientX) {
      const r = this.barEl.getBoundingClientRect();
      const ratio = Math.max(0, Math.min(1, (clientX - r.left) / r.width));
      const dur = this.videoEl.duration;
      return (dur && isFinite(dur)) ? ratio * dur : null;
    }

    _onMouseMove(e) {
      const time = this._getTime(e.clientX);
      if (time === null) return;
      this.ui.show(e.clientX, this.barEl.getBoundingClientRect().top);
      if (this._hoverTimer) clearTimeout(this._hoverTimer);
      this._hoverTimer = setTimeout(() => {
        this._hoverTimer = null;
        this._renderFrame(time);
      }, CFG.hoverDelay);
    }

    _onMouseLeave() {
      this.ui.hide();
      if (this._hoverTimer) { clearTimeout(this._hoverTimer); this._hoverTimer = null; }
    }

    _renderFrame(time) {
      if (!this.source || !this.ui) return;
      this.ui.update(time, this.source.getFrame(time));
    }

    destroy() {
      this._onMouseLeave();
      if (this.ui) { this.ui.destroy(); this.ui = null; }
    }
  }

  // ======================== 扫描 & 启动 ========================
  const managedVideos = new WeakMap();

  function setupVideo(videoEl) {
    if (managedVideos.has(videoEl)) return;
    if (!videoEl.classList.contains('mgp_videoElement') && !videoEl.classList.contains('player-html5')) {
      const r = videoEl.getBoundingClientRect();
      if (r.width < 400 || r.height < 200) return;
    }
    const dur = videoEl.duration;
    if (!dur || !isFinite(dur) || dur < 1) {
      if (!videoEl.dataset.phWaiting) {
        videoEl.dataset.phWaiting = '1';
        videoEl.addEventListener('loadedmetadata', () => setupVideo(videoEl), { once: true });
        videoEl.addEventListener('durationchange', () => {
          if (videoEl.duration && isFinite(videoEl.duration) && videoEl.duration >= 1) setupVideo(videoEl);
        }, { once: true });
      }
      return;
    }
    const bar = findProgressBar(videoEl);
    if (!bar) { log('No progress bar'); return; }
    const mgr = new PreviewManager(videoEl, bar);
    managedVideos.set(videoEl, mgr);
    mgr.init();
  }

  function scan() {
    for (const v of document.querySelectorAll('video')) setupVideo(v);
  }

  function boot() {
    injectStyles();
    scan();
    new MutationObserver(mutations => {
      for (const m of mutations) {
        if (m.type !== 'childList') continue;
        for (const n of m.addedNodes) {
          if (n.nodeType === 1 && (n.tagName === 'VIDEO' || n.querySelector?.('video'))) { scan(); return; }
        }
      }
    }).observe(document.body, { childList: true, subtree: true });
  }

  if (document.readyState === 'complete') boot();
  else window.addEventListener('load', boot);
})();