// ==UserScript== // @name B站快捷变速+视频帧捕获 // @namespace https://tampermonkey.net/ // @version 3.0.0 // @description 数字键 1~5 切换倍速;在播放器控制栏加入"复制帧/下载帧"按钮,不再接管右键菜单 // @author You // @match https://www.bilibili.com/video/* // @match https://www.bilibili.com/bangumi/play/* // @match https://www.bilibili.com/list/* // @grant none // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; /* ========================= 配置 ========================= */ // 数字键 -> 播放速率 const RATE_MAP = { '1': 1.0, '2': 1.25, '3': 1.5, '4': 1.8, '5': 2.0 }; // 按钮要插入的位置:播放器控制栏右侧区域(B 站改版后如失效,改这里) const BAR_SELECTOR = '.bpx-player-control-bottom-right'; /* ========================= 公共工具 ========================= */ // 缓存 video;仅在元素失效(播放器重建)时才重新查询 DOM let cachedVideo = null; function getVideo() { if (cachedVideo && cachedVideo.isConnected) return cachedVideo; cachedVideo = document.querySelector('.bpx-player-video-wrap video, .bpx-player-video-wrap bwp-video') || document.querySelector('video, bwp-video'); return cachedVideo; } // 是否正在输入文字(评论、弹幕、搜索框) function isTyping() { const el = document.activeElement; if (!el) return false; const tag = el.tagName; return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable; } // 样式:第一次真正用到时才注入 let styleInjected = false; function ensureStyle() { if (styleInjected) return; styleInjected = true; const style = document.createElement('style'); style.textContent = ` .vfc-wrap { display: inline-flex; align-items: center; height: 100%; } .vfc-btn { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 22px; margin: 0 2px; color: #fff; opacity: .85; cursor: pointer; user-select: none; } .vfc-btn:hover { opacity: 1; } .vfc-btn:active { transform: scale(.9); } .vfc-btn svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } .vfc-toast { position: fixed; top: 20%; left: 50%; transform: translateX(-50%); background: rgba(15,23,42,.85); color: #fff; padding: 8px 18px; border-radius: 8px; font: bold 16px system-ui, sans-serif; z-index: 2147483647; pointer-events: none; opacity: 0; transition: opacity .2s ease; } `; document.head.appendChild(style); } /* ========================= Toast 提示(单例,按需创建) ========================= */ let toastNode = null; let toastTimer = 0; function toast(text, duration = 1000) { ensureStyle(); if (!toastNode) { toastNode = document.createElement('div'); toastNode.className = 'vfc-toast'; } // 真·全屏时 body 上的元素不可见,所以挂到全屏元素里 const host = document.fullscreenElement || document.body; if (toastNode.parentNode !== host) host.appendChild(toastNode); toastNode.textContent = text; toastNode.style.opacity = '1'; clearTimeout(toastTimer); toastTimer = setTimeout(() => { toastNode.style.opacity = '0'; }, duration); } /* ========================= 功能一:快捷键变速 ========================= */ window.addEventListener('keydown', (e) => { // 先做最便宜的判断:不是 1~5 立刻返回 const rate = RATE_MAP[e.key]; if (rate === undefined) return; if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return; if (isTyping()) return; const video = getVideo(); if (!video) return; video.playbackRate = rate; toast(`播放速度: ${rate}x`); }, true); /* ========================= 功能二:帧捕获 ========================= */ // 截图:Canvas 用完即释放 async function capture(act) { const video = getVideo(); if (!video) return; const w = video.videoWidth, h = video.videoHeight; if (!w || !h) { toast('视频尚未就绪'); return; } const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d', { alpha: false }); try { ctx.drawImage(video, 0, 0, w, h); const blob = await new Promise((res) => canvas.toBlob(res, 'image/png')); if (!blob) throw new Error('toBlob failed'); if (act === 'copy') { await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); toast('已复制当前帧'); } else { download(blob, video); toast('已开始下载'); } } catch (err) { console.error('[视频帧捕获]', err); toast('捕获失败(详见控制台)', 1800); } finally { canvas.width = canvas.height = 0; // 立刻释放像素缓冲 } } function download(blob, video) { const t = Math.floor(video.currentTime || 0); const stamp = `${String(Math.floor(t / 60)).padStart(2, '0')}m${String(t % 60).padStart(2, '0')}s`; const title = document.title.replace(/_哔哩哔哩.*$/, '').replace(/[\\/:*?"<>|]/g, '_').slice(0, 60); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${title}_${stamp}.png`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 10000); } /* ========================= 按钮注入 ========================= */ const ICON_COPY = ''; const ICON_SAVE = ''; let wrap = null; // 按钮容器:只创建一次,播放器重建时复用 let retryTimer = 0; function buildWrap() { ensureStyle(); wrap = document.createElement('div'); wrap.className = 'vfc-wrap'; wrap.innerHTML = `