// ==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.2.4
// ==/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 MITUNES_SINCE_FOCUS_LOST_FOR_REFRESH = 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 NAV_CARD_CLONE_SELECTOR = ".side-nav-section .side-nav-card";
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 isTabVisible = false;
let waitForMainContainer;
const main = () => {
let relevantContent;
if (waitForMainContainer) {
clearInterval(waitForMainContainer);
}
waitForMainContainer = setInterval(async () => {
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.');
// Tab visibility handler
isTabVisible = !document.hidden;
document.addEventListener('visibilitychange', async () => {
if (document.hidden) {
logger.debug('Tab hidden.');
isTabVisible = false;
return;
}
logger.debug('Tab visible.');
isTabVisible = true;
// Refresh if change to visible
const lastRefreshedAt = localStorageGetPinnedRefresheddAt();
if (requireDataRefresh(lastRefreshedAt)) {
logger.info("Refreshing pinned streamers.");
try {
await refreshPinnedData();
await renderPinnedStreamers();
} catch (error) {
logger.warn(`Could not refresh pinned streamers. ${error?.message}`);
}
}
});
// End Tab visibility handler
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',
);
const anonFollowedElement = document.createElement('div');
anonFollowedElement.id = 'anon-followed';
anonFollowedElement.innerHTML += pinnedHeader();
anonFollowedElement.innerHTML += '<div class="tps-pinned-container"></div>';
sidebarContent.insertBefore(anonFollowedElement, sidebarContent.childNodes[0]);
await renderPinnedStreamers();
setInterval(async () => {
if (!isTabVisible) {
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 SECONDS = 1000;
const MINUTES = 60;
const differenceMinutes = differenceMs / SECONDS / MINUTES;
if (differenceMinutes < MITUNES_SINCE_FOCUS_LOST_FOR_REFRESH) {
return false;
}
return true;
};
const refreshPinnedData = async () => {
const pinned = localStorageGetPinned();
const userNames = pinned.map(p => p.user);
const fetchedPinned = await 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.debug("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 = `<path vector-effect="non-scaling-stroke" d="M 12 2 C 6.4889971 2 2 6.4889971 2 12 C 2 17.511003 6.4889971 22 12 22 C 17.511003 22 22 17.511003 22 12 C 22 6.4889971 17.511003 2 12 2 z M 12 4 C 16.430123 4 20 7.5698774 20 12 C 20 16.430123 16.430123 20 12 20 C 7.5698774 20 4 16.430123 4 12 C 4 7.5698774 7.5698774 4 12 4 z M 11 7 L 11 11 L 7 11 L 7 13 L 11 13 L 11 17 L 13 17 L 13 13 L 17 13 L 17 11 L 13 11 L 13 7 L 11 7 z"></path>`;
return clonedBtn.outerHTML;
}
const pinnedStreamer = ({
user, id, displayName, profileImageURL, isLive, viewers = '', category,
}) => {
const removeBtn = `<button class="tps-remove-pinned-streamer" data-id="${id}" title="Remove pinned streamer" style="position:absolute;top:-6px;left:2px;z-index:1;">x</button>`;
const prettyViewers = stylizedViewers(viewers);
const clonedPinnedStreamer = document.querySelector(ALL_RELEVANT_CONTENT_SELECTOR).querySelector(NAV_CARD_CLONE_SELECTOR).parentNode.parentNode.cloneNode(true);
if (!isLive) {
clonedPinnedStreamer.setAttribute("style", "opacity:0.4;");
}
clonedPinnedStreamer.querySelector("a").setAttribute("href", `/${user}`);
const figure = clonedPinnedStreamer.querySelector(".side-nav-card__avatar");
figure.setAttribute("aria-label", displayName)
const img = figure.querySelector("img");
img.setAttribute("alt", displayName);
img.setAttribute("src", profileImageURL);
const metadata = clonedPinnedStreamer.querySelector("[data-a-target='side-nav-card-metadata'] p");
metadata.title = displayName;
metadata.innerText = displayName;
const streamCategory = clonedPinnedStreamer.querySelector("[data-a-target='side-nav-game-title'] p");
streamCategory.title = isLive ? category : '';
streamCategory.innerText = isLive ? category : '';
const liveStatus = clonedPinnedStreamer.querySelector("div[data-a-target='side-nav-live-status']");
if (!isLive) {
liveStatus.innerHTML = "";
} else {
const liveSpan = liveStatus.querySelector("span");
liveSpan.setAttribute("aria-label", `${prettyViewers} viewers`);
liveSpan.innerText = prettyViewers;
}
clonedPinnedStreamer.querySelector("div").innerHTML = removeBtn + clonedPinnedStreamer.querySelector("div").innerHTML;
return clonedPinnedStreamer.outerHTML;
};
const stylizedViewers = (viewers) => {
if (!viewers) {
return '';
}
const number = parseInt(viewers, 10);
return nFormatter(number, 1);
};
// From https://stackoverflow.com/a/9462382
function nFormatter(num, digits) {
const lookup = [
{ value: 1, symbol: '' },
{ value: 1e3, symbol: 'K' },
{ value: 1e6, symbol: 'M' },
{ value: 1e9, symbol: 'G' },
{ value: 1e12, symbol: 'T' },
{ value: 1e15, symbol: 'P' },
{ value: 1e18, symbol: 'E' },
];
const rx = /\.0+$|(\.[0-9]*[1-9])0+$/;
const item = lookup.slice().reverse().find((lookupItem) => num >= lookupItem.value);
return item ? (num / item.value).toFixed(digits).replace(rx, '$1') + item.symbol : '0';
}
// GRAPHQL Requests
const batchGetTwitchUsers = async (logins) => {
if (logins.length === 0) {
return [];
}
const twitchUsers = await twitchGQLRequest({
query: `query($logins: [String!]!, $all: Boolean!, $skip: Boolean!) {
users(logins: $logins) {
login
id
broadcastSettings {
language
game {
displayName
name
}
title
}
createdAt
description
displayName
followers {
totalCount
}
stream {
archiveVideo @include(if: $all) {
id
}
createdAt
id
type
viewersCount
}
lastBroadcast {
startedAt
}
primaryTeam {
displayName
name
}
profileImageURL(width: 70)
profileViewCount
self @skip(if: $skip) {
canFollow
follower {
disableNotifications
}
}
}
}`,
variables: { logins, all: false, skip: false },
});
const result = twitchUsers.data.users.map(user => {
if (!user) {
return {};
}
return {
user: user.login,
displayName: user.displayName,
profileImageURL: user.profileImageURL,
id: user.id,
isLive: user?.stream?.type,
viewers: user?.stream?.viewersCount,
category: user?.broadcastSettings?.game?.displayName,
title: user?.broadcastSettings?.title,
};
});
return result;
};
const twitchGQLRequest = async ({ query, variables }) => {
const headers = new Headers();
headers.append('Client-ID', CLIENT_ID);
headers.append('Content-Type', 'application/json');
const graphql = JSON.stringify({
query,
variables,
});
const requestOptions = {
method: 'POST',
headers,
body: graphql,
redirect: 'follow',
};
return fetch(TWITCH_GRAPHQL, requestOptions)
.then((response) => {
if (!response.ok) {
logger.warn('GraphQL request error:', query, variables);
throw new Error(`HTTP-Error twitchGQLRequest. Status code: ${response.status}`);
}
return response;
})
.then((response) => response.text())
.then((text) => JSON.parse(text))
.catch((error) => {
throw error;
});
};
// LocalStorage
const localStorageGetPinned = () => {
const lsPinned = localStorage.getItem('tps:pinned');
return lsPinned ? JSON.parse(lsPinned) : [];
};
const localStorageSetPinned = (data) => {
localStorage.setItem('tps:pinned', JSON.stringify(data));
return true;
};
const localStorageGetPinnedRefresheddAt = () => {
const pinnedRefreshededAt = localStorage.getItem('tps:pinned:refreshedat');
return pinnedRefreshededAt ? new Date(pinnedRefreshededAt) : new Date();
};
const localStorageSetPinnedRefreshededAt = (date) => {
localStorage.setItem('tps:pinned:refreshedat', date.toISOString());
return true;
};