TesterTV_YouTube_Effects

Add video effects with persistent sliders

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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();
})();