Majestic_ / YouTube & Twitch Video Dimmer (with Cycling Slider)

// ==UserScript==
// @name         YouTube & Twitch Video Dimmer (with Cycling Slider)
// @namespace    https://github.com/E-VanDeWoestyne
// @version      1.2
// @description  Adjust video brightness on YouTube and Twitch with a slider (Shift+S to open, rebindable), or cycle through it in steps with a hotkey (Shift+D by default, rebindable, capped at 100%). Double-click a slider to reset it to 100%. An Advanced panel adds an optional contrast slider, a configurable cycle step size, custom keybinds, and an Extra brightness toggle for boosting up to 200%.
// @author       E-VanDeWoestyne
// @match        https://www.youtube.com/*
// @match        https://www.twitch.tv/*
// @grant        none
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  const STORAGE_KEYS = {
    brightness: "yt-video-dimmer-brightness",
    contrast: "yt-video-dimmer-contrast",
    interval: "yt-video-dimmer-interval",
    keybind: "yt-video-dimmer-cycle-keybind",
    showKeybind: "yt-video-dimmer-show-keybind",
    extraBrightness: "yt-video-dimmer-extra-brightness",
  };

  const MIN_BRIGHTNESS_PCT = 20;
  const DEFAULT_PCT = 100; // used by double-click reset, and as the cycle hotkey's cap
  const CYCLE_MAX_PCT = DEFAULT_PCT;
  const CONTRAST_MIN_PCT = 50;
  const CONTRAST_MAX_PCT = 150;
  const INTERVAL_MIN_PCT = 5;
  const INTERVAL_MAX_PCT = 70;

  let currentBrightness = 1.0; // 0.2 - 1.0 (up to 2.0 if Extra brightness is enabled)
  let currentContrast = 1.0; // 0.5 - 1.5
  let cycleInterval = 20; // percent, used by the cycle hotkey
  let extraBrightnessEnabled = false; // when true, slider range extends to 200%

  function getMaxBrightnessPct() {
    return extraBrightnessEnabled ? 200 : CYCLE_MAX_PCT;
  }
  let cycleKeybind = {
    key: "d",
    shiftKey: true,
    ctrlKey: false,
    altKey: false,
    metaKey: false,
  };
  let showKeybind = {
    key: "s",
    shiftKey: true,
    ctrlKey: false,
    altKey: false,
    metaKey: false,
  };
  let capturingKeybindTarget = null; // null | 'cycle' | 'show'

  // ---------- Styles ----------
  const style = document.createElement("style");
  style.id = "yt-video-dimmer-styles";
  style.appendChild(
    document.createTextNode(`
        video.html5-main-video,
        [data-a-target="video-player"] video,
        [data-a-target="player-overlay-click-handler"] video,
        .video-player__container video {
            filter: brightness(var(--yt-dimmer-b, 1.0)) contrast(var(--yt-dimmer-c, 1.0)) !important;
            transition: filter 0.15s ease-in-out;
        }
        #yt-dimmer-slider-panel {
            position: absolute;
            top: 60px;
            right: 20px;
            background: rgba(28, 28, 28, 0.92);
            color: #f1f1f1;
            padding: 8px 12px;
            border-radius: 6px;
            font-family: Roboto, Arial, sans-serif;
            font-size: 12px;
            z-index: 9999;
            display: flex;
            flex-direction: column;
            gap: 6px;
            width: 220px;
            box-sizing: border-box;
            overflow: hidden;
            opacity: 0;
            pointer-events: none;
            transition: opacity 0.2s ease-in-out;
            border: 1px solid rgba(255,255,255,0.1);
        }
        #yt-dimmer-slider-panel.visible {
            opacity: 1;
            pointer-events: auto;
        }
        .yt-dimmer-row {
            display: flex;
            align-items: center;
            gap: 8px;
            width: 100%;
            box-sizing: border-box;
        }
        .yt-dimmer-row-label {
            flex: 0 0 60px;
            color: #ccc;
        }
        .yt-dimmer-row input[type="range"] {
            flex: 1 1 auto;
            min-width: 0;
            accent-color: #3ea6ff;
            cursor: pointer;
        }
        .yt-dimmer-row input[type="number"] {
            width: 50px;
            background: rgba(255,255,255,0.08);
            border: 1px solid rgba(255,255,255,0.15);
            border-radius: 3px;
            color: #f1f1f1;
            padding: 2px 4px;
            font-size: 12px;
            box-sizing: border-box;
        }
        .yt-dimmer-slider-value {
            flex: 0 0 32px;
            text-align: right;
            font-weight: bold;
        }
        .yt-dimmer-row input[type="checkbox"] {
            flex: 0 0 auto;
            accent-color: #3ea6ff;
            cursor: pointer;
        }
        .yt-dimmer-hint {
            flex: 1;
            color: #999;
            font-size: 10.5px;
            line-height: 1.3;
        }
        #yt-dimmer-advanced-toggle {
            background: none;
            border: none;
            color: #9ab8d6;
            font-size: 11px;
            cursor: pointer;
            padding: 2px 0;
            text-align: left;
            opacity: 0.85;
        }
        #yt-dimmer-advanced-toggle:hover {
            opacity: 1;
        }
        #yt-dimmer-advanced-content {
            display: none;
            flex-direction: column;
            gap: 6px;
            padding-top: 2px;
        }
        #yt-dimmer-advanced-content.expanded {
            display: flex;
        }
        #yt-dimmer-keybind-display, .yt-dimmer-keybind-display {
            flex: 1;
            font-family: monospace;
            background: rgba(255,255,255,0.08);
            border-radius: 3px;
            padding: 2px 6px;
        }
        #yt-dimmer-keybind-change, .yt-dimmer-keybind-change {
            background: rgba(255,255,255,0.1);
            border: 1px solid rgba(255,255,255,0.2);
            color: #f1f1f1;
            border-radius: 3px;
            font-size: 11px;
            padding: 3px 8px;
            cursor: pointer;
        }
        #yt-dimmer-keybind-change:hover, .yt-dimmer-keybind-change:hover {
            background: rgba(255,255,255,0.18);
        }
    `),
  );

  const injectStyles = () => {
    if (document.head) {
      document.head.appendChild(style);
    } else {
      const observer = new MutationObserver(() => {
        if (document.head) {
          document.head.appendChild(style);
          observer.disconnect();
        }
      });
      observer.observe(document.documentElement, { childList: true });
    }
  };
  injectStyles();

  function saveToStorage(key, value) {
    try {
      localStorage.setItem(key, value);
    } catch (e) {
      /* ignore storage errors */
    }
  }

  function getPlayer() {
    // YouTube's player container
    const ytPlayer = document.querySelector("#movie_player");
    if (ytPlayer) return ytPlayer;

    // Twitch's player container (class names are hashed/unstable, so we
    // anchor off the video element itself and walk up to a positioned ancestor)
    const twitchVideo = document.querySelector(
      '[data-a-target="video-player"] video, [data-a-target="player-overlay-click-handler"] video, .video-player__container video',
    );
    if (twitchVideo) {
      const container = twitchVideo.closest(
        '[data-a-target="player-overlay-click-handler"], .video-player__container, [data-a-target="video-player"]',
      );
      if (container) return container;
    }

    return document.body;
  }

  // ---------- Apply brightness / contrast ----------
  function applyBrightness(brightness, save = true) {
    currentBrightness = brightness;
    document.documentElement.style.setProperty(
      "--yt-dimmer-b",
      brightness.toFixed(2),
    );
    if (save) {
      saveToStorage(STORAGE_KEYS.brightness, brightness.toFixed(2));
    }
    if (sliderInput) {
      sliderInput.value = String(Math.round(brightness * 100));
      sliderValueLabel.textContent = `${sliderInput.value}%`;
    }
  }

  function applyContrast(contrast, save = true) {
    currentContrast = contrast;
    document.documentElement.style.setProperty(
      "--yt-dimmer-c",
      contrast.toFixed(2),
    );
    if (save) {
      saveToStorage(STORAGE_KEYS.contrast, contrast.toFixed(2));
    }
    if (contrastInput) {
      contrastInput.value = String(Math.round(contrast * 100));
      contrastValueLabel.textContent = `${contrastInput.value}%`;
    }
  }

  // Steps brightness down by cycleInterval%, wrapping back to 100% once it
  // would drop below the minimum.
  function cycleBrightness() {
    let pct = Math.round(currentBrightness * 100);
    if (pct > CYCLE_MAX_PCT) {
      pct = CYCLE_MAX_PCT; // snap down to the cap first, even with Extra brightness on
    } else {
      pct -= cycleInterval;
      if (pct < MIN_BRIGHTNESS_PCT) pct = CYCLE_MAX_PCT;
    }
    applyBrightness(pct / 100);
    showSliderPanel();
  }

  // Wires up double-click-to-reset, live dragging, and save-on-release for a
  // percentage range input. applyFn must accept (fraction, save = true).
  function setupRangeControl(input, valueLabel, applyFn, storageKey) {
    input.addEventListener("dblclick", () => {
      applyFn(DEFAULT_PCT / 100);
      resetSliderHideTimer();
    });
    input.addEventListener("input", () => {
      const pct = parseInt(input.value, 10);
      valueLabel.textContent = `${pct}%`;
      applyFn(pct / 100, false);
      resetSliderHideTimer();
    });
    input.addEventListener("change", () => {
      saveToStorage(storageKey, (parseInt(input.value, 10) / 100).toFixed(2));
    });
  }

  // ---------- Keybind helpers ----------
  function formatKeybind(binding) {
    const parts = [];
    if (binding.ctrlKey) parts.push("Ctrl");
    if (binding.altKey) parts.push("Alt");
    if (binding.shiftKey) parts.push("Shift");
    if (binding.metaKey) parts.push("Meta");
    let keyLabel = binding.key;
    if (keyLabel.length === 1) keyLabel = keyLabel.toUpperCase();
    parts.push(keyLabel);
    return parts.join("+");
  }

  function matchesKeybind(e, binding) {
    if (!binding) return false;
    const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
    return (
      key === binding.key &&
      e.shiftKey === !!binding.shiftKey &&
      e.ctrlKey === !!binding.ctrlKey &&
      e.altKey === !!binding.altKey &&
      e.metaKey === !!binding.metaKey
    );
  }

  function updateKeybindUI() {
    if (keybindDisplay)
      keybindDisplay.textContent = formatKeybind(cycleKeybind);
    if (keybindChangeBtn) keybindChangeBtn.textContent = "Change";
    if (showKeybindDisplay)
      showKeybindDisplay.textContent = formatKeybind(showKeybind);
    if (showKeybindChangeBtn) showKeybindChangeBtn.textContent = "Change";
    resetSliderHideTimer();
  }

  // ---------- Slider panel ----------
  let sliderPanel, sliderInput, sliderValueLabel, sliderHideTimeout;
  let contrastInput, contrastValueLabel;
  let intervalInput;
  let keybindDisplay, keybindChangeBtn;
  let showKeybindDisplay, showKeybindChangeBtn;
  let advancedContent;

  function buildSliderPanel() {
    const player = getPlayer();

    sliderPanel = document.createElement("div");
    sliderPanel.id = "yt-dimmer-slider-panel";

    // --- Brightness row ---
    const brightnessRow = document.createElement("div");
    brightnessRow.className = "yt-dimmer-row";

    sliderInput = document.createElement("input");
    sliderInput.type = "range";
    sliderInput.min = String(MIN_BRIGHTNESS_PCT);
    sliderInput.max = String(getMaxBrightnessPct());
    sliderInput.step = "1";
    sliderInput.value = String(Math.round(currentBrightness * 100));

    sliderValueLabel = document.createElement("span");
    sliderValueLabel.className = "yt-dimmer-slider-value";
    sliderValueLabel.textContent = `${sliderInput.value}%`;

    setupRangeControl(
      sliderInput,
      sliderValueLabel,
      applyBrightness,
      STORAGE_KEYS.brightness,
    );

    brightnessRow.append(sliderInput, sliderValueLabel);

    // --- Advanced toggle ---
    const advancedToggle = document.createElement("button");
    advancedToggle.type = "button";
    advancedToggle.id = "yt-dimmer-advanced-toggle";
    advancedToggle.textContent = "Advanced \u25be";

    advancedContent = document.createElement("div");
    advancedContent.id = "yt-dimmer-advanced-content";

    advancedToggle.addEventListener("click", () => {
      const expanded = advancedContent.classList.toggle("expanded");
      advancedToggle.textContent = expanded
        ? "Advanced \u25b4"
        : "Advanced \u25be";
      resetSliderHideTimer();
    });

    // --- Contrast row ---
    const contrastRow = document.createElement("div");
    contrastRow.className = "yt-dimmer-row";

    const contrastLabel = document.createElement("span");
    contrastLabel.className = "yt-dimmer-row-label";
    contrastLabel.textContent = "Contrast";

    contrastInput = document.createElement("input");
    contrastInput.type = "range";
    contrastInput.min = String(CONTRAST_MIN_PCT);
    contrastInput.max = String(CONTRAST_MAX_PCT);
    contrastInput.step = "1";
    contrastInput.value = String(Math.round(currentContrast * 100));

    contrastValueLabel = document.createElement("span");
    contrastValueLabel.className = "yt-dimmer-slider-value";
    contrastValueLabel.textContent = `${contrastInput.value}%`;

    setupRangeControl(
      contrastInput,
      contrastValueLabel,
      applyContrast,
      STORAGE_KEYS.contrast,
    );

    contrastRow.append(contrastLabel, contrastInput, contrastValueLabel);

    // --- Cycle step row ---
    const intervalRow = document.createElement("div");
    intervalRow.className = "yt-dimmer-row";

    const intervalLabel = document.createElement("span");
    intervalLabel.className = "yt-dimmer-row-label";
    intervalLabel.textContent = "Cycle step";

    intervalInput = document.createElement("input");
    intervalInput.type = "number";
    intervalInput.min = String(INTERVAL_MIN_PCT);
    intervalInput.max = String(INTERVAL_MAX_PCT);
    intervalInput.step = "5";
    intervalInput.value = String(cycleInterval);

    const intervalUnit = document.createElement("span");
    intervalUnit.className = "yt-dimmer-slider-value";
    intervalUnit.textContent = "%";

    intervalInput.addEventListener("change", () => {
      let val = parseInt(intervalInput.value, 10);
      if (isNaN(val) || val < INTERVAL_MIN_PCT) val = INTERVAL_MIN_PCT;
      if (val > INTERVAL_MAX_PCT) val = INTERVAL_MAX_PCT;
      intervalInput.value = String(val);
      cycleInterval = val;
      saveToStorage(STORAGE_KEYS.interval, String(val));
      resetSliderHideTimer();
    });

    intervalRow.append(intervalLabel, intervalInput, intervalUnit);

    // --- Keybind row ---
    const keybindRow = document.createElement("div");
    keybindRow.className = "yt-dimmer-row";

    const keybindLabel = document.createElement("span");
    keybindLabel.className = "yt-dimmer-row-label";
    keybindLabel.textContent = "Cycle key";

    keybindDisplay = document.createElement("span");
    keybindDisplay.id = "yt-dimmer-keybind-display";
    keybindDisplay.textContent = formatKeybind(cycleKeybind);

    keybindChangeBtn = document.createElement("button");
    keybindChangeBtn.type = "button";
    keybindChangeBtn.id = "yt-dimmer-keybind-change";
    keybindChangeBtn.textContent = "Change";
    keybindChangeBtn.addEventListener("click", () => {
      capturingKeybindTarget = "cycle";
      keybindChangeBtn.textContent = "Press a key\u2026";
      keybindDisplay.textContent = "\u2026";
      if (sliderHideTimeout) clearTimeout(sliderHideTimeout);
    });

    keybindRow.append(keybindLabel, keybindDisplay, keybindChangeBtn);

    // --- Show key row ---
    const showKeybindRow = document.createElement("div");
    showKeybindRow.className = "yt-dimmer-row";

    const showKeybindLabel = document.createElement("span");
    showKeybindLabel.className = "yt-dimmer-row-label";
    showKeybindLabel.textContent = "Show key";

    showKeybindDisplay = document.createElement("span");
    showKeybindDisplay.id = "yt-dimmer-show-keybind-display";
    showKeybindDisplay.className = "yt-dimmer-keybind-display";
    showKeybindDisplay.textContent = formatKeybind(showKeybind);

    showKeybindChangeBtn = document.createElement("button");
    showKeybindChangeBtn.type = "button";
    showKeybindChangeBtn.className = "yt-dimmer-keybind-change";
    showKeybindChangeBtn.textContent = "Change";
    showKeybindChangeBtn.addEventListener("click", () => {
      capturingKeybindTarget = "show";
      showKeybindChangeBtn.textContent = "Press a key\u2026";
      showKeybindDisplay.textContent = "\u2026";
      if (sliderHideTimeout) clearTimeout(sliderHideTimeout);
    });

    showKeybindRow.append(
      showKeybindLabel,
      showKeybindDisplay,
      showKeybindChangeBtn,
    );

    // --- Extra brightness toggle row ---
    const extraBrightnessRow = document.createElement("div");
    extraBrightnessRow.className = "yt-dimmer-row";

    const extraBrightnessLabel = document.createElement("span");
    extraBrightnessLabel.className = "yt-dimmer-row-label";
    extraBrightnessLabel.textContent = "Extra bright";

    const extraBrightnessToggle = document.createElement("input");
    extraBrightnessToggle.type = "checkbox";
    extraBrightnessToggle.id = "yt-dimmer-extra-brightness-toggle";
    extraBrightnessToggle.checked = extraBrightnessEnabled;

    const extraBrightnessHint = document.createElement("span");
    extraBrightnessHint.className = "yt-dimmer-hint";
    extraBrightnessHint.textContent = "up to 200%";

    extraBrightnessToggle.addEventListener("change", () => {
      extraBrightnessEnabled = extraBrightnessToggle.checked;
      try {
        localStorage.setItem(
          STORAGE_KEYS.extraBrightness,
          extraBrightnessEnabled ? "1" : "0",
        );
      } catch (e) {
        /* ignore */
      }
      if (sliderInput) {
        sliderInput.max = String(getMaxBrightnessPct());
        if (!extraBrightnessEnabled && currentBrightness > 1.0) {
          applyBrightness(1.0);
        }
      }
      resetSliderHideTimer();
    });

    extraBrightnessRow.append(
      extraBrightnessLabel,
      extraBrightnessToggle,
      extraBrightnessHint,
    );

    advancedContent.append(
      contrastRow,
      intervalRow,
      keybindRow,
      showKeybindRow,
      extraBrightnessRow,
    );

    sliderPanel.append(brightnessRow, advancedToggle, advancedContent);
    player.appendChild(sliderPanel);
  }

  function resetSliderHideTimer() {
    if (sliderHideTimeout) clearTimeout(sliderHideTimeout);
    if (capturingKeybindTarget) return; // don't hide mid key-capture
    if (advancedContent && advancedContent.classList.contains("expanded"))
      return; // keep open while tweaking advanced settings
    sliderHideTimeout = setTimeout(hideSliderPanel, 4000);
  }

  function showSliderPanel() {
    if (!sliderPanel) buildSliderPanel();
    const player = getPlayer();
    if (sliderPanel.parentElement !== player) player.appendChild(sliderPanel);
    sliderPanel.classList.add("visible");
    resetSliderHideTimer();
  }

  function hideSliderPanel() {
    if (sliderPanel) sliderPanel.classList.remove("visible");
  }

  function toggleSliderPanel() {
    if (sliderPanel && sliderPanel.classList.contains("visible")) {
      hideSliderPanel();
    } else {
      showSliderPanel();
    }
  }

  // ---------- Restore saved settings on load ----------
  (function restoreSaved() {
    try {
      const savedExtraBrightness = localStorage.getItem(
        STORAGE_KEYS.extraBrightness,
      );
      if (savedExtraBrightness !== null) {
        extraBrightnessEnabled = savedExtraBrightness === "1";
      }

      const savedBrightness = localStorage.getItem(STORAGE_KEYS.brightness);
      if (savedBrightness !== null) {
        const val = parseFloat(savedBrightness);
        if (!isNaN(val)) {
          const maxAllowed = getMaxBrightnessPct() / 100;
          const clamped = Math.min(
            maxAllowed,
            Math.max(MIN_BRIGHTNESS_PCT / 100, val),
          );
          currentBrightness = clamped;
          document.documentElement.style.setProperty(
            "--yt-dimmer-b",
            clamped.toFixed(2),
          );
        }
      }

      const savedContrast = localStorage.getItem(STORAGE_KEYS.contrast);
      if (savedContrast !== null) {
        const val = parseFloat(savedContrast);
        if (!isNaN(val) && val >= 0.5 && val <= 1.5) {
          currentContrast = val;
          document.documentElement.style.setProperty(
            "--yt-dimmer-c",
            val.toFixed(2),
          );
        }
      }

      const savedInterval = localStorage.getItem(STORAGE_KEYS.interval);
      if (savedInterval !== null) {
        const val = parseInt(savedInterval, 10);
        if (!isNaN(val) && val >= 5 && val <= 70) cycleInterval = val;
      }

      const savedKeybind = localStorage.getItem(STORAGE_KEYS.keybind);
      if (savedKeybind !== null) {
        const parsed = JSON.parse(savedKeybind);
        if (parsed && typeof parsed.key === "string") {
          cycleKeybind = {
            key: parsed.key,
            shiftKey: !!parsed.shiftKey,
            ctrlKey: !!parsed.ctrlKey,
            altKey: !!parsed.altKey,
            metaKey: !!parsed.metaKey,
          };
        }
      }

      const savedShowKeybind = localStorage.getItem(STORAGE_KEYS.showKeybind);
      if (savedShowKeybind !== null) {
        const parsed = JSON.parse(savedShowKeybind);
        if (parsed && typeof parsed.key === "string") {
          showKeybind = {
            key: parsed.key,
            shiftKey: !!parsed.shiftKey,
            ctrlKey: !!parsed.ctrlKey,
            altKey: !!parsed.altKey,
            metaKey: !!parsed.metaKey,
          };
        }
      }
    } catch (e) {
      /* ignore */
    }
  })();

  // ---------- Hotkeys ----------
  window.addEventListener(
    "keydown",
    (e) => {
      // Capturing a new keybind after the user clicked "Change" on either row
      if (capturingKeybindTarget) {
        if (["Shift", "Control", "Alt", "Meta"].includes(e.key)) return; // wait for a real key
        e.preventDefault();
        e.stopPropagation();
        const binding = {
          key: e.key.length === 1 ? e.key.toLowerCase() : e.key,
          shiftKey: e.shiftKey,
          ctrlKey: e.ctrlKey,
          altKey: e.altKey,
          metaKey: e.metaKey,
        };
        if (capturingKeybindTarget === "cycle") {
          cycleKeybind = binding;
          try {
            localStorage.setItem(STORAGE_KEYS.keybind, JSON.stringify(binding));
          } catch (err) {
            /* ignore */
          }
        } else if (capturingKeybindTarget === "show") {
          showKeybind = binding;
          try {
            localStorage.setItem(
              STORAGE_KEYS.showKeybind,
              JSON.stringify(binding),
            );
          } catch (err) {
            /* ignore */
          }
        }
        capturingKeybindTarget = null;
        updateKeybindUI();
        return;
      }

      const active = document.activeElement;
      if (
        active &&
        (active.tagName === "INPUT" ||
          active.tagName === "TEXTAREA" ||
          active.isContentEditable)
      ) {
        return;
      }

      if (matchesKeybind(e, cycleKeybind)) {
        e.preventDefault();
        e.stopPropagation();
        cycleBrightness();
        return;
      }

      if (matchesKeybind(e, showKeybind)) {
        e.preventDefault();
        e.stopPropagation();
        toggleSliderPanel();
      }
    },
    true,
  );
})();