Twitch - Toggle Video Quality

Adds a customizable button to toggle stream quality (lowest <-> preferred) with optional auto-mute

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==UserScript==
// @name         Twitch - Toggle Video Quality
// @namespace    twitch-toggle-video-quality
// @version      1.0.0
// @description  Adds a customizable button to toggle stream quality (lowest <-> preferred) with optional auto-mute
// @author       Vikindor (https://vikindor.github.io/)
// @homepageURL  https://github.com/Vikindor/twitch-toggle-video-quality/
// @supportURL   https://github.com/Vikindor/twitch-toggle-video-quality/issues
// @license      MIT
// @match        https://www.twitch.tv/*
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  // ---------------- CONFIG ----------------
  // Preferred HIGH resolution.
  // Set a number (e.g. 1080) to try switching to that exact height.
  // If not available on the stream, the script falls back to the highest available quality.
  // Set to "null" to always use the maximum available quality.
  const PREFERRED_HIGH = 1080;

  // When switching to the lowest quality, automatically mute the player (true / false)
  const MUTE_ON_LOW = true;

  // Persist quality + mute state across reload
  const PERSIST_SELECTION = true;

  // 'minimal' -> small "Q" button inside player controls (bottom-right of video)
  // 'header'  -> purple "Quality" button in the channel header (next to "Subscribe")
  const VISUAL_MODE = 'header';
  // ----------------------------------------

  function persistQuality(group) {
    if (!PERSIST_SELECTION) return;

    try {
      localStorage.setItem(
        'video-quality',
        JSON.stringify({ default: group })
      );
    } catch (e) {}
  }

  function persistMute(isMuted) {
    if (!PERSIST_SELECTION || !MUTE_ON_LOW) return;

    try {
      localStorage.setItem(
        'video-muted',
        JSON.stringify({ default: String(isMuted) })
      );
    } catch (e) {}
  }

  function restoreMute(player) {
    if (!PERSIST_SELECTION || !MUTE_ON_LOW) return;

    try {
      const raw = localStorage.getItem('video-muted');
      if (!raw) return;

      const parsed = JSON.parse(raw);
      const isMuted = parsed?.default === 'true';

      player.setMuted(isMuted);
    } catch (e) {}
  }

  function getTwitchPlayer() {
    const node = document.querySelector('[data-a-target="video-player"]');
    if (!node) return null;

    const fiberKey = Object.keys(node).find(k => k.startsWith('__reactFiber'));
    if (!fiberKey) return null;

    const fiber = node[fiberKey];
    let found;

    (function find(obj, depth = 0, maxDepth = 6, seen = new WeakSet()) {
      if (!obj || typeof obj !== 'object') return;
      if (seen.has(obj)) return;
      seen.add(obj);

      if (
        typeof obj.setQuality === 'function' &&
        typeof obj.getQualities === 'function'
      ) {
        found = obj;
        return;
      }

      if (depth > maxDepth) return;

      for (let key in obj) {
        try {
          find(obj[key], depth + 1, maxDepth, seen);
        } catch (e) {}
      }
    })(fiber);

    return found || null;
  }

  function extractHeight(q) {
    const match = q.name.match(/^(\d+)/);
    return match ? parseInt(match[1], 10) : 0;
  }

  function toggleQuality() {
    const player = getTwitchPlayer();
    if (!player) return;

    const qualities = player.getQualities();
    if (!qualities || !qualities.length) return;

    const current = player.getQuality();

    const lowest = qualities.reduce((min, q) =>
      q.bitrate < min.bitrate ? q : min
    );

    let preferredHigh = null;

    if (PREFERRED_HIGH != null) {
      preferredHigh = qualities.find(q =>
        extractHeight(q) === PREFERRED_HIGH
      );
    }

    const highestAvailable = qualities.reduce((max, q) =>
      q.bitrate > max.bitrate ? q : max
    );

    const high = preferredHigh || highestAvailable;

    const isCurrentlyLowest = current.group === lowest.group;

    if (isCurrentlyLowest) {
      player.setQuality(high);
      player.setMuted(false);
      persistQuality(high.group);
      persistMute(false);
    } else {
      player.setQuality(lowest);
      if (MUTE_ON_LOW) {
        player.setMuted(true);
        persistMute(true);
      }
      persistQuality(lowest.group);
    }
  }

  function insertMinimalButton() {
    if (document.getElementById('quality-toggle-btn')) return;

    const rightGroup = document.querySelector(
      '[data-a-target="player-controls"] .player-controls__right-control-group'
    );

    if (!rightGroup) return;

    const btn = document.createElement('button');
    btn.id = 'quality-toggle-btn';
    btn.type = 'button';
    btn.textContent = 'Q';

    btn.style.background = 'transparent';
    btn.style.color = 'white';
    btn.style.border = 'none';
    btn.style.cursor = 'pointer';
    btn.style.fontWeight = 'bold';
    btn.style.padding = '0 8px';
    btn.style.height = '100%';

    btn.addEventListener('click', toggleQuality);

    rightGroup.appendChild(btn);
  }

  function insertHeaderButton() {
    if (document.getElementById('quality-toggle-btn')) return;

    const headerRight = document.querySelector(
      '[data-target="channel-header-right"]'
    );

    if (!headerRight) return;

    const btn = document.createElement('button');
    btn.id = 'quality-toggle-btn';
    btn.type = 'button';

    btn.style.display = 'flex';
    btn.style.alignItems = 'center';
    btn.style.justifyContent = 'center';
    btn.style.height = '32px';
    btn.style.padding = '0 12px';
    btn.style.border = '0';
    btn.style.boxSizing = 'border-box';
    btn.style.cursor = 'pointer';
    btn.style.fontFamily = 'Inter, inherit';
    btn.style.fontSize = '14px';
    btn.style.fontWeight = '600';
    btn.style.lineHeight = '19.6px';
    btn.style.borderRadius = '9000px';
    btn.style.marginLeft = '8px';
    btn.style.backgroundColor = '#9147ff';
    btn.style.color = 'white';
    btn.style.transition = 'background-color 0.15s ease';

    const svg = document.createElementNS(
      'http://www.w3.org/2000/svg',
      'svg'
    );
    svg.setAttribute('width', '16');
    svg.setAttribute('height', '16');
    svg.setAttribute('viewBox', '0 0 24 24');
    svg.setAttribute('fill', 'none');
    svg.setAttribute('stroke', 'currentColor');
    svg.setAttribute('stroke-width', '2');
    svg.setAttribute('stroke-linecap', 'round');
    svg.setAttribute('stroke-linejoin', 'round');
    svg.style.marginRight = '6px';

    svg.innerHTML = `
      <line x1="4" y1="6" x2="20" y2="6"></line>
      <circle cx="9" cy="6" r="2"></circle>
      <line x1="4" y1="12" x2="20" y2="12"></line>
      <circle cx="15" cy="12" r="2"></circle>
      <line x1="4" y1="18" x2="20" y2="18"></line>
      <circle cx="11" cy="18" r="2"></circle>
    `;

    const label = document.createElement('span');
    label.textContent = 'Quality';

    btn.appendChild(svg);
    btn.appendChild(label);

    btn.addEventListener('mouseenter', () => {
      btn.style.backgroundColor = '#772ce8';
    });

    btn.addEventListener('mouseleave', () => {
      btn.style.backgroundColor = '#9147ff';
    });

    btn.addEventListener('click', toggleQuality);

    headerRight.appendChild(btn);
  }

  function observeUI() {
    let muteRestored = false;

    const observer = new MutationObserver(() => {
      const player = getTwitchPlayer();

      if (player && !muteRestored) {
        setTimeout(() => {
          restoreMute(player);
        }, 500);

        muteRestored = true;
      }

      if (VISUAL_MODE === 'minimal') {
        insertMinimalButton();
      } else if (VISUAL_MODE === 'header') {
        insertHeaderButton();
      }
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true
    });
  }

  window.addEventListener('load', () => {
    observeUI();
  });
})();