Instantly share code, notes, and snippets.
Last active
August 26, 2026 19:30
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save klydeinside/3e2babc1e2f21d458cb9b4214be13c2e to your computer and use it in GitHub Desktop.
Twitch userscripts - Live Follows Shortcut Button
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Twitch – Live Follows Shortcut Button | |
| // @namespace https://cody-tools.local | |
| // @version 1.6 | |
| // @description Adds a one-click button right next to the side-nav collapse arrow (same icon/style as Twitch's own "Collapse Chat" button) that jumps straight to https://www.twitch.tv/directory/following/live using Twitch's own client-side router — so a stream you're watching drops into mini-player instead of the whole site reloading. The mini-player itself dims to 45% opacity so it doesn't block the grid behind it, and snaps to full opacity on hover. | |
| // @match https://www.twitch.tv/* | |
| // @grant none | |
| // @updateURL https://gist.githubusercontent.com/klydeinside/3e2babc1e2f21d458cb9b4214be13c2e/raw/twitch-open-chat-button.user.js | |
| // @downloadURL https://gist.githubusercontent.com/klydeinside/3e2babc1e2f21d458cb9b4214be13c2e/raw/twitch-open-chat-button.user.js | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| const BTN_ID = 'lgGoLiveFollowsBtn'; | |
| const DEST = 'https://www.twitch.tv/directory/following/live'; | |
| // side-nav-arrow is present on every Twitch page, so this button shows | |
| // up sitewide right next to it. | |
| const SIDE_ARROW = '[data-a-target="side-nav-arrow"]'; | |
| // Twitch's own nav elements. Clicking these (they're real React-managed | |
| // anchors) triggers Twitch's client-side router, which is what keeps a | |
| // playing stream alive in the mini-player. A raw location.href navigation | |
| // or a synthetic <a> we create ourselves both force a full page reload | |
| // instead — verified against the live site before shipping this. | |
| const FOLLOWING_LINK = '[data-a-target="following-link"]'; | |
| const LIVE_TAB = '[data-a-target="following-live-tab"]'; | |
| function goToLiveFollows() { | |
| const followingLink = document.querySelector(FOLLOWING_LINK); | |
| if (!followingLink) { | |
| // Fallback: shouldn't happen (following-link is sitewide), but | |
| // don't leave the button dead if Twitch changes this element. | |
| window.location.href = DEST; | |
| return; | |
| } | |
| followingLink.click(); | |
| const start = Date.now(); | |
| const poll = setInterval(() => { | |
| const liveTab = document.querySelector(LIVE_TAB); | |
| if (liveTab) { | |
| clearInterval(poll); | |
| liveTab.click(); | |
| } else if (Date.now() - start > 3000) { | |
| clearInterval(poll); // give up quietly rather than hang forever | |
| } | |
| }, 50); | |
| } | |
| function insertButton() { | |
| if (document.getElementById(BTN_ID)) return; | |
| const sideArrow = document.querySelector(SIDE_ARROW); | |
| if (!sideArrow) return; | |
| const wrapper = sideArrow.parentElement; | |
| const row = wrapper.parentElement; | |
| if (!wrapper || !row) return; | |
| const btn = document.createElement('button'); | |
| btn.id = BTN_ID; | |
| // Reuse Twitch's own button classes so it inherits native styling | |
| // (hover/focus states, sizing) instead of hand-rolled CSS. | |
| btn.className = sideArrow.className; | |
| btn.setAttribute('aria-label', 'Go to Live Follows'); | |
| btn.style.marginLeft = '4px'; | |
| btn.innerHTML = `<div class="ButtonIconFigure-sc-1emm8lf-0 jgYFuA"><div class="ScSvgWrapper-sc-wkgzod-0 cnGLHG tw-svg"><svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" role="presentation"><path d="M3 5h2v14H3V5Zm19.414 7-6.707-6.707-1.414 1.414L18.586 11H7v2h11.586l-4.293 4.293 1.414 1.414L22.414 12Z"></path></svg></div></div>`; | |
| btn.addEventListener('click', goToLiveFollows); | |
| const newWrapper = wrapper.cloneNode(false); | |
| newWrapper.removeAttribute('id'); | |
| newWrapper.appendChild(btn); | |
| row.insertBefore(newWrapper, wrapper.nextSibling); | |
| } | |
| // Twitch's floating mini-player (shown when you navigate away from a | |
| // stream while it's playing) is a fixed-position box that sits over | |
| // whatever's underneath. IMPORTANT: the bare ".persistent-player" class | |
| // is Twitch's name for the video player component in general — it's | |
| // ALSO present on the full-size player on a normal channel page, not | |
| // just the floating mini box. Targeting it unscoped dims the main video | |
| // you're actively watching to 45% at all times (that's the "icon stuck | |
| // on the video" bug — reproduced live on 2026-08-26: the full player | |
| // matched ".persistent-player" with 45% opacity while sitting in the | |
| // main content area, and only the true floating mini box additionally | |
| // carries "persistent-player__border--mini"). Scope to that mini class | |
| // specifically so the main player is never touched. | |
| const MINI_PLAYER_STYLE_ID = 'lgMiniPlayerDim'; | |
| function injectMiniPlayerStyle() { | |
| if (document.getElementById(MINI_PLAYER_STYLE_ID)) return; | |
| const style = document.createElement('style'); | |
| style.id = MINI_PLAYER_STYLE_ID; | |
| style.textContent = ` | |
| .persistent-player__border--mini { | |
| opacity: 0.45; | |
| transition: opacity 0.25s ease; | |
| } | |
| .persistent-player__border--mini:hover { | |
| opacity: 1; | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| } | |
| function tick() { | |
| insertButton(); | |
| injectMiniPlayerStyle(); | |
| } | |
| let tickScheduled = false; | |
| function scheduleTick() { | |
| if (tickScheduled) return; | |
| tickScheduled = true; | |
| requestAnimationFrame(() => { | |
| tickScheduled = false; | |
| tick(); | |
| }); | |
| } | |
| // Twitch is an SPA — hook history navigation and watch for DOM changes | |
| // so the button survives route changes. | |
| const origPushState = history.pushState; | |
| history.pushState = function (...args) { | |
| origPushState.apply(this, args); | |
| scheduleTick(); | |
| }; | |
| window.addEventListener('popstate', scheduleTick); | |
| const observer = new MutationObserver(scheduleTick); | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| tick(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment