Greasy Fork

来自缓存

Greasy Fork is available in English.

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.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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 http://greasyfork.icu/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();
})();