Twitch Low-Latency Catch-Up

Enjoy a smoother, truly live Twitch experience! This script intelligently manages playback speed to eliminate frustrating lag, keeping you in the moment. Comes with a simple on-screen menu to customize your settings.

Versione datata 26/09/2025. Vedi la nuova versione l'ultima versione.

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         Twitch Low-Latency Catch-Up
// @version      1.1
// @description  Enjoy a smoother, truly live Twitch experience! This script intelligently manages playback speed to eliminate frustrating lag, keeping you in the moment. Comes with a simple on-screen menu to customize your settings.
// @author       Mattskiiau
// @license      GNU GPLv3
// @match        https://www.twitch.tv/*
// @match        https://player.twitch.tv/*
// @grant        none
// @run-at       document-start
// @namespace https://greatest.deepsurf.us/users/1519406
// ==/UserScript==
(function () {
  'use strict';

  const DEFAULTS = {
    targetLag: 2.5,
    maxBoost: 1.03,
    enabled: true,
    checkMs: 100,
    rateEpsilon: 0.003,
    bufferSafety: 1.5,
    rateStepUp: 0.05,
    rateStepDown: 0.05,
    rateSmoothFactor: 0.55,
    rateMinStep: 0.01,
    normalizeLag: 1.25, // This will be auto-calculated
  };

  let SETTINGS = { ...DEFAULTS };

  const LS_KEYS = ['llc-v3.0-settings', 'llc-v2.0-settings', 'llc-v1.9-settings', 'llc-v1.8-settings', 'llc-v1.7-settings', 'llc-v1.6-settings', 'llc-v1.5-settings', 'llc-v1.4-settings','llc-v1.3-settings','llc-v1.2-settings','llc-v1.1'];
  const UI_KEYS = ['llc-v3.0-ui', 'llc-v2.0-ui', 'llc-v1.9-ui', 'llc-v1.8-ui', 'llc-v1.7-ui', 'llc-v1.6-ui', 'llc-v1.5-ui', 'llc-v1.4-ui','llc-v1.3-ui','llc-v1.2-ui'];
  const PANEL_ID = 'llc30';
  const VIDEO_SCAN_INTERVAL = 2000;

  let activeVideo = null;
  let cachedVideos = [];
  let lastVideoScan = 0;
  const panelRefs = { root: null, body: null, lag: null, rate: null, minBtn: null };
  let rateEstimate = 1;

  function resetPanelRefs() {
    panelRefs.root = panelRefs.body = panelRefs.lag = panelRefs.rate = panelRefs.minBtn = null;
  }

  function load() {
    let loadedSettings = null;
    for (const k of LS_KEYS) {
      try {
        const s = JSON.parse(localStorage.getItem(k) || 'null');
        if (s && typeof s === 'object') {
          loadedSettings = s;
          break;
        }
      } catch (_) {}
    }
    if (loadedSettings) {
        SETTINGS = Object.assign({}, DEFAULTS, loadedSettings);
    }
  }

  function save() {
    try {
      localStorage.setItem(LS_KEYS[0], JSON.stringify(SETTINGS));
    } catch (_) {}
  }

  function loadUI() {
    for (const k of UI_KEYS) {
      try {
        const v = JSON.parse(localStorage.getItem(k) || 'null');
        if (v) return v;
      } catch (_) {}
    }
    return {};
  }

  function saveUIState(obj) {
    try {
      localStorage.setItem(UI_KEYS[0], JSON.stringify(obj));
    } catch (_) {}
  }

  function log(...a) {
    if (SETTINGS.debug) console.log('[LLC]', ...a);
  }

  function collectVideos(root) {
    const out = [];
    try {
      const walker = document.createTreeWalker(root || document, NodeFilter.SHOW_ELEMENT);
      let n = walker.currentNode;
      while (n) {
        if (n.tagName === 'VIDEO') out.push(n);
        if (n.shadowRoot) out.push(...collectVideos(n.shadowRoot));
        if (n.tagName === 'IFRAME') {
          try {
            if (n.contentDocument) out.push(...collectVideos(n.contentDocument));
          } catch (_) {}
        }
        n = walker.nextNode();
      }
    } catch (_) {}
    return out;
  }

  function isRectVisible(rect) {
    return rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.right > 0 && rect.left < innerWidth && rect.top < innerHeight;
  }

  function isCandidate(video) {
    return !!video && video.isConnected && video.readyState >= 2;
  }

  function refreshVideos(force = false) {
    const now = Date.now();
    if (!force && now - lastVideoScan < VIDEO_SCAN_INTERVAL) {
      cachedVideos = cachedVideos.filter(isCandidate);
      if (!cachedVideos.includes(activeVideo)) activeVideo = null;
      if (cachedVideos.length) return cachedVideos;
    }
    lastVideoScan = now;
    cachedVideos = collectVideos(document).filter(isCandidate);
    if (!cachedVideos.includes(activeVideo)) activeVideo = null;
    return cachedVideos;
  }

  function selectBestVideo(list) {
    let best = null;
    let bestScore = -1;
    for (const v of list) {
      const rect = v.getBoundingClientRect();
      if (!isRectVisible(rect)) continue;
      const score = rect.width * rect.height;
      if (score > bestScore) {
        bestScore = score;
        best = v;
      }
    }
    return best;
  }

  function pickActiveVideo() {
    if (isCandidate(activeVideo)) {
      const rect = activeVideo.getBoundingClientRect();
      if (isRectVisible(rect)) return activeVideo;
      activeVideo = null;
    }

    let vids = refreshVideos(false);
    let best = selectBestVideo(vids);
    if (!best) {
      vids = refreshVideos(true);
      best = selectBestVideo(vids);
    }
    if (best) activeVideo = best;
    return best;
  }

  function isAdPlaying() {
    return !!(document.querySelector('[data-a-target="video-ad-label"]') || document.querySelector('.ad-banner, .player-ad-banner, .video-player__ad-overlay'));
  }

  function isLive(video) {
    if (!video) return false;
    const liveish = !Number.isFinite(video.duration) || (video.seekable && video.seekable.length > 0);
    return liveish;
  }

  function extractLag(range, currentTime) {
    try {
      if (range && range.length) {
        const end = range.end(range.length - 1);
        const lag = end - currentTime;
        if (Number.isFinite(lag) && lag >= -1 && lag < 120) return lag;
      }
    } catch (_) {}
    return NaN;
  }

  function getLag(video) {
    const lagFromSeekable = extractLag(video.seekable, video.currentTime);
    if (Number.isFinite(lagFromSeekable)) return lagFromSeekable;
    return extractLag(video.buffered, video.currentTime);
  }

  function getBufferedAhead(video) {
    try {
      const bf = video && video.buffered;
      if (bf && bf.length) {
        return Math.max(0, bf.end(bf.length - 1) - video.currentTime);
      }
    } catch (_) {}
    return 0;
  }

  function clamp(v, min, max) {
    return Math.max(min, Math.min(max, v));
  }

  function setRate(video, rate) {
    if (!video) return 1;
    const target = clamp(rate, 0.25, SETTINGS.maxBoost);
    if (Math.abs(video.playbackRate - target) > SETTINGS.rateEpsilon) {
      try {
        video.playbackRate = target;
      } catch (_) {}
      try {
        if ('preservesPitch' in video) video.preservesPitch = true;
        if ('mozPreservesPitch' in video) video.mozPreservesPitch = true;
        if ('webkitPreservesPitch' in video) video.webkitPreservesPitch = true;
      } catch (_) {}
      log('rate:', target.toFixed(2));
    }
    rateEstimate = video.playbackRate;
    return video.playbackRate;
  }

  const originalPlaybackRate = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'playbackRate');
  if (originalPlaybackRate) {
    Object.defineProperty(HTMLMediaElement.prototype, 'playbackRate', {
      get() {
        return originalPlaybackRate.get.call(this);
      },
      set(v) {
        try {
          originalPlaybackRate.set.call(this, v);
        } catch (_) {}
      },
    });
  }

  function smoothRate(desired) {
    const current = rateEstimate;
    if (!Number.isFinite(desired)) return current;
    const delta = desired - current;
    if (Math.abs(delta) < SETTINGS.rateMinStep) {
      rateEstimate = clamp(desired, 0.25, SETTINGS.maxBoost);
      return rateEstimate;
    }
    const limit = delta > 0 ? SETTINGS.rateStepUp : SETTINGS.rateStepDown;
    const step = Math.min(Math.abs(delta) * SETTINGS.rateSmoothFactor, limit);
    const next = current + Math.sign(delta) * step;
    rateEstimate = clamp(next, 0.25, SETTINGS.maxBoost);
    return rateEstimate;
  }

  function updatePanelDisplay(lag, rate) {
    if (!SETTINGS.enabled) {
      if (panelRefs.lag) panelRefs.lag.textContent = 'off';
      if (panelRefs.rate) panelRefs.rate.textContent = '1.00×';
      return;
    }
    if (panelRefs.lag) panelRefs.lag.textContent = Number.isFinite(lag) ? `${lag.toFixed(1)} s` : '-- s';
    if (panelRefs.rate) panelRefs.rate.textContent = (Number.isFinite(rate) ? rate : 1).toFixed(2) + '×';
  }

  function panel() {
    if (panelRefs.root && panelRefs.root.isConnected) return;
    if (!document.body) return;

    const el = document.createElement('div');
    el.id = PANEL_ID;
    el.style.cssText = 'position:fixed;z-index:2147483647;background:#111c;color:#eee;border-radius:8px;font:12px system-ui,Segoe UI,Roboto,Arial;backdrop-filter:blur(4px);box-shadow:0 2px 10px rgba(0,0,0,.4);user-select:none;width:240px;';

    const uiState = Object.assign({ left: null, top: null, collapsed: false, advanced: false }, loadUI());

    el.innerHTML = `
      <div id="llc_hdr" style="display:flex;align-items:center;gap:8px;padding:6px 8px;cursor:move">
        <strong style="font-weight:600">Stats:</strong>
        <span id="llc_l" style="opacity:.9">-- s</span>
        <span id="llc_r" style="opacity:.9">1.00×</span>
        <div style="flex:1"></div>
        <button id="llc_min" title="Minimize" style="background:#222;color:#eee;border:0;border-radius:6px;padding:2px 6px;cursor:pointer">–</button>
      </div>
      <div id="llc_body" style="display: flex; flex-direction: column; gap: 8px; padding: 8px;">
        <label style="display:flex; justify-content: space-between; align-items: center;">
            <span>Target Delay:</span>
            <span style="display: flex; align-items: center; gap: 4px;">
                <input data-key="targetLag" type="number" step="0.1" min="0" style="width: 60px; text-align: right; border-radius: 4px; border: 1px solid #555; background: #222; color: #eee;">
                <span style="width: 20px; text-align: left;">s</span>
            </span>
        </label>
        <label style="display:flex; justify-content: space-between; align-items: center;">
            <span>Speed Rate: </span>
            <span style="display: flex; align-items: center; gap: 4px;">
                <input data-key="maxBoost" type="number" step="0.01" min="1" max="5" style="width: 60px; text-align: right; border-radius: 4px; border: 1px solid #555; background: #222; color: #eee;">
                <span style="width: 20px; text-align: left;">×</span>
            </span>
        </label>
        <label style="display:flex; justify-content: space-between; align-items: center;">
            <span>Enabled:</span>
            <input data-key="enabled" type="checkbox">
        </label>
      </div>
      <div id="llc_advanced_body" style="display: none; flex-direction: column; gap: 8px; padding: 8px; border-top: 1px solid #444;">
      </div>
      <div id="llc_footer" style="display:flex; justify-content: space-between; padding: 4px 8px 8px 8px;">
        <button id="llc_advanced_toggle" style="background:none; border:none; color:#aaa; cursor:pointer;">Advanced ▾</button>
        <button id="llc_reset" style="background:none; border:none; color:#aaa; cursor:pointer;">Reset</button>
      </div>
      `;

    document.body.appendChild(el);

    const hdr = el.querySelector('#llc_hdr');
    const body = el.querySelector('#llc_body');
    const advancedBody = el.querySelector('#llc_advanced_body');
    const advancedToggleBtn = el.querySelector('#llc_advanced_toggle');
    const resetBtn = el.querySelector('#llc_reset');
    const footer = el.querySelector('#llc_footer');
    const minBtn = el.querySelector('#llc_min');

    panelRefs.root = el;
    panelRefs.body = body;
    panelRefs.lag = el.querySelector('#llc_l');
    panelRefs.rate = el.querySelector('#llc_r');
    panelRefs.minBtn = minBtn;

    const inputs = {};
    let advancedOpen = uiState.advanced;

    const advancedSettings = {
        checkMs: { min: 50, max: 5000, step: 50, unit: 'ms', title: 'How often the script checks for lag.' },
        rateStepUp: { min: 0.01, max: 1, step: 0.01, unit: 'Δ/s', title: 'Maximum rate increase per second.' },
        rateStepDown: { min: 0.01, max: 1, step: 0.01, unit: 'Δ/s', title: 'Maximum rate decrease per second.' },
        rateSmoothFactor: { min: 0.01, max: 1, step: 0.01, unit: '', title: 'Smoothing factor for rate changes.' },
        bufferSafety: { min: 0, max: 10, step: 0.1, unit: 's', title: 'Minimum video buffer required for max boost.' },
    };

    for (const [key, props] of Object.entries(advancedSettings)) {
        const label = document.createElement('label');
        label.style.cssText = 'display:flex; justify-content: space-between; align-items: center;';
        label.innerHTML = `
            <span style="display: flex; align-items: center; gap: 4px;">
                ${key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
                <span title="${props.title}" style="cursor:help; border: 1px solid #777; border-radius: 50%; width: 14px; height: 14px; display: inline-flex; justify-content: center; align-items: center; font-size: 10px;">i</span>
            </span>
            <span style="display: flex; align-items: center; gap: 4px;">
                <input data-key="${key}" type="number" step="${props.step}" min="${props.min}" max="${props.max}" style="width: 60px; text-align: right; border-radius: 4px; border: 1px solid #555; background: #222; color: #eee;">
                <span style="width: 20px; text-align: left;">${props.unit}</span>
            </span>
        `;
        advancedBody.appendChild(label);
    }

    el.querySelectorAll('[data-key]').forEach(input => {
        const key = input.dataset.key;
        inputs[key] = input;
    });

    function updateUIFromSettings() {
        for (const [key, input] of Object.entries(inputs)) {
            if (input.type === 'checkbox') {
                input.checked = SETTINGS[key];
            } else {
                input.value = SETTINGS[key];
            }
        }
    }

    function persist() {
        for (const [key, input] of Object.entries(inputs)) {
            const value = input.type === 'checkbox' ? input.checked : Number(input.value);
            if (SETTINGS[key] !== value) {
                SETTINGS[key] = value;
            }
        }
        SETTINGS.normalizeLag = SETTINGS.targetLag / 2;
        save();
    }

    el.querySelectorAll('[data-key]').forEach(input => {
        input.addEventListener('input', persist);
    });

    resetBtn.addEventListener('click', () => {
        SETTINGS = { ...DEFAULTS };
        save();
        updateUIFromSettings();
    });

    function setAdvancedVisible(visible) {
        const collapsed = body.style.display === 'none';
        advancedOpen = visible;
        advancedBody.style.display = (!collapsed && visible) ? 'flex' : 'none';
        advancedToggleBtn.textContent = visible ? 'Advanced ▴' : 'Advanced ▾';
        saveUIState({ ...loadUI(), advanced: visible });
    }

    advancedToggleBtn.addEventListener('click', () => setAdvancedVisible(!advancedOpen));

    function setCollapsed(collapsed) {
      body.style.display = collapsed ? 'none' : 'flex';
      advancedBody.style.display = (!collapsed && advancedOpen) ? 'flex' : 'none';
      advancedToggleBtn.textContent = advancedOpen ? 'Advanced ▴' : 'Advanced ▾';
      footer.style.display = collapsed ? 'none' : 'flex';
      minBtn.textContent = collapsed ? '+' : '–';
      saveUIState({ ...loadUI(), collapsed });
    }

    function toggleCollapsed() {
      setCollapsed(body.style.display !== 'none');
    }

    minBtn.addEventListener('click', toggleCollapsed);

    function placeInitial() {
      const rect = el.getBoundingClientRect();
      if (uiState.left == null || uiState.top == null) {
        const left = clamp(innerWidth - rect.width - 12, 0, Math.max(0, innerWidth - rect.width));
        const top = clamp(innerHeight - rect.height - 12, 0, Math.max(0, innerHeight - rect.height));
        el.style.left = left + 'px';
        el.style.top = top + 'px';
      } else {
        el.style.left = clamp(uiState.left, 0, innerWidth - rect.width) + 'px';
        el.style.top = clamp(uiState.top, 0, innerHeight - rect.height) + 'px';
      }
    }

    (function enableDrag() {
      let dragging = false;
      let ox = 0;
      let oy = 0;
      let sx = 0;
      let sy = 0;
      let moved = false;

      hdr.addEventListener('pointerdown', (ev) => {
        dragging = true;
        moved = false;
        hdr.setPointerCapture(ev.pointerId);
        const r = el.getBoundingClientRect();
        ox = ev.clientX;
        oy = ev.clientY;
        sx = r.left;
        sy = r.top;
      });

      hdr.addEventListener('pointermove', (ev) => {
        if (!dragging) return;
        const dx = ev.clientX - ox;
        const dy = ev.clientY - oy;
        if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;
        const nx = clamp(sx + dx, 0, innerWidth - el.offsetWidth);
        const ny = clamp(sy + dy, 0, innerHeight - el.offsetHeight);
        el.style.left = nx + 'px';
        el.style.top = ny + 'px';
      });

      hdr.addEventListener('pointerup', (ev) => {
        if (!dragging) return;
        dragging = false;
        hdr.releasePointerCapture(ev.pointerId);
        const r = el.getBoundingClientRect();
        saveUIState({ left: r.left, top: r.top, collapsed: body.style.display === 'none' });
        if (!moved) toggleCollapsed();
      });

      window.addEventListener('resize', () => {
        const r = el.getBoundingClientRect();
        el.style.left = clamp(r.left, 0, innerWidth - el.offsetWidth) + 'px';
        el.style.top = clamp(r.top, 0, innerHeight - el.offsetHeight) + 'px';
      });
    })();

    updateUIFromSettings();
    setAdvancedVisible(uiState.advanced);
    setCollapsed(uiState.collapsed);
    placeInitial();
  }

  function controlLoop() {
    const video = pickActiveVideo();
    if (!video) {
      updatePanelDisplay(NaN, 1);
      return;
    }

    if (Number.isFinite(video.playbackRate)) {
      rateEstimate = video.playbackRate;
    }

    if (!SETTINGS.enabled) {
      rateEstimate = 1;
      const applied = setRate(video, 1.0);
      updatePanelDisplay(NaN, applied);
      return;
    }

    if (!isLive(video) || isAdPlaying()) {
      rateEstimate = 1;
      const applied = setRate(video, 1.0);
      updatePanelDisplay(NaN, applied);
      return;
    }

    const lag = getLag(video);
    if (!Number.isFinite(lag)) {
      rateEstimate = 1;
      const applied = setRate(video, 1.0);
      updatePanelDisplay(NaN, applied);
      return;
    }

    const bufferAhead = getBufferedAhead(video);

    let targetRate = 1.0;

    if (rateEstimate > 1.0) {
        if (lag > SETTINGS.normalizeLag) {
            const excess = lag - SETTINGS.normalizeLag;
            const catchupSpan = Math.max(0.25, (SETTINGS.targetLag - SETTINGS.normalizeLag) * 1.5);
            const normalized = clamp(excess / catchupSpan, 0, 1);
            targetRate = 1 + normalized * (SETTINGS.maxBoost - 1);
        } else {
            targetRate = 1.0;
        }
    } else {
        if (lag > SETTINGS.targetLag) {
            const excess = lag - SETTINGS.targetLag;
            const catchupSpan = Math.max(0.25, SETTINGS.targetLag * 1.5);
            const normalized = clamp(excess / catchupSpan, 0, 1);
            targetRate = 1 + normalized * (SETTINGS.maxBoost - 1);
        }
    }

    if (targetRate > 1.0 && bufferAhead < SETTINGS.bufferSafety) {
      const bufferScale = clamp(bufferAhead / SETTINGS.bufferSafety, 0, 1);
      const maxAllowed = 1 + (SETTINGS.maxBoost - 1) * bufferScale;
      targetRate = Math.min(targetRate, maxAllowed);
    }

    const smoothed = smoothRate(targetRate);
    const applied = setRate(video, smoothed);
    updatePanelDisplay(lag, applied);
  }

  function main() {
    load();
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', panel, { once: true });
    } else {
      panel();
    }
    setInterval(controlLoop, SETTINGS.checkMs);
  }

  main();
})();