// ==UserScript==
// @name Twitching Lurkist
// @description Automatically lurk on Twitch channels you follow
// @author Xspeed
// @namespace xspeed.net
// @license MIT
// @version 14
// @icon https://static.twitchcdn.net/assets/favicon-32-e29e246c157142c94346.png
// @match *://www.twitch.tv/*
// @grant GM.getValue
// @grant GM.setValue
// @run-at document-start
// @noframes
// ==/UserScript==
const activationPath = '/directory/following/autolurk';
const spacing = '5px';
let intervalJob = -1;
let refreshJob = -1;
let fetchOpts = { init: false };
let streamFrames = [];
const prefs = { autoCinema: true, autoClose: false, autoRefresh: true,
detectPause: false, frameScale: 0.75, lowLatDisable: true, whitelist: [] };
function log(txt) {
console.log('[' + GM.info.script.name + '] ' + txt);
}
function clearChildren(parent) {
while (parent.firstChild) {
parent.removeChild(parent.lastChild);
}
}
function startCinemaJob(streamFrame) {
if (!prefs.autoCinema || streamFrame.theatre) return;
const jobId = setInterval(() => {
if (!prefs.autoCinema || streamFrame.theatre) {
clearInterval(jobId);
}
const btn = streamFrame.frame.contentDocument?.querySelector('button[aria-label*="Theatre Mode"]');
if (btn) {
streamFrame.theatre = true;
btn.click();
clearInterval(jobId);
}
}, 1000);
}
function onFrameLoaded(e) {
const obj = streamFrames.find(x => x.frame == e.target);
['pushState', 'replaceState'].forEach((changeState) => {
e.target.contentWindow.history[changeState] = new Proxy(e.target.contentWindow.history[changeState], {
apply(target, thisArg, argList) {
const [state, title, url] = argList;
log(changeState + ' to ' + url);
startCinemaJob(obj);
return target.apply(thisArg, argList);
},
});
});
}
function setFrameSize(item) {
item.frame.style.transform = 'scale(' + prefs.frameScale + ')';
item.container.style.width = Math.round(1000 * prefs.frameScale) + 'px';
item.container.style.height = Math.round(480 * prefs.frameScale) + 'px';
}
function setupFrame(url) {
log('Setting up new frame for ' + url);
const elem = document.createElement('iframe');
elem.src = url;
elem.style.width = '1000px';
elem.style.height = '480px';
elem.style.gridColumnStart = '1';
elem.style.gridRowStart = '1';
elem.style.transformOrigin = '0 0';
elem.addEventListener('load', onFrameLoaded);
const container = document.createElement('div');
container.style.position = 'static';
container.style.display = 'grid';
container.style.placeItems = 'start';
const result = { container: container, frame: elem, timeout: 0, wait: 0, theatre: false };
setFrameSize(result);
const closeBtn = document.createElement('button');
closeBtn.innerText = 'Close stream';
closeBtn.style.gridColumnStart = '1';
closeBtn.style.gridRowStart = '1';
closeBtn.style.zIndex = '666';
closeBtn.addEventListener('click', function() {
container.parentElement.removeChild(container);
streamFrames = streamFrames.filter(x => x.frame != elem);
});
container.append(elem);
container.append(closeBtn);
// TODO: Restore user set quality after the video player loads
localStorage.setItem('video-muted', '{"default":false,"carousel":false}');
localStorage.setItem('video-quality', '{"default":"160p30"}');
localStorage.setItem('mature', 'true');
if (prefs.lowLatDisable) localStorage.setItem('lowLatencyModeEnabled', 'false');
document.body.append(container);
streamFrames.push(result);
return result;
}
async function detect() {
if (prefs.detectPause) return;
let items = await fetch(fetchOpts.url, fetchOpts.opts).then(res => res.json())
.then(obj => obj[0].data.currentUser.followedLiveUsers.edges.map(x => '/' + x.node.login.toLowerCase()));
let currentUrls = [];
streamFrames = streamFrames.filter(x => {
if (!x.frame.contentDocument) {
log('Frame ' + x.frame.src + ' invalid');
x.container.parentElement.removeChild(x.container);
return false;
}
let url = x.frame.contentDocument.location.pathname.toLowerCase();
if (!currentUrls.includes(url)) {
x.wait = 0;
currentUrls.push(url);
}
else if (++x.wait > 2) {
log('Frame ' + x.frame.contentDocument.location.pathname + ' duplicated');
x.container.parentElement.removeChild(x.container);
return false;
}
let chatLink = x.frame.contentDocument.querySelector('a[tabname="chat"]');
if (chatLink) {
x.theatre = false;
chatLink.click();
return true;
}
let hostLink = x.frame.contentDocument.querySelector('a[data-a-target="hosting-indicator"]');
if (!hostLink) {
hostLink = Array.from(x.frame.contentDocument.querySelectorAll('a.tw-link'))
.find(x => x.innerText.match(/Watch\s+\w+\s+with\s+\d+\s+viewers/));
}
if (hostLink) {
log('Frame ' + url + ' redirecting to ' + hostLink.href);
x.theatre = false;
hostLink.click();
return true;
}
let vidElem = x.frame.contentDocument.querySelector('video');
if (vidElem && !vidElem.paused && !vidElem.ended && vidElem.readyState > 2) {
x.timeout = 0;
}
else if (++x.timeout > 6) {
log('Frame ' + url + ' timed out');
x.container.parentElement.removeChild(x.container);
return false;
}
return true;
});
if (prefs.autoClose) {
streamFrames = streamFrames.filter(x => {
if (!items.includes(x.frame.contentDocument.location.pathname)) {
log('Frame ' + x.frame.contentDocument.location.pathname + ' auto-closing');
x.container.parentElement.removeChild(x.container);
return false;
}
return true;
});
}
if (prefs.whitelist.length) items = items.filter(x => prefs.whitelist.includes(x.substr(1)));
items.filter(x => !currentUrls.includes(x)).forEach(x => setupFrame(x));
}
function setRefresh(value) {
prefs.autoRefresh = value;
if (value) {
refreshJob = setTimeout(() => location.reload(), (4 * 3600000) + (Math.floor(Math.random() * 10000)));
}
else if (refreshJob != -1) {
clearTimeout(refreshJob);
refreshJob = -1;
}
}
function setupTab() {
if (location.pathname.startsWith(activationPath)) {
if (fetchOpts.url && !fetchOpts.init) {
log('Preparing layout and update loop');
fetchOpts.init = true;
setupControls();
setInterval(detect, 10000);
clearInterval(intervalJob);
}
return;
}
if (!location.pathname.startsWith('/directory/following')) return;
if (document.querySelector('a[href="' + activationPath + '"]')) return;
const tabs = document.body.querySelectorAll('li[role=presentation]');
if (!tabs.length) return;
log('Setting up Auto-lurk navigation tab');
const lastTab = tabs[tabs.length - 1];
const newTab = document.createElement('li');
newTab.className = lastTab.className;
newTab.innerHTML = lastTab.innerHTML;
const link = newTab.querySelector('a');
link.href = activationPath;
link.querySelector("[class^='ScTitle']").innerText = 'Auto-lurk';
lastTab.parentElement.appendChild(newTab);
}
function createWhitelist() {
const list = document.createElement('ul');
list.style.marginTop = '3px';
list.style.marginBottom = '3px';
const inp = document.createElement('input');
inp.type = 'text';
inp.style.width = '130px';
const okBtn = document.createElement('button');
okBtn.type = 'submit';
okBtn.innerText = '+';
const listLab = document.createElement('label');
listLab.append('Whitelist: ');
listLab.append(inp);
listLab.append(okBtn);
const updateList = function() {
clearChildren(list);
prefs.whitelist.forEach(x => {
const btn = document.createElement('a');
btn.href = '#';
btn.innerText = 'X';
btn.addEventListener('click', function(e) {
e.preventDefault();
prefs.whitelist.splice(prefs.whitelist.indexOf(x), 1);
updateList();
return false;
});
const elem = document.createElement('li');
elem.innerText = x + ' ';
elem.append(btn);
list.append(elem);
});
GM.setValue('whitelist', prefs.whitelist.join(','));
};
const listBox = document.createElement('form');
listBox.style.marginBottom = '3px';
listBox.append(list);
listBox.append(listLab);
listBox.addEventListener('submit', function(e) {
e.preventDefault();
const value = inp.value.trim().toLowerCase();
if (!value || prefs.whitelist.includes(value)) return;
prefs.whitelist.push(value);
prefs.whitelist.sort();
updateList();
inp.value = '';
});
updateList();
return listBox;
}
function createToggle(name, text, init, setter) {
const toggle = document.createElement('input');
toggle.type = 'checkbox';
toggle.checked = init;
toggle.style.marginLeft = 0;
toggle.style.marginRight = spacing;
toggle.addEventListener('change', function() {
setter(this.checked);
GM.setValue(name, this.checked);
});
const label = document.createElement('label');
label.style.display = 'flex';
label.style.alignItems = 'center';
label.append(toggle);
label.append(text);
return label;
}
function createScaleRange() {
const span = document.createElement('span');
span.innerText = (prefs.frameScale * 100) + '%';
const range = document.createElement('input');
range.type = 'range';
range.min = '0.1';
range.step = '0.05';
range.max = '1';
range.value = prefs.frameScale;
range.style.width = '90px';
range.style.marginLeft = spacing;
range.style.marginRight = spacing;
range.addEventListener('input', function() {
span.innerText = Math.round(this.value * 100) + '%';
});
range.addEventListener('change', function() {
prefs.frameScale = this.value;
GM.setValue('frameScale', this.value);
span.innerText = Math.round(this.value * 100) + '%';
streamFrames.forEach(x => setFrameSize(x));
});
const label = document.createElement('label');
label.style.display = 'flex';
label.style.alignItems = 'center';
label.append('Frame scale');
label.append(range);
label.append(span);
return label;
}
async function setupControls() {
prefs.autoClose = await GM.getValue('autoClose', false);
prefs.autoCinema = await GM.getValue('autoCinema', true);
prefs.lowLatDisable = await GM.getValue('lowLatDisable', true);
prefs.whitelist = (await GM.getValue('whitelist', '')).split(',').map(x => x.trim()).filter(x => x);
prefs.frameScale = await GM.getValue('frameScale', 0.75);
setRefresh(await GM.getValue('autoRefresh', true));
const listBox = createWhitelist();
const closeBtn = document.createElement('button');
closeBtn.innerText = 'Close all streams';
closeBtn.style.marginTop = spacing;
closeBtn.addEventListener('click', function() {
streamFrames.forEach(x => x.container.parentElement.removeChild(x.container));
streamFrames.length = 0;
});
const autoTgl = createToggle('autoClose', 'Auto-close raids and hosts', prefs.autoClose, x => prefs.autoClose = x);
const cinemaTgl = createToggle('autoCinema', 'Auto enable theatre mode', prefs.autoCinema, x => prefs.autoCinema = x);
const refreshTgl = createToggle('autoRefresh', 'Refresh page every 4 hours', prefs.autoRefresh, x => setRefresh(x));
const bufferTgl = createToggle('lowLatDisable', 'Disable low latency mode', prefs.lowLatDisable, x => prefs.lowLatDisable = x);
const pauseTgl = createToggle('detectPause', 'Pause streams detection', prefs.detectPause, x => prefs.detectPause = x);
const scaleRng = createScaleRange();
const panel = document.createElement('div');
panel.style.padding = spacing;
panel.style.backgroundColor = 'gray';
panel.style.position = 'fixed';
panel.style.zIndex = '1';
panel.style.right = '0px';
panel.style.bottom = '0px';
panel.style.transition = 'right 0.2s ease-in-out';
panel.append(listBox);
panel.append(autoTgl);
panel.append(cinemaTgl);
panel.append(refreshTgl);
panel.append(bufferTgl);
panel.append(pauseTgl);
panel.append(scaleRng);
panel.append(closeBtn);
const panelBtn = document.createElement('button');
panelBtn.innerText = 'Settings panel';
panelBtn.style.margin = spacing;
panelBtn.style.position = 'fixed';
panelBtn.style.zIndex = '2';
panelBtn.style.right = '0px';
panelBtn.style.bottom = '0px';
panelBtn.addEventListener('click', function() {
panel.style.right = panel.style.right == '0px' ? ('-' + (panel.offsetWidth + 1) + 'px') : '0px';
});
setTimeout(() => panelBtn.click(), 500);
document.documentElement.style.backgroundColor = 'black';
document.body.style.backgroundColor = 'black';
clearChildren(document.body);
clearChildren(document.head);
document.body.append(panel);
document.body.append(panelBtn);
}
log('Script loaded on path ' + location.pathname);
if (location.pathname.startsWith(activationPath)) {
unsafeWindow.fetch = new Proxy(unsafeWindow.fetch, {
apply(target, thisArg, argList) {
const [url, opts] = argList;
if (url.includes('gql.twitch.tv') && opts.body.includes('FollowingLive_CurrentUser') && opts._meta != GM.info.script.name) {
log('Intercepted live list request');
const opBody = JSON.parse(opts.body).find(x => x.operationName == 'FollowingLive_CurrentUser');
fetchOpts.url = url;
fetchOpts.opts = { _meta: GM.info.script.name, headers: opts.headers, method: opts.method, body: JSON.stringify([{
operationName: opBody.operationName,
variables: { limit: 50, includeIsDJ: false },
extensions: opBody.extensions }]) };
}
return target.apply(thisArg, argList);
}
});
}
intervalJob = setInterval(setupTab, 1250);