asteriksme / Better Twitch

// ==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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
    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 });

})();