Skip to content

Instantly share code, notes, and snippets.

@klydeinside
Last active August 12, 2026 21:12
Show Gist options
  • Select an option

  • Save klydeinside/ec546153caba19d37890dd5a0f0f1ade to your computer and use it in GitHub Desktop.

Select an option

Save klydeinside/ec546153caba19d37890dd5a0f0f1ade to your computer and use it in GitHub Desktop.
Twitch userscripts - Liquid Glass Suite (merged)
// ==UserScript==
// @name Twitch – Liquid Glass Suite
// @namespace https://cody-tools.local
// @version 1.3
// @description All-in-one: frosted "Liquid Glass" sidebar treatment (Off/Subtle/More/Full — Full gives each followed-channel card a raised glass-bubble look), a Live Follows shortcut button (mini-player dims via a dark overlay pseudo-element instead of opacity, so the video's own compositing is never touched), and a narrow Following grid — all controlled from one glass-styled settings panel (gear icon, bottom-right). Replaces twitch-liquid-glass-sidebar, twitch-open-chat-button, and twitch-following-narrow-grid.
// @match https://www.twitch.tv/*
// @grant GM_setValue
// @grant GM_getValue
// @updateURL https://gist.githubusercontent.com/klydeinside/ec546153caba19d37890dd5a0f0f1ade/raw/twitch-liquid-glass-suite.user.js
// @downloadURL https://gist.githubusercontent.com/klydeinside/ec546153caba19d37890dd5a0f0f1ade/raw/twitch-liquid-glass-suite.user.js
// ==/UserScript==
(function () {
'use strict';
// ---------------------------------------------------------------------
// Settings (persisted via GM_setValue/GM_getValue). Keys match the
// original standalone scripts' storage keys where applicable, so
// switching from those scripts to this one keeps your existing
// preferences instead of resetting them.
// ---------------------------------------------------------------------
const GLASS_LEVEL_KEY = 'lgSidebarLevel'; // 0 off / 1 subtle / 2 more / 3 full (bubble)
const COLS_KEY = 'lfDirColumns'; // 2 or 3
const MINI_DIM_KEY = 'lgMiniPlayerDimEnabled'; // boolean
const GLASS_LEVELS = ['Off', 'Subtle', 'More', 'Full'];
function getGlassLevel() {
const v = GM_getValue(GLASS_LEVEL_KEY, 2);
return (v === 0 || v === 1 || v === 2 || v === 3) ? v : 2;
}
function setGlassLevel(n) {
GM_setValue(GLASS_LEVEL_KEY, n);
applyStyles();
renderPanel();
}
function getCols() {
const v = GM_getValue(COLS_KEY, 3);
return v === 2 ? 2 : 3;
}
function setCols(n) {
GM_setValue(COLS_KEY, n);
applyStyles();
renderPanel();
}
function getMiniDimEnabled() {
const v = GM_getValue(MINI_DIM_KEY, true);
return v !== false;
}
function setMiniDimEnabled(b) {
GM_setValue(MINI_DIM_KEY, b);
applyStyles();
renderPanel();
}
// ---------------------------------------------------------------------
// Selectors — verified against live Twitch DOM. data-a-target hooks are
// Twitch's own stable attributes; the class names below are semantic
// ones, not hashed styled-components classes, so they've held up.
// ---------------------------------------------------------------------
const SIDE_NAV = '[data-a-target="side-nav-bar"]';
const SCROLL_AREA = '.side-nav__scrollable_content';
const HEADER = '.followed-side-nav-header, .side-nav-header';
const CARD = '.side-nav-card';
const AVATAR_IMG = '.side-nav-card__avatar img';
const SIDE_ARROW = '[data-a-target="side-nav-arrow"]';
const FOLLOWING_LINK = '[data-a-target="following-link"]';
const LIVE_TAB = '[data-a-target="following-live-tab"]';
function isFollowingDirectory() {
return location.pathname.replace(/\/+$/, '') === '/directory/following/live';
}
// ---------------------------------------------------------------------
// Combined stylesheet — rebuilt any time a setting changes or Twitch
// re-renders. Sections are conditionally included based on current
// settings / current page so nothing leaks where it shouldn't.
// ---------------------------------------------------------------------
const STYLE_ID = 'lgSuiteStyle';
function applyStyles() {
let style = document.getElementById(STYLE_ID);
if (!style) {
style = document.createElement('style');
style.id = STYLE_ID;
document.head.appendChild(style);
}
let css = '';
// --- Liquid glass sidebar ---
const level = getGlassLevel();
if (level > 0) {
css += `
${SIDE_NAV} {
position: relative;
background:
linear-gradient(180deg, rgba(255,255,255,0.09), rgba(255,255,255,0.01) 35%, rgba(255,255,255,0.04)),
color-mix(in srgb, var(--color-background-body, #0e0e10) 20%, transparent) !important;
-webkit-backdrop-filter: blur(14px) saturate(140%);
backdrop-filter: blur(14px) saturate(140%);
border-right: 1px solid rgba(255,255,255,0.35);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.5),
inset -1px 0 0 rgba(255,255,255,0.08),
1px 0 0 rgba(120,180,255,0.12),
14px 0 34px rgba(0,0,0,0.5);
}
${SCROLL_AREA} {
background: transparent !important;
scrollbar-width: none;
-ms-overflow-style: none;
}
${SCROLL_AREA}::-webkit-scrollbar {
display: none;
}
${HEADER} {
position: relative;
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
background: rgba(255,255,255,0.03) !important;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
${CARD} {
position: relative;
border-radius: 10px;
transition:
background 0.25s ease,
transform 0.2s cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 0.25s ease;
}
${CARD}:hover {
background: color-mix(in srgb, white 7%, transparent);
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.4),
0 6px 16px rgba(0,0,0,0.35);
transform: translateX(3px) scale(1.015);
}
${AVATAR_IMG} {
box-shadow: 0 0 0 1px rgba(255,255,255,0.2);
}
`;
if (level >= 2) {
css += `
${SIDE_NAV}::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
115deg,
transparent 15%,
rgba(255,255,255,0.07) 38%,
rgba(255,255,255,0.18) 50%,
rgba(255,255,255,0.07) 62%,
transparent 85%
);
background-size: 250% 250%;
mix-blend-mode: overlay;
animation: lgSheen 7s ease-in-out infinite;
}
@keyframes lgSheen {
0% { background-position: 0% 0%; }
50% { background-position: 100% 100%; }
100% { background-position: 0% 0%; }
}
${CARD}::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease;
background: radial-gradient(
160px 160px at var(--lg-mx, 50%) var(--lg-my, 50%),
rgba(255,255,255,0.3),
transparent 65%
);
}
${CARD}:hover::before {
opacity: 1;
}
`;
}
if (level === 3) {
// "Full" — each followed-channel card looks like a raised
// dome of liquid glass sitting on top of the sidebar: a
// strong convex highlight up top, a dark rim underneath for
// depth, extra local blur/saturation to suggest refraction,
// and a lift/scale on hover like the bubble is reacting to
// your cursor. Experimental — easy to strip out (this is
// the only `level === 3` block) if it doesn't land right.
css += `
${CARD} {
border-radius: 18px;
background:
radial-gradient(130% 150% at 28% 15%, rgba(255,255,255,0.4), rgba(255,255,255,0.06) 45%, rgba(255,255,255,0.14) 100%);
-webkit-backdrop-filter: blur(10px) saturate(180%) brightness(1.12);
backdrop-filter: blur(10px) saturate(180%) brightness(1.12);
box-shadow:
inset 0 2px 1px rgba(255,255,255,0.9),
inset 0 -8px 12px rgba(0,0,0,0.28),
inset 8px 0 14px rgba(255,255,255,0.12),
inset -6px 0 10px rgba(0,0,0,0.12),
0 12px 26px rgba(0,0,0,0.45),
0 3px 8px rgba(0,0,0,0.35);
transform: scale(1.02);
}
${CARD}:hover {
background:
radial-gradient(130% 150% at 28% 15%, rgba(255,255,255,0.5), rgba(255,255,255,0.08) 45%, rgba(255,255,255,0.18) 100%);
box-shadow:
inset 0 2px 1px rgba(255,255,255,1),
inset 0 -8px 14px rgba(0,0,0,0.32),
inset 8px 0 16px rgba(255,255,255,0.18),
inset -6px 0 10px rgba(0,0,0,0.14),
0 16px 34px rgba(145,71,255,0.4),
0 0 0 1px rgba(145,71,255,0.45);
transform: translateX(3px) scale(1.06);
}
`;
}
}
// --- Narrow Following grid (only on the Live tab of the directory) ---
if (isFollowingDirectory()) {
const cols = getCols();
const pct = (100 / cols).toFixed(4);
css += `
.tw-tower {
justify-content: flex-start !important;
}
.tw-tower > div {
box-sizing: border-box !important;
flex: 0 0 ${pct}% !important;
width: ${pct}% !important;
max-width: ${pct}% !important;
}
.tw-tower article {
border-radius: 8px;
transition:
transform 0.18s cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 0.18s cubic-bezier(0.16, 1, 0.3, 1),
border-radius 0.18s ease;
}
.tw-tower article:hover {
transform: translateY(-4px) scale(1.02);
box-shadow:
0 12px 32px rgba(145,71,255,0.35),
0 0 0 1px rgba(145,71,255,0.5);
position: relative;
z-index: 2;
}
`;
}
// --- Mini-player dim-on-rest ---
// IMPORTANT: this used to set `opacity` directly on .persistent-player.
// That forces Chromium to promote the whole subtree (including the
// live <video>) into a translucent compositing layer, and that was
// triggering Twitch's player-error ("?") placeholder intermittently
// — confirmed by the fact hovering (opacity back to 1) always fixed
// it instantly. Using a separate dark overlay pseudo-element instead
// dims the box visually without ever touching the video's own
// opacity/compositing, so the player itself is untouched.
if (getMiniDimEnabled()) {
css += `
.persistent-player::after {
content: "";
position: absolute;
inset: 0;
background: rgba(0,0,0,0.55);
opacity: 1;
pointer-events: none;
transition: opacity 0.25s ease;
z-index: 5;
}
.persistent-player:hover::after {
opacity: 0;
}
`;
}
style.textContent = css;
}
// Delegated mousemove: tracks cursor position per hovered sidebar card
// and writes it to CSS custom properties, so the "Full" glass level's
// shine follows the cursor like light catching curved glass.
let shineHandlerBound = false;
function bindShineHandler() {
if (shineHandlerBound) return;
shineHandlerBound = true;
document.addEventListener('mousemove', (e) => {
if (getGlassLevel() < 2) return;
const card = e.target.closest && e.target.closest(CARD);
if (!card) return;
const rect = card.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
card.style.setProperty('--lg-mx', `${x}%`);
card.style.setProperty('--lg-my', `${y}%`);
}, { passive: true });
}
// ---------------------------------------------------------------------
// Live Follows shortcut button — inserted next to the side-nav collapse
// arrow. Clicking Twitch's own real anchors (not a synthetic <a> or a
// raw location.href) is what keeps a playing stream alive in the
// floating mini-player instead of forcing a full page reload.
// ---------------------------------------------------------------------
const BTN_ID = 'lgGoLiveFollowsBtn';
const DEST = 'https://www.twitch.tv/directory/following/live';
function goToLiveFollows() {
const followingLink = document.querySelector(FOLLOWING_LINK);
if (!followingLink) {
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);
}
}, 50);
}
function insertGoLiveButton() {
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;
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);
}
// ---------------------------------------------------------------------
// Settings panel — one gear icon (bottom-right), one glass-styled panel
// with everything in it. Replaces the three separate floating pills.
// ---------------------------------------------------------------------
const GEAR_ID = 'lgSuiteGear';
const PANEL_ID = 'lgSuitePanel';
let panelOpen = false;
function segButton(label, active, action, value) {
return `<button class="lgSeg${active ? ' lgSegActive' : ''}" data-action="${action}" data-value="${value}">${label}</button>`;
}
function panelHTML() {
const level = getGlassLevel();
const cols = getCols();
const dim = getMiniDimEnabled();
return `
<div class="lgPanelRow">
<div class="lgPanelLabel">Liquid Glass</div>
<div class="lgSegGroup">
${GLASS_LEVELS.map((l, i) => segButton(l, level === i, 'glass', i)).join('')}
</div>
</div>
<div class="lgPanelRow">
<div class="lgPanelLabel">Following Grid</div>
<div class="lgSegGroup">
${segButton('2 col', cols === 2, 'cols', 2)}
${segButton('3 col', cols === 3, 'cols', 3)}
</div>
</div>
<div class="lgPanelRow">
<div class="lgPanelLabel">Dim Mini-Player</div>
<div class="lgSegGroup">
${segButton('On', dim, 'dim', 1)}
${segButton('Off', !dim, 'dim', 0)}
</div>
</div>
`;
}
function renderPanel() {
const panel = document.getElementById(PANEL_ID);
if (panel) panel.innerHTML = panelHTML();
}
function ensurePanelStyle() {
if (document.getElementById('lgSuitePanelStyle')) return;
const style = document.createElement('style');
style.id = 'lgSuitePanelStyle';
style.textContent = `
#${GEAR_ID} {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 999999;
width: 44px;
height: 44px;
border-radius: 9999px;
border: 1px solid rgba(255,255,255,0.35);
background:
linear-gradient(180deg, rgba(255,255,255,0.14), rgba(255,255,255,0.04)),
color-mix(in srgb, #0e0e10 55%, transparent);
-webkit-backdrop-filter: blur(14px) saturate(150%);
backdrop-filter: blur(14px) saturate(150%);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.5), 0 8px 20px rgba(0,0,0,0.45);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #fff;
transition: transform 0.15s ease;
}
#${GEAR_ID}:hover {
transform: scale(1.06);
}
#${PANEL_ID} {
position: fixed;
bottom: 76px;
right: 20px;
z-index: 999999;
width: 240px;
padding: 14px;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.35);
background:
linear-gradient(180deg, rgba(255,255,255,0.1), rgba(255,255,255,0.02)),
color-mix(in srgb, #0e0e10 45%, transparent);
-webkit-backdrop-filter: blur(20px) saturate(150%);
backdrop-filter: blur(20px) saturate(150%);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.5), 0 20px 50px rgba(0,0,0,0.5);
color: #fff;
font-family: Arial, sans-serif;
display: none;
flex-direction: column;
gap: 12px;
}
#${PANEL_ID}.lgOpen {
display: flex;
}
.lgPanelRow {
display: flex;
flex-direction: column;
gap: 6px;
}
.lgPanelLabel {
font-size: 12px;
font-weight: 700;
opacity: 0.85;
letter-spacing: 0.02em;
}
.lgSegGroup {
display: flex;
gap: 4px;
}
.lgSeg {
flex: 1;
padding: 6px 0;
font-size: 12px;
font-weight: 600;
font-family: Arial, sans-serif;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.06);
color: #fff;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.lgSeg:hover {
background: rgba(255,255,255,0.14);
}
.lgSegActive {
background: #9147ff;
border-color: #9147ff;
}
`;
document.head.appendChild(style);
}
function createGear() {
if (document.getElementById(GEAR_ID)) return;
ensurePanelStyle();
const gear = document.createElement('button');
gear.id = GEAR_ID;
gear.setAttribute('aria-label', 'Liquid Glass Suite settings');
gear.innerHTML = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`;
gear.addEventListener('click', (e) => {
e.stopPropagation();
panelOpen = !panelOpen;
const panel = document.getElementById(PANEL_ID);
if (panel) panel.classList.toggle('lgOpen', panelOpen);
});
document.body.appendChild(gear);
const panel = document.createElement('div');
panel.id = PANEL_ID;
panel.innerHTML = panelHTML();
panel.addEventListener('click', (e) => {
const btn = e.target.closest('.lgSeg');
if (!btn) return;
const action = btn.dataset.action;
const value = btn.dataset.value;
if (action === 'glass') setGlassLevel(Number(value));
else if (action === 'cols') setCols(Number(value));
else if (action === 'dim') setMiniDimEnabled(value === '1');
});
document.body.appendChild(panel);
document.addEventListener('click', (e) => {
if (!panelOpen) return;
if (panel.contains(e.target) || gear.contains(e.target)) return;
panelOpen = false;
panel.classList.remove('lgOpen');
});
}
// ---------------------------------------------------------------------
// Single shared tick loop for the whole suite.
// ---------------------------------------------------------------------
function tick() {
applyStyles();
insertGoLiveButton();
createGear();
bindShineHandler();
}
let tickScheduled = false;
function scheduleTick() {
if (tickScheduled) return;
tickScheduled = true;
requestAnimationFrame(() => {
tickScheduled = false;
tick();
});
}
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