Pornhub Progress Bar Thumbnail Preview

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

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 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)

Advertisement:

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)

Advertisement:

// ==UserScript==
// @name         Pornhub Progress Bar Thumbnail Preview
// @namespace    Just do it
// @version      4.1
// @description  Pornhub 视频进度条悬停缩略图预览
// @author       Burgess Leo
// @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: 10,
    debug: true,
  };

  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{position:absolute;top:0;left:0;width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.85);color:#999;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=... → 按 vts 值排序后顺序分配 sheet 索引
    const sheetUrlMap = new Map();
    const vtsEntries = [];

    for (const url of thumbs.spritePatterns) {
      const sm = url.match(/S(\d+)\.jpg/);
      if (sm) {
        const idx = parseInt(sm[1], 10);
        if (!sheetUrlMap.has(idx)) sheetUrlMap.set(idx, []);
        sheetUrlMap.get(idx).push(url);
      } else {
        const vm = url.match(/vts:(\d+)/);
        if (vm) vtsEntries.push({ vts: parseInt(vm[1], 10), url });
      }
    }
    // vts 格式的 URL 按数值排序后顺序分配 sheet 索引
    vtsEntries.sort((a, b) => a.vts - b.vts);
    for (let i = 0; i < vtsEntries.length; i++) {
      if (!sheetUrlMap.has(i)) sheetUrlMap.set(i, []);
      sheetUrlMap.get(i).push(vtsEntries[i].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(barEl) {
      this.root = null; this.canvas = null; this.label = null;
      this.loadingEl = null; this.visible = false;
      this._lastTime = 0; this._lastFrameData = null;
      this._barEl = barEl;
      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);

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

    _getContainer() {
      // 全屏元素优先,确保预览在全屏层之上
      if (document.fullscreenElement) return document.fullscreenElement;
      // 其次查找进度条的最近播放器容器
      if (this._barEl) {
        const player = this._barEl.closest('[class*="player" i], .mgp_player');
        if (player) return player;
      }
      // 兜底使用 body
      return document.body;
    }

    show(x, y) {
      const container = this._getContainer();
      if (this.root.parentNode !== container) {
        container.appendChild(this.root);
      }
      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(frameData));
            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(frameData) {
      if (!this.visible || !frameData) return;
      const ctx = this.canvas.getContext('2d');
      ctx.clearRect(0, 0, CFG.previewWidth, CFG.previewHeight);
      this.loadingEl.style.display = 'none';
      try {
        ctx.drawImage(frameData.image, frameData.sx, frameData.sy, frameData.sw, frameData.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.barEl);
      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());
      // 全屏切换时隐藏预览,下次 hover 会自动挂载到正确容器
      document.addEventListener('fullscreenchange', () => {
        if (this.ui?.visible) this.ui.hide();
      });
    }

    _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);
})();