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.

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