// ==UserScript==
// @name Twitch Screenshot Helper
// @name:zh-TW Twitch 截圖助手
// @name:zh-CN Twitch 截图助手
// @namespace http://tampermonkey.net/
// @version 2.0
// @description Twitch screen capture tool with support for hotkeys, burst mode, customizable shortcuts, capture interval, and English/Chinese menu switching.
// @description:zh-TW Twitch 擷取畫面工具,支援快捷鍵、連拍模式、自訂快捷鍵、連拍間隔與中英菜單切換
// @description:zh-CN Twitch 撷取画面工具,支援快捷键、连拍模式、自订快捷键、连拍间隔与中英菜单切换
// @author chatgpt
// @match https://www.twitch.tv/*
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @license MIT
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const lang = GM_getValue("lang", "EN");
const screenshotKey = GM_getValue("screenshotKey", "s");
const intervalTime = parseInt(GM_getValue("shootInterval", "1000"), 10);
let shootTimer = null;
let createButtonTimeout;
const text = {
EN: {
btnTooltip: `Screenshot (Shortcut: ${screenshotKey.toUpperCase()})`,
setKey: `Set Screenshot Key (Current: ${screenshotKey.toUpperCase()})`,
setInterval: `Set Interval (Current: ${intervalTime}ms)`,
langSwitch: `language EN`,
keySuccess: key => `New shortcut key set to: ${key.toUpperCase()}. Please refresh.`,
keyError: `Please enter a single letter (A-Z).`,
intervalSuccess: ms => `Interval updated to ${ms}ms. Please refresh.`,
intervalError: `Please enter a number >= 100`,
},
ZH: {
btnTooltip: `擷取畫面(快捷鍵:${screenshotKey.toUpperCase()})`,
setKey: `設定快捷鍵(目前為 ${screenshotKey.toUpperCase()})`,
setInterval: `設定連拍間隔(目前為 ${intervalTime} 毫秒)`,
langSwitch: `語言 中文`,
keySuccess: key => `操作成功!新快捷鍵為:${key.toUpperCase()},請重新整理頁面以使設定生效。`,
keyError: `請輸入單一英文字母(A-Z)!`,
intervalSuccess: ms => `間隔時間已更新為:${ms}ms,請重新整理頁面以使設定生效。`,
intervalError: `請輸入 100ms 以上的數字!`,
}
}[lang];
function getStreamerId() {
const match = window.location.pathname.match(/^\/([^\/?#]+)/);
return match ? match[1] : "unknown";
}
function getTimeString() {
const now = new Date();
const pad = n => n.toString().padStart(2, '0');
const ms = now.getMilliseconds().toString().padStart(3, '0');
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}_${pad(now.getMinutes())}_${pad(now.getSeconds())}_${ms}`;
}
function takeScreenshot() {
const video = document.querySelector('video');
if (!video || video.readyState < 2) return;
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext("2d");
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob(blob => {
if (!blob) return;
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${getTimeString()}_${getStreamerId()}_${canvas.width}x${canvas.height}.png`;
a.style.display = "none";
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
}, 100);
}, "image/png");
}
function startContinuousShot() {
if (shootTimer) return;
takeScreenshot();
shootTimer = setInterval(takeScreenshot, intervalTime);
}
function stopContinuousShot() {
clearInterval(shootTimer);
shootTimer = null;
}
function createIntegratedButton() {
if (document.querySelector("#screenshot-btn")) return;
const controls = document.querySelector('.player-controls__right-control-group, [data-a-target="player-controls-right-group"]');
if (!controls) {
// 未找到控制區,稍後重試
setTimeout(createIntegratedButton, 1000);
return;
}
const btn = document.createElement("button");
btn.id = "screenshot-btn";
btn.innerHTML = "📸";
btn.title = text.btnTooltip;
// 跨瀏覽器樣式設定
Object.assign(btn.style, {
background: 'transparent',
border: 'none',
color: 'white',
fontSize: '20px',
cursor: 'pointer',
marginLeft: '8px',
display: 'flex',
alignItems: 'center',
order: 9999, // 確保在 Flex 容器最右側
zIndex: '2147483647'
});
// 事件監聽:使用 capture 以確保最先接收到事件
const addEvent = (event, handler) => {
btn.addEventListener(event, handler, { capture: true });
};
addEvent('mousedown', startContinuousShot);
addEvent('mouseup', stopContinuousShot);
addEvent('mouseleave', stopContinuousShot);
// 插入到控制組最右側
try {
const referenceNode = controls.querySelector('[data-a-target="player-settings-button"]');
if (referenceNode) {
controls.insertBefore(btn, referenceNode);
} else {
controls.appendChild(btn);
}
} catch (e) {
controls.appendChild(btn);
}
// 防止按鈕被移除
const observer = new MutationObserver(() => {
if (!document.contains(btn)) {
controls.appendChild(btn);
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
// 透過 debounce 降低頻繁重複執行 createIntegratedButton 的呼叫頻率
function createIntegratedButtonDebounced() {
if (createButtonTimeout) clearTimeout(createButtonTimeout);
createButtonTimeout = setTimeout(createIntegratedButton, 500);
}
function init() {
createIntegratedButton();
const observer = new MutationObserver(createIntegratedButtonDebounced);
observer.observe(document.body, { childList: true, subtree: true });
// 若按鈕意外被移除,每隔 5 秒檢查一次
setInterval(() => {
if (!document.querySelector("#screenshot-btn")) {
createIntegratedButton();
}
}, 5000);
}
function isTyping() {
const active = document.activeElement;
return active && (
active.tagName === 'INPUT' ||
active.tagName === 'TEXTAREA' ||
active.isContentEditable
);
}
document.addEventListener("keydown", e => {
if (
e.key.toLowerCase() === screenshotKey.toLowerCase() &&
!shootTimer &&
!isTyping() &&
!e.repeat
) {
e.preventDefault();
startContinuousShot();
}
});
document.addEventListener("keyup", e => {
if (
e.key.toLowerCase() === screenshotKey.toLowerCase() &&
!isTyping()
) {
e.preventDefault();
stopContinuousShot();
}
});
GM_registerMenuCommand(text.setKey, () => {
const input = prompt(
lang === "EN"
? "Enter new shortcut key (A-Z)"
: "請輸入新的快捷鍵(A-Z)",
screenshotKey
);
if (input && /^[a-zA-Z]$/.test(input)) {
GM_setValue("screenshotKey", input.toLowerCase());
alert(text.keySuccess(input));
} else {
alert(text.keyError);
}
});
GM_registerMenuCommand(text.setInterval, () => {
const input = prompt(
lang === "EN"
? "Enter interval in milliseconds (min: 100)"
: "請輸入新的連拍間隔(最小100毫秒)",
intervalTime
);
const val = parseInt(input, 10);
if (!isNaN(val) && val >= 100) {
GM_setValue("shootInterval", val);
alert(text.intervalSuccess(val));
} else {
alert(text.intervalError);
}
});
GM_registerMenuCommand(text.langSwitch, () => {
GM_setValue("lang", lang === "EN" ? "ZH" : "EN");
location.reload();
});
init();
})();