// ==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 // @downloadURL none // ==/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(); })();