NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Better Twitch
// @namespace http://tampermonkey.net/
// @version 0.1
// @description Remove front page carousel, add favorite channels feature
// @author asteriksme
// @match https://www.twitch.tv/*
// @icon https://static.twitchcdn.net/assets/favicon-32-e29e246c157142c94346.png
// @grant none
// @license MIT
// @updateURL https://openuserjs.org/meta/asteriksme/Better_Twitch.meta.js
// @downloadURL https://openuserjs.org/install/asteriksme/Better_Twitch.user.js
// ==/UserScript==
/* jshint esversion: 10 */
(function () {
'use strict';
const STORAGE_KEY = 'twitch_fav_channels';
// Stores the first-seen Twitch-natural order of offline channels so we can
// restore a channel to its original position when it's unfavorited.
const ORDER_KEY = 'twitch_fav_channels_order';
function getOriginalOrder() {
try { return JSON.parse(localStorage.getItem(ORDER_KEY)) || []; }
catch { return []; }
}
function recordOriginalOrder(offlineWrappers) {
const order = getOriginalOrder();
const known = new Set(order);
let changed = false;
offlineWrappers.forEach(w => {
const ch = getChannelFromCard(w);
if (ch && !known.has(ch)) { order.push(ch); known.add(ch); changed = true; }
});
if (changed) localStorage.setItem(ORDER_KEY, JSON.stringify(order));
return order;
}
// Persists display name + avatar of favorite offline channels so they can be
// reconstructed on future page loads when hidden behind "Show more".
const CARDS_KEY = 'twitch_fav_channels_cards';
function getSavedCards() {
try { return JSON.parse(localStorage.getItem(CARDS_KEY)) || {}; }
catch { return {}; }
}
function saveCardData(channel, card) {
const img = card.querySelector('img');
const name = (() => {
for (const el of card.querySelectorAll('p, span')) {
if (el.childElementCount > 0) continue;
const t = el.textContent.trim();
if (t && t !== 'Offline' && !/^\d[\d,.]*\s*(viewers?)?$/i.test(t)) return t;
}
return channel;
})();
const saved = getSavedCards();
saved[channel] = { name, avatarSrc: img ? img.src : '' };
localStorage.setItem(CARDS_KEY, JSON.stringify(saved));
}
// Build a minimal card element that matches all our selectors so it sorts
// and gets a favourite button like a real card would.
function createSyntheticCard(channel, data) {
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const avatar = data.avatarSrc
? `<img src="${esc(data.avatarSrc)}" alt="" style="width:30px;height:30px;border-radius:50%;flex-shrink:0">`
: `<div style="width:30px;height:30px;border-radius:50%;background:#3a3a3d;flex-shrink:0"></div>`;
const wrapper = document.createElement('div');
wrapper.setAttribute('data-tfav-synthetic', 'true');
wrapper.innerHTML =
`<div class="side-nav-card" style="position:relative">` +
`<a data-test-selector="followed-channel" href="/${esc(channel)}"` +
` style="display:flex;align-items:center;padding:5px 8px;text-decoration:none;color:inherit;gap:8px">` +
avatar +
`<div style="min-width:0;flex:1">` +
`<p style="margin:0;font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(data.name)}</p>` +
`<span style="font-size:11px;color:#adadb8">Offline</span>` +
`</div></a></div>`;
return wrapper;
}
// Insert synthetic cards for favourites that are not yet in the DOM.
function injectMissingFavorites(group) {
const favs = getFavs();
if (!favs.length) return;
const saved = getSavedCards();
const present = new Set(
Array.from(group.querySelectorAll('a[data-test-selector="followed-channel"]'))
.map(a => { const h = a.getAttribute('href'); return h ? h.replace(/^\//, '').toLowerCase() : null; })
.filter(Boolean)
);
favs.forEach(channel => {
if (present.has(channel) || !saved[channel]) return;
if (group.querySelector(`[data-tfav-synthetic] a[href="/${channel}"]`)) return;
group.appendChild(createSyntheticCard(channel, saved[channel]));
});
}
function getFavs() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; }
catch { return []; }
}
function saveFavs(favs) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(favs));
}
function toggleFav(channel) {
const favs = getFavs();
const idx = favs.indexOf(channel);
if (idx === -1) favs.push(channel);
else favs.splice(idx, 1);
saveFavs(favs);
return getFavs();
}
// Grab the page nonce so injected content passes Twitch's CSP
function getPageNonce() {
const el = document.querySelector('script[nonce]') ||
document.querySelector('style[nonce]');
return el ? el.nonce : '';
}
// Inject global styles once
if (!document.getElementById('twitch-fav-style')) {
const style = document.createElement('style');
style.id = 'twitch-fav-style';
style.nonce = getPageNonce();
style.textContent = `
.tfav-btn {
position: absolute;
top: 50%;
right: 60px;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 2px 4px;
border-radius: 4px;
opacity: 0;
transition: opacity 0.15s;
z-index: 10;
color: #f0c040;
filter: drop-shadow(0 0 2px #0008);
}
.side-nav-card:hover .tfav-btn,
.tfav-btn.tfav-active {
opacity: 1;
}
.tfav-btn:hover {
background: rgba(255,255,255,0.1);
}
.side-nav-card {
position: relative;
}
`;
document.head.appendChild(style);
}
function getChannelFromCard(card) {
const a = card.querySelector('a[data-test-selector="followed-channel"]');
if (!a) return null;
const href = a.getAttribute('href');
return href ? href.replace(/^\//, '').toLowerCase() : null;
}
// Detect offline cards by looking for a leaf element whose only text is "Offline"
function isOfflineCard(wrapper) {
const nodes = wrapper.querySelectorAll('p, span');
for (const el of nodes) {
if (el.childElementCount === 0 && el.textContent.trim() === 'Offline') return true;
}
return false;
}
function applyOrder(section) {
const favs = getFavs();
const group = section.querySelector('.InjectLayout-sc-1i43xsx-0.cpWPwm, [class*="tw-transition-group"]');
if (!group) return;
// Inject synthetic cards for favourites not yet rendered (e.g. hidden
// behind "Show more" on page load). Must happen before wrappers snapshot.
injectMissingFavorites(group);
const wrappers = Array.from(group.children).filter(el => el.querySelector('[data-test-selector="followed-channel"]'));
if (!wrappers.length) return;
// Split into live channels (untouched) and offline channels
const liveCards = [];
const offlineWrappers = [];
wrappers.forEach(w => (isOfflineCard(w) ? offlineWrappers : liveCards).push(w));
// Record original order for real (non-synthetic) offline channels only.
// Synthetic cards have no meaningful Twitch-natural position.
const realOfflineWrappers = offlineWrappers.filter(w => !w.hasAttribute('data-tfav-synthetic'));
const originalOrder = recordOriginalOrder(realOfflineWrappers);
const offlineFavCards = [];
const offlineRest = [];
offlineWrappers.forEach(w => {
const ch = getChannelFromCard(w);
const favIdx = ch ? favs.indexOf(ch) : -1;
if (favIdx !== -1) {
offlineFavCards.push({ w, idx: favIdx });
} else {
const origIdx = ch ? originalOrder.indexOf(ch) : Infinity;
offlineRest.push({ w, origIdx });
}
});
offlineFavCards.sort((a, b) => a.idx - b.idx);
// Restore unfavorited channels to their original Twitch position
offlineRest.sort((a, b) => a.origIdx - b.origIdx);
// Desired order: live (Twitch's own order) → offline favs → offline rest
const desired = [...liveCards, ...offlineFavCards.map(({ w }) => w), ...offlineRest.map(({ w }) => w)];
// Only mutate the DOM when the order actually differs.
const current = Array.from(group.children).filter(el => el.querySelector('[data-test-selector="followed-channel"]'));
if (desired.every((w, i) => current[i] === w)) return;
desired.forEach(w => group.appendChild(w));
}
function decorateCards(section) {
const favs = getFavs();
const cards = section.querySelectorAll('[data-test-selector="followed-channel"]');
cards.forEach(a => {
const card = a.closest('.side-nav-card');
if (!card) return;
// Remove button from cards that have gone online
if (!isOfflineCard(card)) {
const existing = card.querySelector('.tfav-btn');
if (existing) existing.remove();
return;
}
const isSynthetic = !!a.closest('[data-tfav-synthetic]');
// Button already present on this offline card — just update its state
const existingBtn = card.querySelector('.tfav-btn');
const channel = (() => {
const href = a.getAttribute('href');
return href ? href.replace(/^\//, '').toLowerCase() : null;
})();
if (!channel) return;
if (!isSynthetic) {
// Real card arrived — remove the synthetic placeholder if present
const synth = section.querySelector(`[data-tfav-synthetic] a[href="/${channel}"]`);
if (synth) synth.closest('[data-tfav-synthetic]').remove();
// Persist data so the card can be synthesised on future page loads
if (favs.includes(channel)) saveCardData(channel, card);
}
if (existingBtn) {
const isFav = favs.includes(channel);
existingBtn.textContent = isFav ? '★' : '☆';
existingBtn.title = isFav ? 'Remove from favorites' : 'Add to favorites';
existingBtn.classList.toggle('tfav-active', isFav);
return;
}
const btn = document.createElement('button');
btn.className = 'tfav-btn' + (favs.includes(channel) ? ' tfav-active' : '');
btn.title = favs.includes(channel) ? 'Remove from favorites' : 'Add to favorites';
btn.textContent = favs.includes(channel) ? '★' : '☆';
btn.setAttribute('aria-label', 'Toggle favorite');
btn.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
const newFavs = toggleFav(channel);
const isFav = newFavs.includes(channel);
btn.textContent = isFav ? '★' : '☆';
btn.title = isFav ? 'Remove from favorites' : 'Add to favorites';
btn.classList.toggle('tfav-active', isFav);
// Re-decorate and reorder
const sec = btn.closest('[aria-label="Followed Channels"]');
if (sec) applyOrder(sec);
});
card.appendChild(btn);
});
applyOrder(section);
}
function removeClutter() {
// Remove front-page carousel and Stories left-nav sections that clutter
// the sidebar once the followed channels header is present.
if (!document.querySelector('.followed-side-nav-header')) return;
document.querySelectorAll('.front-page-carousel, [class*=storiesLeftNavSection], .lcFJxY')
.forEach(el => el.remove());
}
// Declared before run() so run() can reference it to disconnect/reconnect.
let rafId = null;
let observer;
function run() {
// Disconnect while we mutate the DOM so our own changes (appending buttons,
// reordering cards) don't re-trigger the observer and cause a blink loop.
if (observer) observer.disconnect();
removeClutter();
const sections = document.querySelectorAll('[aria-label="Followed Channels"]');
sections.forEach(s => {
decorateCards(s);
});
if (observer) observer.observe(document.body, { childList: true, subtree: true });
}
run();
observer = new MutationObserver(() => {
if (rafId !== null) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => { rafId = null; run(); });
});
observer.observe(document.body, { childList: true, subtree: true });
})();