Pornhub 视频进度条悬停缩略图预览
// ==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);
})();