// ==UserScript==
// @name Bilibili Video Screenshot Helper & VXBilibili Link Copy Button
// @name:zh-TW Bilibili 影片截圖助手 & VXBilibili鏈結複製按鈕
// @name:zh-CN Bilibili 视频截图助手 & VXBilibili链结复制按钮
// @namespace https://www.tampermonkey.net/
// @version 2.8
// @description Bilibili video screenshot tool supporting screenshot buttons, shortcut key screenshots, burst shooting, customizable shortcut keys, burst interval settings, one-click fullscreen toggle, VX link copy button, and Chinese-English menu switching.
// @description:zh-TW B站影片截圖工具,支援截圖按鈕、快捷鍵截圖、連拍功能,自定義快捷鍵、連拍間隔設定、一鍵切換全屏、VX鏈結複製按鈕、中英菜單切換
// @description:zh-CN B站视频截图工具,支援截图按钮、快捷键截图、连拍功能,自定义快捷键、连拍间隔设定、一键切换全屏、VX链结复制按钮、中英菜单切换
// @author ChatGPT
// @match https://www.bilibili.com/*
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ====== 預設設定 ======
const DEFAULTS = {
key: 'S',
interval: 1000,
minInterval: 100,
lang: 'EN',
lockKey: 'bili_screenshot_prompt_lock'
};
// ====== 語言包 ======
const LANGS = {
EN: {
screenshot: 'Screenshot',
keySetting: key => `Set Screenshot Key (Current: ${key})`,
intervalSetting: val => `Set Burst Interval (Current: ${val}ms)`,
langSwitch: 'Switch to 中文',
keyPrompt: 'Enter new key (A-Z)',
intervalPrompt: 'Enter new interval in ms (>= 100)'
},
ZH: {
screenshot: '截圖',
keySetting: key => `設定截圖快捷鍵(目前:${key})`,
intervalSetting: val => `設定連拍間隔(目前:${val} 毫秒)`,
langSwitch: '切換到 English',
keyPrompt: '輸入新快捷鍵(A-Z)',
intervalPrompt: '輸入新的連拍間隔(最小 100ms)'
}
};
// ====== 狀態管理 ======
let lang = GM_getValue('lang', DEFAULTS.lang);
let hotkey = GM_getValue('hotkey', DEFAULTS.key);
let interval = GM_getValue('interval', DEFAULTS.interval);
function getLangPack() {
return LANGS[lang];
}
// ====== 設定選單註冊 ======
function safePrompt(action) {
if (window.top !== window.self) return;
if (sessionStorage.getItem(DEFAULTS.lockKey) === '1') return;
sessionStorage.setItem(DEFAULTS.lockKey, '1');
try {
action();
} finally {
sessionStorage.removeItem(DEFAULTS.lockKey);
}
}
GM_registerMenuCommand(getLangPack().keySetting(hotkey), () => {
safePrompt(() => {
const input = prompt(getLangPack().keyPrompt);
if (input && /^[a-zA-Z]$/.test(input)) {
const newKey = input.toUpperCase();
if (newKey !== hotkey) {
GM_setValue('hotkey', newKey);
location.reload();
}
}
});
});
GM_registerMenuCommand(getLangPack().intervalSetting(interval), () => {
safePrompt(() => {
const input = prompt(getLangPack().intervalPrompt);
const val = parseInt(input, 10);
if (!isNaN(val) && val >= DEFAULTS.minInterval && val !== interval) {
GM_setValue('interval', val);
location.reload();
}
});
});
GM_registerMenuCommand(getLangPack().langSwitch, () => {
safePrompt(() => {
const newLang = lang === 'EN' ? 'ZH' : 'EN';
GM_setValue('lang', newLang);
location.reload();
});
});
// ====== 取得影片標題 ======
function getVideoTitle() {
// 新播放器
let title = document.querySelector('h1[data-v-1c684a5a]')?.innerText
|| document.querySelector('h1.video-title')?.innerText
|| document.querySelector('h1')?.innerText;
// 備用:<title>
if (!title) {
title = document.title.replace(/_.*$/, '').trim();
}
// 過濾非法檔名字元
if (title) {
title = title.replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, '_');
} else {
title = 'UnknownTitle';
}
return title;
}
// ====== 截圖邏輯 ======
function takeScreenshot() {
const video = document.querySelector('video');
const match = location.pathname.match(/\/video\/(BV\w+)/);
if (!match || !video || video.paused) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
const pad = (n, len = 2) => n.toString().padStart(len, '0');
const padMs = n => pad(n, 3);
const bvId = match[1];
const t = video.currentTime;
const h = pad(Math.floor(t / 3600));
const m = pad(Math.floor((t % 3600) / 60));
const s = pad(Math.floor(t % 60));
const ms = padMs(Math.floor((t * 1000) % 1000));
const res = `${canvas.width}x${canvas.height}`;
const title = getVideoTitle();
// 命名規則:影片標題_小時_分鐘_秒_毫秒_BV號_解析度
const filename = `${title}_${h}_${m}_${s}_${ms}_${bvId}_${res}.png`;
canvas.toBlob(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 100);
}, 'image/png');
}
// ====== 新增:簡易通知函式 ======
function showNotification(msg) {
const notif = document.createElement('div');
notif.textContent = msg;
Object.assign(notif.style, {
position: 'fixed',
bottom: '20px',
right: '20px',
backgroundColor: 'rgba(0,0,0,0.75)',
color: 'white',
padding: '8px 12px',
borderRadius: '4px',
fontSize: '14px',
zIndex: 9999,
opacity: 0,
transition: 'opacity 0.3s ease'
});
document.body.appendChild(notif);
// 觸發 CSS 過渡效果
requestAnimationFrame(() => {
notif.style.opacity = 1;
});
setTimeout(() => {
notif.style.opacity = 0;
setTimeout(() => document.body.removeChild(notif), 300);
}, 1500);
}
// ====== 插入截圖與複製網址按鈕 ======
function insertScreenshotButton() {
const qualityBtn = document.querySelector('.bpx-player-ctrl-quality');
if (!qualityBtn || document.querySelector('.bili-screenshot-btn')) return;
// 插入複製網址按鈕(放在截圖按鈕左邊)
const copyBtn = document.createElement('div');
copyBtn.className = 'bpx-player-ctrl-btn bili-copylink-btn';
Object.assign(copyBtn.style, {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: '18px',
marginRight: '6px'
});
copyBtn.title = 'Copy Modified URL';
copyBtn.innerHTML = '🔗';
copyBtn.addEventListener('click', () => {
let url = location.href;
url = url.replace(/^https:\/\/(www\.)?bilibili\.com/, 'https://www.vxbilibili.com');
url = url.replace(/^https:\/\/(www\.)?b23\.tv/, 'https://vxb23.tv');
navigator.clipboard.writeText(url).then(() => {
showNotification('✅ URL copied!');
console.log('✅ URL copied:', url);
}).catch(err => {
showNotification('❌ Copy failed!');
console.error('❌ Copy failed:', err);
});
});
qualityBtn.parentNode.insertBefore(copyBtn, qualityBtn);
// 插入截圖按鈕
const btn = document.createElement('div');
btn.className = 'bpx-player-ctrl-btn bili-screenshot-btn';
Object.assign(btn.style, {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: '18px',
marginRight: '6px'
});
btn.title = getLangPack().screenshot;
btn.innerHTML = '📸';
btn.addEventListener('click', takeScreenshot);
qualityBtn.parentNode.insertBefore(btn, qualityBtn);
}
// ====== 監聽 DOM 變動(SPA 支援) ======
const observer = new MutationObserver(() => {
insertScreenshotButton();
// 動態更新截圖按鈕 title
const btn = document.querySelector('.bili-screenshot-btn');
if (btn) btn.title = getLangPack().screenshot;
});
observer.observe(document.body, { childList: true, subtree: true });
// ====== 快捷鍵與連拍功能 ======
let holdTimer = null;
document.addEventListener('keydown', e => {
if (e.repeat) return;
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName) || e.target.isContentEditable) return;
hotkey = GM_getValue('hotkey', DEFAULTS.key);
interval = GM_getValue('interval', DEFAULTS.interval);
if (e.key.toUpperCase() === hotkey && !holdTimer) {
takeScreenshot();
holdTimer = setInterval(takeScreenshot, interval);
}
});
document.addEventListener('keyup', e => {
hotkey = GM_getValue('hotkey', DEFAULTS.key);
if (e.key.toUpperCase() === hotkey && holdTimer) {
clearInterval(holdTimer);
holdTimer = null;
}
});
// ====== 快捷鍵:切換網頁全螢幕 ======
document.addEventListener('keydown', e => {
if (e.key === '`' && !e.repeat) {
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName) || e.target.isContentEditable) return;
const webFullBtn = document.querySelector('.bpx-player-ctrl-web');
if (webFullBtn) webFullBtn.click();
}
});
// ====== SPA 路徑變化偵測(不 reload,只更新語言包) ======
let lastPath = location.pathname;
function onPathChange() {
if (location.pathname !== lastPath) {
lastPath = location.pathname;
setTimeout(() => {
const btn = document.querySelector('.bili-screenshot-btn');
if (btn) btn.title = getLangPack().screenshot;
}, 500);
}
}
(function(history){
const pushState = history.pushState;
const replaceState = history.replaceState;
history.pushState = function() {
pushState.apply(this, arguments);
setTimeout(onPathChange, 100);
};
history.replaceState = function() {
replaceState.apply(this, arguments);
setTimeout(onPathChange, 100);
};
})(window.history);
window.addEventListener('popstate', () => setTimeout(onPathChange, 100));
// ====== 初始化 ======
insertScreenshotButton();
})();