// ==UserScript== // @name Twitch Pinned Streamers - twitch.tv // @description Pin Twitch streamers on sidebar without being logged in. // @namespace https://github.com/vekvoid/UserScripts // @homepageURL https://github.com/vekvoid/UserScripts/ // @supportURL https://github.com/vekvoid/UserScripts/issues // @match *://*.twitch.tv/* // @grant none // @icon https://www.google.com/s2/favicons?domain=twitch.com // @version 1.1.1 // @downloadURL none // ==/UserScript== const logLevels = { trace: 10, debug: 20, info: 30, warn: 40, error: 50, fatal: 60, }; const NAME = 'Twitch Pinned Streamers'; const CURRENT_LOG_LEVEL = logLevels.info; const DETECT_PAGE_CHANGE_INTERVAL = 1000; const PINNED_REFRESH_DELAY_DAYS = 1; const REFRESH_DISPLAYED_DATA_DELAY_MINUTES = 5; const ALL_RELEVANT_CONTENT_SELECTOR = '.dShujj'; const HEADER_CLONE_SELECTOR = ".side-nav-header[data-a-target='side-nav-header-expanded']"; const BTN_CLONE_SELECTOR = ".side-nav.side-nav--expanded[data-a-target='side-nav-bar']"; const BTN_INNER_CLONE_SELECTOR = ".simplebar-content button[data-a-target='side-nav-arrow']"; const TWITCH_GRAPHQL = 'https://gql.twitch.tv/gql'; const CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; // From Alternate Player for Twitch.tv const logger = { /* eslint-disable no-console */ trace: (...args) => (logLevels.trace >= CURRENT_LOG_LEVEL) && console.trace(`${NAME}:`, ...args), debug: (...args) => (logLevels.debug >= CURRENT_LOG_LEVEL) && console.log(`${NAME}:`, ...args), info: (...args) => (logLevels.info >= CURRENT_LOG_LEVEL) && console.info(`${NAME}:`, ...args), warn: (...args) => (logLevels.warn >= CURRENT_LOG_LEVEL) && console.warn(`${NAME}:`, ...args), error: (...args) => (logLevels.error >= CURRENT_LOG_LEVEL) && console.error(`${NAME}:`, ...args), fatal: (...args) => (logLevels.fatal >= CURRENT_LOG_LEVEL) && console.fatal(`${NAME}:`, ...args), /* eslint-enable no-console */ }; const css = ` .tps-pinned-container { min-height: 0; overflow: hidden; transition: all 250ms ease 0ms; } .tps-pinned-container div .tps-remove-pinned-streamer { opacity: 0; } .tps-pinned-container div :hover .tps-remove-pinned-streamer { opacity: 0.3; } .tps-remove-pinned-streamer { transition: all 150ms ease 0ms; opacity: 0.3; } .tps-remove-pinned-streamer:hover { opacity: 1 !important; } `; let currentPage = "window.top.location.href"; let previousPage = ''; let isWorking = false; let isTabFocused = false; let waitForMainContainer; const main = () => { let relevantContent; if (waitForMainContainer) { clearInterval(waitForMainContainer); } waitForMainContainer = setInterval(async () => { window.addEventListener('focus', function() { logger.debug('Focused tab'); isTabFocused = true; }); window.addEventListener('blur', function() { logger.debug('Tab lost focus'); isTabFocused = false; }); relevantContent = document.querySelector(ALL_RELEVANT_CONTENT_SELECTOR); if (!relevantContent) { return; } if (relevantContent.childElementCount < 2) { return; } if (!relevantContent.querySelector(HEADER_CLONE_SELECTOR)) { return; } if (!relevantContent.querySelector(`${BTN_CLONE_SELECTOR} ${BTN_INNER_CLONE_SELECTOR}`)) { return; } clearInterval(waitForMainContainer); logger.debug('Main content found.'); // Refresh localStorage pinned data to get new posible avatar changes. const lastRefreshedAt = localStorageGetPinnedRefresheddAt(); if (requireDataRefresh(lastRefreshedAt)) { logger.debug("Refreshing pinned streamers."); try { await refreshPinnedData(); } catch (error) { logger.warn(`Could not refresh pinned streamers. ${error?.message}`); } } injectCSS(); const observer = new MutationObserver(async () => { if (isWorking) { return; } isWorking = true; if (document.getElementById('anon-followed')) { return; } const sidebar = relevantContent.querySelector('.side-nav.side-nav--expanded'); logger.debug(sidebar); if (!sidebar) { return; } // '.simplebar-content .side-bar-contents nav div > div > div' const sidebarContent = sidebar.querySelector( '#side-nav div > div > div', ); const anonFollowedElement = document.createElement('div'); anonFollowedElement.id = 'anon-followed'; anonFollowedElement.innerHTML += pinnedHeader(); anonFollowedElement.innerHTML += '
'; sidebarContent.insertBefore(anonFollowedElement, sidebarContent.childNodes[0]); await renderPinnedStreamers(); setInterval(async () => { if (!isTabFocused) { return; } await renderPinnedStreamers(); logger.info("Refreshed pinned streamers displayed data"); }, REFRESH_DISPLAYED_DATA_DELAY_MINUTES*60*1000); document.getElementById('tps-add-streamer').onclick = addStreamer; const mainSection = relevantContent.querySelector('main'); logger.debug(sidebar, mainSection); isWorking = false; observer.disconnect(); }); observer.observe(document.body, { childList: true, subtree: true }); }, 500); }; (() => { logger.info('Started'); // Modify "locationchange" event // From https://stackoverflow.com/a/52809105 let oldPushState = history.pushState; history.pushState = function pushState() { let ret = oldPushState.apply(this, arguments); window.dispatchEvent(new Event('pushstate')); window.dispatchEvent(new Event('locationchange')); return ret; }; let oldReplaceState = history.replaceState; history.replaceState = function replaceState() { let ret = oldReplaceState.apply(this, arguments); window.dispatchEvent(new Event('replacestate')); window.dispatchEvent(new Event('locationchange')); return ret; }; window.addEventListener('popstate', () => { window.dispatchEvent(new Event('locationchange')); }); window.addEventListener('locationchange', function () { logger.debug('Location changed'); main(); }); main(); })(); const requireDataRefresh = (lastRefreshDate) => { if (!lastRefreshDate) { return true; } const now = new Date(); const differenceMs = now - lastRefreshDate; const MILLISECONDS = 1000; const SECONDS = 60; const MINUTES = 60; const HOURS = 24; const differenceDays = differenceMs / MILLISECONDS / SECONDS / MINUTES / HOURS; if (differenceDays < PINNED_REFRESH_DELAY_DAYS) { return false; } return true; }; const refreshPinnedData = async () => { const pinned = localStorageGetPinned(); const userNames = pinned.map(p => p.user); const fetchedPinned = batchGetTwitchUsers(userNames); fetchedPinned.forEach((fetched) => { const foundIndex = pinned.findIndex((user) => user.user.toLowerCase() === fetched?.user?.toLowerCase()); if (foundIndex < 0) { return; } pinned[foundIndex] = fetched; }) localStorageSetPinned(pinned); localStorageSetPinnedRefreshededAt(new Date()); logger.info("Pinned data refreshed."); } const injectCSS = () => { const style = document.createElement('style'); document.head.appendChild(style); style.appendChild(document.createTextNode(css)); }; const addStreamer = async () => { // eslint-disable-next-line no-alert const streamerUser = prompt('Streamer username:'); if (!streamerUser) { return; } const pinned = localStorageGetPinned(); const found = pinned.find((user) => user.user.toLowerCase() === streamerUser.toLowerCase()); if (found) { logger.info(`Streamer '${streamerUser}' already pinned.`); return; } const [user] = await batchGetTwitchUsers([streamerUser]); logger.debug(user); if (!user.id) { const message = `Streamer '${streamerUser}' not found.`; logger.warn(message); // eslint-disable-next-line no-alert alert(message); return; } pinned.push(user); localStorageSetPinned(pinned); logger.debug(localStorage['tps:pinned']); const prevHeight = document.querySelector('.tps-pinned-container').getBoundingClientRect().height; const nextHeight = prevHeight + document.querySelector('.tps-pinned-container > div').getBoundingClientRect().height; document.querySelector('.tps-pinned-container').style.height = `${prevHeight}px`; await renderPinnedStreamers(); document.querySelector('.tps-pinned-container').style.height = `${nextHeight}px`; setTimeout(() => { document.querySelector('.tps-pinned-container').style.height = ''; }, 500); }; const removeStreamer = async (id) => { const filtered = localStorageGetPinned().filter((p) => p.id !== id && p.id); localStorageSetPinned(filtered); const prevHeight = document.querySelector('.tps-pinned-container').getBoundingClientRect().height; const nextHeight = prevHeight - document.querySelector('.tps-pinned-container > div').getBoundingClientRect().height; document.querySelector('.tps-pinned-container').style.height = `${prevHeight}px`; await renderPinnedStreamers(); document.querySelector('.tps-pinned-container').style.height = `${nextHeight}px`; setTimeout(() => { document.querySelector('.tps-pinned-container').style.height = ''; }, 500); }; const renderPinnedStreamers = async () => { const pinnedUsers =localStorageGetPinned().map(p => p.user); const pinnedStreamers = await batchGetTwitchUsers(pinnedUsers); document.getElementById('anon-followed').querySelector('div:nth-child(2)').innerHTML = ''; pinnedStreamers .sort((a, b) => ((a.viewers < b.viewers) ? 1 : -1)) .sort((a, b) => { if (a.isLive === b.isLive) return 0; return a.isLive ? -1 : 1; }) .forEach((data) => { document.getElementById('anon-followed').querySelector('div:nth-child(2)').innerHTML += pinnedStreamer({ ...data, }); }); document.querySelectorAll('.tps-remove-pinned-streamer').forEach((btn) => { btn.addEventListener('click', async (event) => { const id = event.target.getAttribute('data-id'); logger.debug(`Removing pinned streamer with id: ${id}`); await removeStreamer(id); logger.debug(`Removed pinned streamer with id: ${id}`); }); }); }; // HTML templates const pinnedHeader = () => { const clonedPinnedHeader = document.querySelector(ALL_RELEVANT_CONTENT_SELECTOR).querySelector(HEADER_CLONE_SELECTOR).cloneNode(true); const h2 = clonedPinnedHeader.querySelector("h2"); h2.innerText = "Pinned Channels"; h2.setAttribute("style", "display:inline-block;"); clonedPinnedHeader.innerHTML += addBtn(); return clonedPinnedHeader.outerHTML; }; const addBtn = () => { const clonedBtn = document.querySelector(ALL_RELEVANT_CONTENT_SELECTOR).querySelector(BTN_CLONE_SELECTOR).querySelector(BTN_INNER_CLONE_SELECTOR).cloneNode(true); clonedBtn.title = "Add Pinned Streamer"; clonedBtn.id = "tps-add-streamer"; clonedBtn.setAttribute("style", "width:20px;height:16px;left:6px;"); clonedBtn.querySelector("svg").setAttribute("viewBox", "0 0 25 25"); clonedBtn.querySelector("g").innerHTML = `