Ranked Streams V2 (Grid & Sync Fix - Bottom)

Adds synced streams natively in MCSR Ranked at the bottom, supports split-screen grid and relative instant seeking.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        Ranked Streams V2 (Grid & Sync Fix - Bottom)
// @namespace   Violentmonkey Scripts
// @match       *://*.mcsrranked.com/*
// @grant       GM_addStyle
// @version     1.0
// @run-at      document-idle
// @description Adds synced streams natively in MCSR Ranked at the bottom, supports split-screen grid and relative instant seeking.
// @license     MIT
// ==/UserScript==

(function () {
  // Configuration
  const TWITCH_EMBED_URL = "https://player.twitch.tv/js/embed/v1.js";
  const CONTAINER_SELECTOR = "div.min-h-64.flex-1.overflow-scroll.pt-2";
  const LINK_SELECTOR =
    'a[href^="https://www.twitch.tv/videos"][target="_blank"]';
  const BUTTON_SELECTOR =
    "button.cursor-pointer.border.px-2.py-1\\.5, div.cursor-pointer.border.px-2.py-1\\.5";

  // Declare basic variables
  let activePlayers = {}; // Map: videoId -> Twitch.Player instance
  let videoStartTimes = {}; // Map: videoId -> int (Earliest timestamp found for this video)
  let debounceTimer = null;

  // Add CSS for the streams grid layout
  // Bad styling
  const style = document.createElement("style");
  style.textContent = `
    #twitch-grid-container {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
      gap: 8px;
      width: 100%;
    }
    .twitch-stream-wrapper {
      position: relative;
      width: 100%;
      padding-top: 43.75%; /* 16:7 Aspect Ratio */
      background: #000;
      border-radius: 8px;
      overflow: hidden;
    }
    .twitch-stream-wrapper iframe {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
    }
  `;
  document.head.appendChild(style);

  // --- Helpers ---
  // Parse the twitch url
  function parseTwitchUrl(url) {
    const match = /videos\/(\d+)(?:\?t=(\d+)s)?/.exec(url);
    if (match) {
      return {
        videoId: match[1],
        time: match[2] ? parseInt(match[2]) : 0,
      };
    }
    return null;
  }

  // Load the script required for playback from twitch
  function loadTwitchAPI(callback) {
    const script = document.createElement("script");
    script.id = "twitch-api-script";
    script.src = TWITCH_EMBED_URL;
    script.onload = callback;
    document.body.appendChild(script);
  }

  // --- Core Logic ---

  function initStreams() {
    const container = document.querySelector(CONTAINER_SELECTOR);
    const links = document.querySelectorAll(LINK_SELECTOR);

    if (!container || links.length === 0) return;

    // Prevent double injection
    if (document.getElementById("twitch-grid-container")) return;

    console.log("[Ranked Streams] Match detected. Initializing...");

    // 1. Identify Start Times (The "Zero Point" for each stream)
    videoStartTimes = {};
    const uniqueVideos = new Set();

    links.forEach((link) => {
      const data = parseTwitchUrl(link.href);
      if (data) {
        uniqueVideos.add(data.videoId);
        // We assume the earliest timestamp found for a video ID is the "Match Start"
        if (
          typeof videoStartTimes[data.videoId] === "undefined" ||
          data.time < videoStartTimes[data.videoId]
        ) {
          videoStartTimes[data.videoId] = data.time;
        }
      }
    });

    // 2. Setup / Add Grid Container
    const gridDiv = document.createElement("div");
    gridDiv.id = "twitch-grid-container";
    container.appendChild(gridDiv);

    activePlayers = {}; // Reset Twitch Players

    // 3. Create Players

    uniqueVideos.forEach((videoId) => {
      const startTime = videoStartTimes[videoId] || 0;
      const wrapper = document.createElement("div");
      wrapper.className = "twitch-stream-wrapper";
      const playerDivId = `twitch-embed-${videoId}`;
      const playerDiv = document.createElement("div");
      playerDiv.id = playerDivId;
      wrapper.appendChild(playerDiv);
      gridDiv.appendChild(wrapper);

      const player = new Twitch.Player(playerDivId, {
        video: videoId,
        time: `${startTime}s`,
        autoplay: true,
        muted: true,
        controls: true,
        width: "100%",
        controls: false,
        height: "100%",
        parent: ["mcsrranked.com"],
      });

      activePlayers[videoId] = player;
    });

    // 4. Intercept Timeline Clicks
    addButtonEventListeners();
    addLinkEventListeners();
  }

  // 5. Start observing for changes to grid
  function startObserver() {
    const observer = new MutationObserver(() => {
      const container = document.querySelector(CONTAINER_SELECTOR);
      const grid = document.getElementById("twitch-grid-container");

      if (container && !grid) {
        clearTimeout(debounceTimer);
        debounceTimer = setTimeout(initStreams, 500);
      }
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });

    setTimeout(initStreams, 1000);
  }

  // 6. Listen for changes in splits and detail level and reload streams.
  function addButtonEventListeners() {
    const buttons = document.querySelectorAll(BUTTON_SELECTOR);
    console.log(buttons);
    buttons.forEach((button) => {
      button.addEventListener("click", addLinkEventListeners);
    });
  }

  // Add link click listeners which take you that point in stream
  function addLinkEventListeners() {
    const links = document.querySelectorAll(LINK_SELECTOR);
    links.forEach((link) => {
      const newLink = link.cloneNode(true);
      link.parentNode.replaceChild(newLink, link);

      newLink.addEventListener("click", (e) => {
        e.preventDefault();
        e.stopPropagation();

        const data = parseTwitchUrl(newLink.href);
        if (!data) return;

        // Calculate Offset
        const originBaseTime = videoStartTimes[data.videoId];
        if (originBaseTime === undefined) return;

        const matchOffset = data.time - originBaseTime;
        console.log(`[Ranked] Syncing to Match Time +${matchOffset}s`);

        // Apply Offset to all players
        Object.keys(activePlayers).forEach((targetVideoId) => {
          const targetBaseTime = videoStartTimes[targetVideoId];
          // 1 second so it starts correctly
          const targetSeekTime = targetBaseTime + matchOffset - 1;
          activePlayers[targetVideoId].seek(Math.max(0, targetSeekTime));
        });
      });
    });
  }

  // Start script
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", () =>
      loadTwitchAPI(startObserver),
    );
  } else {
    loadTwitchAPI(startObserver);
  }
})();