Pornhub Progress Bar Thumbnail Preview

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

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

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