Records live Twitch streams directly from the browser
// ==UserScript==
// @name Twitch - Recorder
// @namespace https://greasyfork.org/ja/users/941284-ぐらんぴ
// @version 2025-10-05
// @description Records live Twitch streams directly from the browser
// @author ぐらんぴ
// @match https://www.twitch.tv/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=twitch.tv
// @grant none
// @run-at document-start
// @license MIT
// ==/UserScript==
let $s = (el) => document.querySelector(el), $sa = (el) => document.querySelectorAll(el), $c = (el) => document.createElement(el)
let recorder, chunks = [], isRecording = false, seconds = 0, timerInterval, log = console.log;
function supportsFileSystemAccess() {
return ('showSaveFilePicker' in window) || ('chooseFileSystemEntries' in window);
}
const origAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function(type, listener, options) {
if(type === "loadstart"){
const recordWrapper = function(e){
if(location.href == "https://www.twitch.tv/") return;
record();
};
origAddEventListener.call(this, type, recordWrapper, options);
}
return origAddEventListener.call(this, type, listener, options);
};
function record(){
let awaitAddon = setInterval(() => {
let addon = $s(".player-controls__right-control-group")
clearInterval(awaitAddon);
let btn = $c('button');
btn.textContent = ` [RECORD]`;
btn.className = "GRMP";
btn.style.color = "red";
btn.style.cursor = "pointer";
let fileWritable = null;
let fileHandle = null;
let writerLock = null;
let usingFileSystem = false;
async function prepareFileSystem() {
try {
if ('showSaveFilePicker' in window) {
fileHandle = await window.showSaveFilePicker({
suggestedName: getSuggestedFilename(),
types: [{ description: 'WebM', accept: { 'video/webm': ['.webm'] } }]
});
fileWritable = await fileHandle.createWritable();
usingFileSystem = true;
} else if ('chooseFileSystemEntries' in window) {
// older spec fallback (Chrome M89以前の実装)
fileHandle = await window.chooseFileSystemEntries({ type: 'save-file', accepts: [{ description: 'WebM', extensions: ['webm'], mimeTypes: ['video/webm'] }] });
fileWritable = await fileHandle.createWriter();
usingFileSystem = true;
} else {
usingFileSystem = false;
}
} catch (err) {
console.warn("File picker cancelled or failed:", err);
usingFileSystem = false;
}
}
function getSuggestedFilename() {
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
let name = location.pathname.slice(1) || 'record';
try {
let title = $s('[data-a-target="stream-title"]').textContent || 'stream';
if (location.pathname.startsWith('/videos/')) {
let videoId = location.pathname.replace('/videos/', '');
return ($s('h1.tw-title')?.textContent || name) + "_" + title + "_" + videoId + ".webm";
} else {
return name + "_" + title + "_" + month + "-" + day + ".webm";
}
} catch (e) {
return name + "_" + month + "-" + day + ".webm";
}
}
btn.addEventListener("click", async () => {
const video = $s("video");
if(!video){
alert("Video element not found.");
return;
}
if(video.paused || video.readyState < 3){
video.play().catch(err => console.warn("Video play failed:", err));
}
if (!isRecording) {
try {
let stream;
let recorderStream;
const isFirefox = navigator.userAgent.indexOf('Firefox') > -1;
const isChromium = !isFirefox && /Chrome|Chromium|Edg|OPR/.test(navigator.userAgent);
if (isChromium && supportsFileSystemAccess()) {
await prepareFileSystem();
}
if (isFirefox) {
const audioCtx = new AudioContext();
const sourceNode = audioCtx.createMediaElementSource(video);
const destinationNode = audioCtx.createMediaStreamDestination();
sourceNode.connect(audioCtx.destination);
sourceNode.connect(destinationNode);
stream = video.mozCaptureStream();
recorderStream = new MediaStream([
...stream.getVideoTracks(),
...destinationNode.stream.getAudioTracks()
]);
} else {
recorderStream = video.captureStream();
}
if (!recorderStream) {
alert("Failed to capture stream");
return;
}
const mime = 'video/webm;codecs=vp9,opus';
const options = MediaRecorder.isTypeSupported(mime) ? { mimeType: mime, bitsPerSecond: 2500000 } : undefined;
recorder = new MediaRecorder(recorderStream, options);
const timeslice = 1000;
if (usingFileSystem && fileWritable) {
recorder.ondataavailable = async (e) => {
if (!e.data || e.data.size === 0) return;
try {
const ab = await e.data.arrayBuffer();
await fileWritable.write(new Uint8Array(ab));
} catch (err) {
console.error('Write chunk failed:', err);
}
};
recorder.onstop = async () => {
clearInterval(timerInterval);
btn.textContent = ` [RECORD]`;
try {
await fileWritable.close();
} catch (err) {
try { if (fileWritable && fileWritable.close) await fileWritable.close(); } catch (e) {}
}
isRecording = false;
};
} else {
chunks = [];
recorder.ondataavailable = e => {
if (e.data && e.data.size > 0) chunks.push(e.data);
};
recorder.onstop = () => {
clearInterval(timerInterval);
btn.textContent = ` [RECORD]`;
const blob = new Blob(chunks, { type: 'video/webm' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = getSuggestedFilename();
a.click();
chunks = [];
setTimeout(() => URL.revokeObjectURL(url), 10000);
isRecording = false;
};
}
recorder.start(timeslice);
isRecording = true;
seconds = 0;
btn.textContent = formatTime(seconds);
timerInterval = setInterval(() => {
seconds++;
btn.textContent = formatTime(seconds);
}, 1000);
} catch (e) {
alert("Recording failed: " + e);
console.error(e);
}
} else {
try {
recorder.stop();
} catch (e) {
console.warn("recorder.stop() failed:", e);
}
clearInterval(timerInterval);
btn.textContent = ` [RECORD]`;
}
});
if(!$s('.GRMP') && addon) addon.appendChild(btn);
function formatTime(sec){
const m = String(Math.floor(sec / 60)).padStart(2, '0');
const s = String(sec % 60).padStart(2, '0');
return ` [${m}:${s}]`;
}
}, 500);
}