Pornhub Progress Bar Thumbnail Preview

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

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         Pornhub Progress Bar Thumbnail Preview
// @namespace    Just do it
// @version      4.3
// @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,
    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{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 && !thumbs?.urlPattern) 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(无 spritePatterns)→ 单帧模式,兼容老视频格式
    if (thumbs.urlPattern && !thumbs.spritePatterns?.length) {
      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 sheetCache = new Map();

    // 用探针图片获取雪碧图的真实行列数
    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;
      // 真实行列数已知,补加载之前漏掉的 sheet
      preloadAll();
    };
    probeImg.src = templateUrl;

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

    function preloadSheet(sheetIdx) {
      if (sheetCache.has(sheetIdx)) return;
      const url = getSheetUrl(sheetIdx);
      if (!url) return;
      const img = new Image();
      img.crossOrigin = 'anonymous';
      img.src = url;
      sheetCache.set(sheetIdx, img);
    }

    function preloadAll() {
      const totalSheets = Math.ceil(totalFrames / framesPerSheet);
      for (let i = 0; i < totalSheets; i++) preloadSheet(i);
    }

    // 启动时立即预加载所有 sheet(行列数未知时按 5×5 估算,探针加载后校正补全)
    preloadAll();

    return {
      getFrame(time) {
        if (time < 0 || time > dur) return null;

        const frameIdx = Math.min(Math.floor(time / samplingFreq), totalFrames - 1);
        const sheetIdx = Math.floor(frameIdx / framesPerSheet);

        // 防御:万一有遗漏的 sheet 还没加载
        if (!sheetCache.has(sheetIdx)) preloadSheet(sheetIdx);

        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.ctx = null;
      this.label = null; this.loadingEl = null; this.visible = false;
      this._lastTime = 0; this._lastFrameData = null;
      this._barEl = barEl;
      this._cachedContainer = 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';
      this.ctx = canvas.getContext('2d');
      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() {
      // 已缓存的容器仍在 DOM 中则复用,避免每次 mousemove 都做 DOM 查询
      if (this._cachedContainer && document.body.contains(this._cachedContainer)) {
        return this._cachedContainer;
      }
      if (document.fullscreenElement) {
        this._cachedContainer = document.fullscreenElement;
      } else if (this._barEl) {
        const player = this._barEl.closest('[class*="player" i], .mgp_player');
        this._cachedContainer = player || document.body;
      } else {
        this._cachedContainer = document.body;
      }
      return this._cachedContainer;
    }

    /** 全屏切换时调用,强制下次 show() 重新查找容器 */
    invalidateContainer() {
      this._cachedContainer = null;
    }

    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) {
      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 {
        this.ctx.clearRect(0, 0, CFG.previewWidth, CFG.previewHeight);
        this.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;
      this.loadingEl.style.display = 'none';
      try {
        this.ctx.clearRect(0, 0, CFG.previewWidth, CFG.previewHeight);
        this.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.ctx = this.label = null;
    }
  }

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

    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();
        this.ui?.invalidateContainer();
      });
    }

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

      // UI 立即显示(零延迟),复用了上面 getBoundingClientRect 的结果
      this.ui.show(e.clientX, r.top);

      // 实际 Canvas 渲染通过 rAF 节流到显示刷新率,避免重复绘制
      this._targetTime = time;
      if (!this._renderScheduled) {
        this._renderScheduled = true;
        requestAnimationFrame(() => {
          this._renderScheduled = false;
          if (this._targetTime !== null) {
            this._renderFrame(this._targetTime);
          }
        });
      }
    }

    _onMouseLeave() {
      this.ui.hide();
      this._targetTime = 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);
})();