Twitch 截图助手

Twitch截图工具,支援截图按钮、快捷键截图、连拍功能,自定义快捷键、连拍间隔设定、中英菜单切换

  1. // ==UserScript==
  2. // @name Twitch Screenshot Helper
  3. // @name:zh-TW Twitch 截圖助手
  4. // @name:zh-CN Twitch 截图助手
  5. // @namespace http://tampermonkey.net/
  6. // @version 2.8
  7. // @description Twitch screen capture tool with support for hotkeys, burst mode, customizable shortcuts, capture interval, and English/Chinese menu switching.
  8. // @description:zh-TW Twitch截圖工具,支援截圖按鈕、快捷鍵截圖、連拍功能,自定義快捷鍵、連拍間隔設定、中英菜單切換
  9. // @description:zh-CN Twitch截图工具,支援截图按钮、快捷键截图、连拍功能,自定义快捷键、连拍间隔设定、中英菜单切换
  10. // @author chatgpt
  11. // @match https://www.twitch.tv/*
  12. // @grant GM_registerMenuCommand
  13. // @grant GM_getValue
  14. // @grant GM_setValue
  15. // @license MIT
  16. // @run-at document-idle
  17. // ==/UserScript==
  18.  
  19. (function () {
  20. 'use strict';
  21.  
  22. // 取得語言、快捷鍵、連拍間隔等設定
  23. const lang = GM_getValue("lang", "en").toLowerCase(); // 語言(en/zh-tw)
  24. const screenshotKey = GM_getValue("screenshotKey", "s"); // 快捷鍵
  25. const intervalTime = parseInt(GM_getValue("shootInterval", "1000"), 10); // 連拍間隔(ms)
  26. let shootTimer = null; // 連拍定時器
  27. let debounceTimeout = null; // 防抖用於按鈕插入
  28.  
  29. // 多語系文字
  30. const textMap = {
  31. en: {
  32. btnTooltip: `Screenshot (Shortcut: ${screenshotKey.toUpperCase()})`,
  33. setKey: `Set Screenshot Key (Current: ${screenshotKey.toUpperCase()})`,
  34. setInterval: `Set Interval (Current: ${intervalTime}ms)`,
  35. langSwitch: `language EN`,
  36. keyError: `Please enter a single letter (A-Z).`,
  37. intervalError: `Please enter a number >= 100`,
  38. },
  39. "zh-tw": {
  40. btnTooltip: `擷取畫面(快捷鍵:${screenshotKey.toUpperCase()})`,
  41. setKey: `設定快捷鍵(目前為 ${screenshotKey.toUpperCase()})`,
  42. setInterval: `設定連拍間隔(目前為 ${intervalTime} 毫秒)`,
  43. langSwitch: `語言 中文`,
  44. keyError: `請輸入單一英文字母(A-Z)!`,
  45. intervalError: `請輸入 100ms 以上的數字!`,
  46. }
  47. };
  48. const text = textMap[lang] || textMap["en"];
  49.  
  50. // 取得目前直播主ID(網址路徑第一段)
  51. function getStreamerId() {
  52. const match = window.location.pathname.match(/^\/([^\/?#]+)/);
  53. return match ? match[1] : "unknown";
  54. }
  55.  
  56. // 取得當前時間字串(小時_分鐘_秒_毫秒,檔名用)
  57. function getTimeString() {
  58. const now = new Date();
  59. const pad = n => n.toString().padStart(2, '0');
  60. const padMs = n => n.toString().padStart(3, '0');
  61. return `${pad(now.getHours())}_${pad(now.getMinutes())}_${pad(now.getSeconds())}_${padMs(now.getMilliseconds())}`;
  62. }
  63.  
  64. // 取得年月日字串(檔名用)
  65. function getDateString() {
  66. const now = new Date();
  67. const pad = n => n.toString().padStart(2, '0');
  68. return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
  69. }
  70.  
  71. // 擷取畫面主函式
  72. function takeScreenshot() {
  73. const video = document.querySelector('video');
  74. if (!video || video.readyState < 2) return; // 沒有影片或影片未載入完成
  75. const canvas = document.createElement("canvas");
  76. canvas.width = video.videoWidth;
  77. canvas.height = video.videoHeight;
  78. const ctx = canvas.getContext("2d");
  79. ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
  80. canvas.toBlob(blob => {
  81. if (!blob) return;
  82. const a = document.createElement("a");
  83. // 檔名格式:ID_年月日_小時_分鐘_秒_毫秒_解析度.png
  84. a.download = `${getStreamerId()}_${getDateString()}_${getTimeString()}_${canvas.width}x${canvas.height}.png`;
  85. a.href = URL.createObjectURL(blob);
  86. a.style.display = "none";
  87. document.body.appendChild(a);
  88. a.click();
  89. setTimeout(() => {
  90. document.body.removeChild(a);
  91. URL.revokeObjectURL(a.href);
  92. }, 100);
  93. }, "image/png");
  94. }
  95.  
  96. // 開始連拍
  97. function startContinuousShot() {
  98. if (shootTimer) return;
  99. takeScreenshot();
  100. shootTimer = setInterval(takeScreenshot, intervalTime);
  101. }
  102.  
  103. // 停止連拍
  104. function stopContinuousShot() {
  105. clearInterval(shootTimer);
  106. shootTimer = null;
  107. }
  108.  
  109. // 插入截圖按鈕到 Twitch 控制列
  110. function createIntegratedButton() {
  111. if (document.querySelector("#screenshot-btn")) return; // 已存在就不重複插入
  112. // 嘗試多個常見控制列選擇器
  113. const controls = document.querySelector('.player-controls__right-control-group') ||
  114. document.querySelector('[data-a-target="player-controls-right-group"]');
  115. if (!controls) {
  116. // 控制列還沒出現,稍後重試
  117. setTimeout(createIntegratedButton, 1000);
  118. return;
  119. }
  120. // 建立按鈕
  121. const btn = document.createElement("button");
  122. btn.id = "screenshot-btn";
  123. btn.innerHTML = "📸";
  124. btn.title = text.btnTooltip;
  125. Object.assign(btn.style, {
  126. background: 'transparent',
  127. border: 'none',
  128. color: 'white',
  129. fontSize: '20px',
  130. cursor: 'pointer',
  131. marginLeft: '8px',
  132. display: 'flex',
  133. alignItems: 'center',
  134. order: 9999,
  135. zIndex: '2147483647'
  136. });
  137. // 綁定滑鼠事件(支援連拍)
  138. btn.addEventListener('mousedown', startContinuousShot, { capture: true });
  139. btn.addEventListener('mouseup', stopContinuousShot, { capture: true });
  140. btn.addEventListener('mouseleave', stopContinuousShot, { capture: true });
  141. // 插入到控制列最右側
  142. try {
  143. const referenceNode = controls.querySelector('[data-a-target="player-settings-button"]');
  144. if (referenceNode) {
  145. controls.insertBefore(btn, referenceNode);
  146. } else {
  147. controls.appendChild(btn);
  148. }
  149. } catch (e) {
  150. controls.appendChild(btn);
  151. }
  152. }
  153.  
  154. // 防抖:避免頻繁重複插入按鈕
  155. function createIntegratedButtonDebounced() {
  156. if (debounceTimeout) clearTimeout(debounceTimeout);
  157. debounceTimeout = setTimeout(createIntegratedButton, 500);
  158. }
  159.  
  160. // 初始化主流程
  161. function init() {
  162. createIntegratedButton();
  163. // 監控整個 body,偵測 DOM 變動時自動補回按鈕
  164. const observer = new MutationObserver(createIntegratedButtonDebounced);
  165. observer.observe(document.body, { childList: true, subtree: true });
  166. // 每5秒定時檢查按鈕是否存在
  167. setInterval(() => {
  168. if (!document.querySelector("#screenshot-btn")) {
  169. createIntegratedButton();
  170. }
  171. }, 5000);
  172. }
  173.  
  174. // 判斷目前是否在輸入框內輸入
  175. function isTyping() {
  176. const active = document.activeElement;
  177. return active && ['INPUT', 'TEXTAREA'].includes(active.tagName) || active.isContentEditable;
  178. }
  179.  
  180. // 快捷鍵事件:支援單鍵連拍
  181. document.addEventListener("keydown", e => {
  182. if (
  183. e.key.toLowerCase() === screenshotKey.toLowerCase() &&
  184. !shootTimer &&
  185. !isTyping() &&
  186. !e.repeat
  187. ) {
  188. e.preventDefault();
  189. startContinuousShot();
  190. }
  191. });
  192.  
  193. document.addEventListener("keyup", e => {
  194. if (
  195. e.key.toLowerCase() === screenshotKey.toLowerCase() &&
  196. !isTyping()
  197. ) {
  198. e.preventDefault();
  199. stopContinuousShot();
  200. }
  201. });
  202.  
  203. // 註冊油猴右鍵選單:自訂快捷鍵
  204. GM_registerMenuCommand(text.setKey, () => {
  205. const input = prompt(
  206. lang === "en"
  207. ? "Enter new shortcut key (A-Z)"
  208. : "請輸入新的快捷鍵(A-Z)",
  209. screenshotKey
  210. );
  211. if (input && /^[a-zA-Z]$/.test(input)) {
  212. GM_setValue("screenshotKey", input.toLowerCase());
  213. location.reload();
  214. } else {
  215. alert(text.keyError);
  216. }
  217. });
  218.  
  219. // 註冊油猴右鍵選單:自訂連拍間隔
  220. GM_registerMenuCommand(text.setInterval, () => {
  221. const input = prompt(
  222. lang === "en"
  223. ? "Enter interval in milliseconds (min: 100)"
  224. : "請輸入新的連拍間隔(最小100毫秒)",
  225. intervalTime
  226. );
  227. const val = parseInt(input, 10);
  228. if (!isNaN(val) && val >= 100) {
  229. GM_setValue("shootInterval", val);
  230. location.reload();
  231. } else {
  232. alert(text.intervalError);
  233. }
  234. });
  235.  
  236. // 註冊油猴右鍵選單:語言切換
  237. GM_registerMenuCommand(text.langSwitch, () => {
  238. GM_setValue("lang", lang === "en" ? "zh-tw" : "en");
  239. location.reload();
  240. });
  241.  
  242. // ========== 劇院模式快捷鍵切換 (` / ~ 鍵) ==========
  243. document.addEventListener('keydown', (event) => {
  244. const active = document.activeElement;
  245. const isTyping = active && (
  246. active.tagName === 'INPUT' ||
  247. active.tagName === 'TEXTAREA' ||
  248. active.isContentEditable
  249. );
  250. if (isTyping) return;
  251.  
  252. if (event.key === '`') {
  253. // 使用 aria-label 含「劇院模式」的按鈕(中英文皆可)
  254. const buttons = Array.from(document.querySelectorAll('button[aria-label]'));
  255. const theaterButton = buttons.find(btn =>
  256. /劇院模式|Theatre Mode/i.test(btn.getAttribute('aria-label'))
  257. );
  258.  
  259. if (theaterButton) {
  260. theaterButton.click();
  261. } else {
  262. console.warn('找不到劇院模式切換按鈕');
  263. }
  264. }
  265. });
  266.  
  267. // 啟動腳本
  268. init();
  269. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址