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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==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://greasyfork.org/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();
})();