TesterTV_YouTube_Effects

Add video effects with persistent sliders

// ==UserScript==
// @name         TesterTV_YouTube_Effects
// @namespace    https://greasyfork.org/ru/scripts/482237-testertv-youtube-effects
// @version      2025.08.30
// @description  Add video effects with persistent sliders
// @license      GPL-3.0-or-later
// @author       TesterTV
// @match        https://www.youtube.com/*
// @match        https://m.youtube.com/*
// @match        https://music.youtube.com/*
// @grant        GM_openInTab
// ==/UserScript==

(() => {
  'use strict';

  if (window.top !== window) return; // no iframes

  const STORAGE_KEY = 'TesterTV_YouTube_Effects_state_v2';

  const FILTERS = [
    { id: 'blur',       css: 'blur',       label: 'Blur',         min: 0,    max: 50,   step: 1,   default: 0,   unit: 'px'  },
    { id: 'brightness', css: 'brightness', label: 'Brightness',   min: 0,    max: 200,  step: 1,   default: 100, unit: '%'   },
    { id: 'contrast',   css: 'contrast',   label: 'Contrast',     min: 0,    max: 200,  step: 1,   default: 100, unit: '%'   },
    { id: 'grayscale',  css: 'grayscale',  label: 'Grayscale',    min: 0,    max: 100,  step: 1,   default: 0,   unit: '%'   },
    { id: 'hue',        css: 'hue-rotate', label: 'Hue Rotate',   min: 0,    max: 360,  step: 1,   default: 0,   unit: 'deg' },
    { id: 'invert',     css: 'invert',     label: 'Invert Color', min: 0,    max: 100,  step: 1,   default: 0,   unit: '%'   },
    { id: 'saturate',   css: 'saturate',   label: 'Saturation',   min: 0,    max: 200,  step: 1,   default: 100, unit: '%'   },
    { id: 'sepia',      css: 'sepia',      label: 'Sepia',        min: 0,    max: 100,  step: 1,   default: 0,   unit: '%'   },
  ];

  const TRANSFORMS = [
    { id: 'rotate',  css: 'rotate',     label: 'Rotation',   min: 0,     max: 360,  step: 1,   default: 0,  unit: 'deg' },
    { id: 'tx',      css: 'translateX', label: 'TranslateX', min: -7680, max: 7680, step: 1,   default: 0,  unit: 'px'  },
    { id: 'ty',      css: 'translateY', label: 'TranslateY', min: -4320, max: 4320, step: 1,   default: 0,  unit: 'px'  },
    { id: 'scale',   css: 'scale',      label: 'Scale',      min: 1,     max: 10,   step: 0.1, default: 1,  unit: ''    },
    { id: 'scaleX',  css: 'scaleX',     label: 'ScaleX',     min: -1,    max: 10,   step: 0.1, default: 1,  unit: ''    },
    { id: 'scaleY',  css: 'scaleY',     label: 'ScaleY',     min: -1,    max: 10,   step: 0.1, default: 1,  unit: ''    },
  ];

  const ALL = [...FILTERS, ...TRANSFORMS];
  const DEFAULTS = Object.fromEntries(ALL.map(s => [s.id, s.default]));
  const state = loadState();
  ensureStyle();

  function loadState() {
    try {
      const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
      return { ...DEFAULTS, ...saved };
    } catch {
      return { ...DEFAULTS };
    }
  }

  function saveState() {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
  }

  function buildCSS() {
    const filter = FILTERS.map(f => `${f.css}(${state[f.id]}${f.unit})`).join(' ');
    const transform = TRANSFORMS.map(t => `${t.css}(${state[t.id]}${t.unit})`).join(' ');
    // Apply to all <video> elements; !important to win over site styles
    return `
      video {
        filter: ${filter} !important;
        transform: ${transform} !important;
        transform-origin: center center !important;
      }
    `;
  }

  function ensureStyle() {
    let s = document.getElementById('ttv-effects-style');
    if (!s) {
      s = document.createElement('style');
      s.id = 'ttv-effects-style';
      document.head.appendChild(s);
    }
    s.textContent = buildCSS();
  }

  function createSliderRow(cfg) {
    const row = document.createElement('div');
    row.style.cssText = 'display:flex;align-items:center;gap:10px;margin:6px 0;';

    const label = document.createElement('label');
    label.textContent = cfg.label;
    label.style.cssText = 'width:110px;color:#fff;';
    label.htmlFor = `ttv-${cfg.id}`;

    const input = document.createElement('input');
    input.type = 'range';
    input.id = `ttv-${cfg.id}`;
    input.min = cfg.min;
    input.max = cfg.max;
    input.step = cfg.step;
    input.value = state[cfg.id];
    input.style.cssText = 'flex:1;';

    input.addEventListener('input', () => {
      state[cfg.id] = Number(input.value);
      ensureStyle();
      saveState();
    });

    row.append(label, input);
    return row;
  }

  function createPanel() {
    if (document.getElementById('ttv-effects-panel')) {
      return document.getElementById('ttv-effects-panel');
    }

    const panel = document.createElement('div');
    panel.id = 'ttv-effects-panel';
    panel.style.cssText = [
      'position:fixed',
      'top:50%',
      'left:50%',
      'transform:translate(-50%,-50%)',
      'background:rgba(0,0,0,.75)',
      'border:1px solid #555',
      'border-radius:8px',
      'padding:12px 14px',
      'z-index:999999',
      'color:#fff',
      'min-width:380px',
      'max-width:520px',
      'backdrop-filter:blur(3px)',
    ].join(';');

    const title = document.createElement('div');
    title.textContent = 'Effects';
    title.style.cssText = 'font-weight:700;font-size:18px;margin-bottom:8px;text-decoration:underline;';
    panel.appendChild(title);

    // Sliders
    ALL.forEach(cfg => panel.appendChild(createSliderRow(cfg)));

    // Buttons
    const btns = document.createElement('div');
    btns.style.cssText = 'display:flex;gap:10px;justify-content:center;margin-top:12px;';

    const resetBtn = document.createElement('button');
    resetBtn.textContent = 'Reset';
    resetBtn.style.cssText = 'padding:6px 12px;border:1px solid #888;border-radius:6px;background:#fff;color:#222;cursor:pointer;';
    resetBtn.addEventListener('click', () => {
      Object.assign(state, DEFAULTS);
      panel.querySelectorAll('input[type="range"]').forEach(inp => {
        const id = inp.id.replace('ttv-', '');
        inp.value = state[id];
      });
      ensureStyle();
      saveState();
    });

    const donateBtn = document.createElement('button');
    donateBtn.textContent = '💳 Please support me';
    donateBtn.style.cssText = 'padding:6px 12px;border:1px solid #888;border-radius:6px;background:#fff;color:#222;cursor:pointer;';
    donateBtn.addEventListener('click', () => {
      if (typeof GM_openInTab === 'function') {
        GM_openInTab('https://greasyfork.org/ru/scripts/482237-testertv-youtube-effects');
      } else {
        window.open('https://greasyfork.org/ru/scripts/482237-testertv-youtube-effects', '_blank');
      }
    });

    btns.append(resetBtn, donateBtn);
    panel.appendChild(btns);

    panel.style.display = 'none';
    document.body.appendChild(panel);

    // Hide when clicking outside
    document.addEventListener('click', (e) => {
      const btn = document.getElementById('ttv-effects-btn');
      if (!panel.contains(e.target) && e.target !== btn) panel.style.display = 'none';
    }, { capture: true });

    return panel;
  }

function addButton() {
  const right = document.querySelector('.ytp-right-controls');
  const chrome = right && right.parentElement; // .ytp-chrome-controls
  if (!chrome || !right || document.getElementById('ttv-effects-btn')) return;

  const btn = document.createElement('button');
  btn.id = 'ttv-effects-btn';
  btn.className = 'ytp-button';
  btn.textContent = '🎛️';
  btn.title = 'Video effects';
  btn.setAttribute('aria-label', 'Video effects');
  btn.style.fontSize = '20px';
  btn.style.borderRadius = '5%'; // override any previous '6px'

  Object.assign(btn.style, {
    background: 'none',
    border: '2px solid transparent',
    color: 'inherit',
    margin: '0 8px',
    width: '36px',
    height: '36px',
    boxSizing: 'border-box',     // border doesn't change size
    display: 'inline-block',     // match YT buttons
    verticalAlign: 'middle',
    lineHeight: '36px',          // centers emoji inside
    textAlign: 'center',
    cursor: 'pointer',
    alignSelf: 'center',         // KEY: center within the flex parent
  });

  btn.addEventListener('click', (e) => {
    e.stopPropagation();
    const panel = createPanel();
    panel.style.display = (!panel.style.display || panel.style.display === 'none') ? 'block' : 'none';
    btn.style.borderColor = '#74e3ff';
    setTimeout(() => (btn.style.borderColor = 'transparent'), 200);
  });
  btn.addEventListener('mouseenter', () => (btn.style.borderColor = '#74e3ff'));
  btn.addEventListener('mouseleave', () => (btn.style.borderColor = 'transparent'));

  // Left of the entire right-controls cluster
  chrome.insertBefore(btn, right);
}
  // Bootstrap
  function start() {
    ensureStyle();     // Apply saved values immediately
    addButton();       // Try once now
    setInterval(addButton, 1000); // Handle SPA navigation / player reloads
  }

  start();
})();