Twitch - Toggle Video Quality

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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

作者のサイトでサポートを受ける。または、このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();
  });
})();