Automatically lurk on Twitch channels you follow
// ==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 r###lt = { container: container, frame: elem, timeout: 0, wait: 0, theatre: false }; setFrameSize(r###lt); 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(r###lt); return r###lt; } 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);