Greasy Fork

来自缓存

Greasy Fork is available in English.

SOOP - 타임머신용 설정메뉴 재생속도

타임머신으로 이전에 방송내용을 볼때 배속으로 볼수 있습니다. 라이브로 도착시 잠시 로딩이 걸립니다.

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

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

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

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

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

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         SOOP - 타임머신용 설정메뉴 재생속도
// @namespace    https://www.afreecatv.com/
// @version      1.0.3
// @author       hakkutakku
// @description  타임머신으로 이전에 방송내용을 볼때 배속으로 볼수 있습니다. 라이브로 도착시 잠시 로딩이 걸립니다.
// @icon         https://res.sooplive.co.kr/favicon.ico
// @match        https://play.sooplive.com/*/*
// @run-at       document-end
// @grant        none
// @license      MIT
// ==/UserScript==

(() => {
  "use strict";

  // =========================================================
  // Config
  // =========================================================
  const CFG = Object.freeze({
    presets: Object.freeze([
      { label: "기본", rate: 1.0 },
      { label: "1.25x", rate: 1.25 },
      { label: "1.5x", rate: 1.5 },
      { label: "1.75x", rate: 1.75 },
      { label: "2x", rate: 2.0 },
    ]),

    ui: Object.freeze({
      widthPx: 320,
      itemFontPx: 14,
      activeBlue: "#2d8cff",
    }),

    behavior: Object.freeze({
      normalSnapEps: 0.02,
      forceNormalOnFirstMenuOpen: true,

      // 라이브 엣지 근처에서 고배속 버튼 비활성(원하면 false)
      disableFastAtLiveEdge: true,
      liveEdgeSec: 1.2,
    }),

    loop: Object.freeze({
      // 메뉴 열렸을 때만 도는 렌더 루프
      renderTickMs: 300,
      renderMinMs: 250,

      // 페이지 키 변화 감지(저빈도)
      pageCheckMs: 1200,
    }),

    dom: Object.freeze({
      playerRootSelector: "#player",
      settingBoxSelector: ".setting_box",
      settingListSelector: ".setting_list",
      entryId: "soopSpeedEntry",
      subLayerClass: "soop_speed_subLayer",
      openClass: "soop-speed-open",
      styleId: "soop-speed-style-ultralite-v280",
    }),
  });

  // =========================================================
  // Utils
  // =========================================================
  const U = (() => {
    const pageKey = () => location.origin + location.pathname + location.search;

    const stopAll = (e) => {
      try {
        e.preventDefault();
        e.stopPropagation();
        if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
      } catch {}
    };

    const injectStyleOnce = (id, cssText) => {
      if (document.getElementById(id)) return;
      const s = document.createElement("style");
      s.id = id;
      s.textContent = cssText;
      document.head.appendChild(s);
    };

    const isVisible = (el) => {
      if (!el || !(el instanceof HTMLElement)) return false;
      const st = getComputedStyle(el);
      if (st.display === "none" || st.visibility === "hidden" || st.opacity === "0") return false;
      const r = el.getBoundingClientRect();
      return r.width > 0 && r.height > 0;
    };

    const approx = (a, b, eps = 0.001) => Math.abs(a - b) < eps;

    const normalizeRate = (r) => {
      if (!Number.isFinite(r)) return 1.0;
      return Math.abs(r - 1.0) <= CFG.behavior.normalSnapEps ? 1.0 : r;
    };

    const insideRect = (x, y, rect) =>
      x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;

    return { pageKey, stopAll, injectStyleOnce, isVisible, approx, normalizeRate, insideRect };
  })();

  // =========================================================
  // Style (사용자 요청대로 유지)
  // =========================================================
  const Style = (() => {
    const init = () => {
      U.injectStyleOnce(
        CFG.dom.styleId,
        `
        /* speed sublayer */
        .setting_box .${CFG.dom.subLayerClass} { display:none !important; }
        .setting_box .${CFG.dom.subLayerClass}.${CFG.dom.openClass}{
          display:block !important;
          width:${CFG.ui.widthPx}px !important;
          max-width:${CFG.ui.widthPx}px !important;
          background: rgba(23, 25, 28, .9); !important;
          border-radius: 12px !important;
          overflow: hidden !important;
          position: relative !important;
          z-index: 9999 !important;
        }
        .setting_box .${CFG.dom.subLayerClass} .goBack{
          width:100% !important;
          text-align:left !important;
          cursor:pointer !important;
        }
        .setting_box .${CFG.dom.subLayerClass} .soop_speed_list button:hover{
          background: rgba(56, 58, 60, 0.9) !important;
        }
      `
      );
    };
    return { init };
  })();

  // =========================================================
  // Video (Ultra Lite: visible + largest)
  // =========================================================
  const Video = (() => {
    const root = () => document.querySelector(CFG.dom.playerRootSelector) || document;

    const chooseBest = () => {
      const vids = Array.from(root().querySelectorAll("video")).filter((v) => v && v.isConnected);
      if (!vids.length) return null;

      let best = null;
      let bestArea = -1;

      for (const v of vids) {
        const r = v.getBoundingClientRect();
        if (r.width <= 80 || r.height <= 80) continue;

        const st = getComputedStyle(v);
        if (st.display === "none" || st.visibility === "hidden" || st.opacity === "0") continue;

        const area = r.width * r.height;

        // 살짝 가중치: 재생중인 video 선호
        const bonus = !v.paused && !v.ended ? 1e6 : 0;
        const score = area + bonus;

        if (score > bestArea) {
          bestArea = score;
          best = v;
        }
      }
      return best || vids[0] || null;
    };

    const liveDelta = (v) => {
      try {
        if (v?.seekable?.length) {
          const end = v.seekable.end(v.seekable.length - 1);
          return end - v.currentTime;
        }
      } catch {}
      try {
        if (v?.buffered?.length) {
          const end = v.buffered.end(v.buffered.length - 1);
          return end - v.currentTime;
        }
      } catch {}
      return null;
    };

    const isAtLiveEdge = (v) => {
      const d = liveDelta(v);
      return d != null && d <= CFG.behavior.liveEdgeSec;
    };

    const setRateSafely = (state, v, rate) => {
      if (!v) return;
      try {
        state.isSettingRate = true;
        v.playbackRate = rate;
        setTimeout(() => {
          state.isSettingRate = false;
        }, 60);
      } catch {
        state.isSettingRate = false;
      }
    };

    return { chooseBest, isAtLiveEdge, setRateSafely };
  })();

  // =========================================================
  // Popup Killer (작은 네이티브 속도 팝업 제거)
  // =========================================================
  const PopupKiller = (() => {
    const player = () => document.querySelector(CFG.dom.playerRootSelector) || document.body;

    const looksLikeSmallPopup = (el) => {
      if (!el || !(el instanceof HTMLElement) || !el.isConnected) return false;
      const p = player();
      if (!p || !p.contains(el)) return false;

      if (el.closest(CFG.dom.settingBoxSelector)) return false;
      if (el.closest("." + CFG.dom.subLayerClass)) return false;

      const r = el.getBoundingClientRect();
      const sizeOk = r.width >= 120 && r.width <= 360 && r.height >= 120 && r.height <= 460;
      if (!sizeOk) return false;

      const t = el.textContent || "";
      return t.includes("재생") && (t.includes("1.25") || t.includes("1.5") || t.includes("1.75") || t.includes("2"));
    };

    const kill = (root = null) => {
      const container = root instanceof HTMLElement ? root : player();
      if (looksLikeSmallPopup(container)) {
        container.remove();
        return;
      }
      const nodes = container.querySelectorAll?.("div,ul,section,article,aside") || [];
      for (const el of nodes) {
        if (looksLikeSmallPopup(el)) el.remove();
      }
    };

    return { kill };
  })();

  // =========================================================
  // Setting DOM
  // =========================================================
  const SettingDOM = (() => {
    const boxes = () => Array.from(document.querySelectorAll(CFG.dom.settingBoxSelector));

    const getMainList = (box) => {
      const lists = Array.from(box.querySelectorAll(":scope " + CFG.dom.settingListSelector));
      for (const l of lists) if (U.isVisible(l)) return l;
      return lists[0] || null;
    };

    const ensureEntry = (box) => {
      const mainList = getMainList(box);
      const ul = mainList?.querySelector("ul");
      if (!ul) return;
      if (ul.querySelector(`#${CSS.escape(CFG.dom.entryId)}`)) return;

      const templateBtn = ul.querySelector("li button");
      if (!templateBtn) return;

      const li = document.createElement("li");
      const btn = document.createElement("button");
      btn.type = "button";
      btn.id = CFG.dom.entryId;
      btn.className = templateBtn.className || "";

      const sp = document.createElement("span");
      sp.textContent = "재생 속도";
      btn.appendChild(sp);
      li.appendChild(btn);

      const broad = ul.querySelector("#btnBroadInfo")?.closest("li");
      if (broad?.parentNode) broad.parentNode.insertBefore(li, broad.nextSibling);
      else ul.appendChild(li);
    };

    const stashHideNativeSubLayers = (mainList) => {
      if (!mainList) return;
      const subs = Array.from(mainList.querySelectorAll(":scope .setting_list_subLayer"));
      for (const el of subs) {
        if (el.classList.contains(CFG.dom.subLayerClass)) continue;
        if (!el.dataset.soopPrevDisplay) el.dataset.soopPrevDisplay = el.style.display || "__EMPTY__";
        el.style.display = "none";
      }
    };

    const restoreNativeSubLayers = (mainList) => {
      if (!mainList) return;
      const subs = Array.from(mainList.querySelectorAll(":scope .setting_list_subLayer"));
      for (const el of subs) {
        if (el.classList.contains(CFG.dom.subLayerClass)) continue;
        const prev = el.dataset.soopPrevDisplay;
        if (!prev) continue;
        el.style.display = prev === "__EMPTY__" ? "" : prev;
        delete el.dataset.soopPrevDisplay;
      }
    };

    const makeGoBackHeader = (mainList) => {
      const sample = mainList.querySelector(".setting_list_subLayer .goBack");
      if (sample && sample instanceof HTMLElement) {
        const c = sample.cloneNode(true);
        c.removeAttribute("onclick");
        c.removeAttribute("id");
        c.textContent = "재생 속도";
        return c;
      }
      const btn = document.createElement("button");
      btn.type = "button";
      btn.className = "goBack";
      btn.textContent = "재생 속도";
      return btn;
    };

    return { boxes, getMainList, ensureEntry, stashHideNativeSubLayers, restoreNativeSubLayers, makeGoBackHeader };
  })();

  // =========================================================
  // Speed UI (메뉴 열렸을 때만 렌더 루프)
  // =========================================================
  const SpeedUI = (() => {
    // ✅ 타임머신 직후/리셋 레이스를 커버하기 위한 "재적용 버스트"
    const applyRateBurst = (state, rate) => {
      if (state._rateBurstTimer) {
        clearInterval(state._rateBurstTimer);
        state._rateBurstTimer = null;
      }

      const start = performance.now();
      const burstDuration = 900; // 메인 유지 (0.9초)
      const step = 70;

      const apply = () => {
        const v = Video.chooseBest();
        if (!v) return;
        state.activeVideo = v;
        Video.setRateSafely(state, v, rate);
      };

      // 즉시
      apply();

      // 빠른 반복
      state._rateBurstTimer = setInterval(() => {
        if (performance.now() - start > burstDuration) {
          clearInterval(state._rateBurstTimer);
          state._rateBurstTimer = null;
          return;
        }
        apply();
      }, step);

      // ✅ 느린 보험 (마지막 한방)
      setTimeout(() => {
        const v = Video.chooseBest();
        if (!v) return;
        state.activeVideo = v;
        Video.setRateSafely(state, v, rate);
      }, 1300);
    };

    const startRenderLoop = (state) => {
      if (state.renderLoopTimer) return;
      state.renderLoopTimer = setInterval(() => {
        if (!state.openSubLayer || !state.openSubLayer.isConnected) {
          stopRenderLoop(state);
          return;
        }

        const v = Video.chooseBest();
        if (v) state.activeVideo = v;

        const cur = state.activeVideo ? U.normalizeRate(state.activeVideo.playbackRate || 1.0) : 1.0;
        const now = Date.now();
        const changed = !Number.isFinite(state.lastShownRate) || !U.approx(cur, state.lastShownRate, 0.0005);
        const due = now - state.lastRenderAt >= CFG.loop.renderMinMs;

        if ((changed || due) && !state.isSettingRate) render(state);
      }, CFG.loop.renderTickMs);
    };

    const stopRenderLoop = (state) => {
      if (!state.renderLoopTimer) return;
      clearInterval(state.renderLoopTimer);
      state.renderLoopTimer = null;
    };

    const buildSubLayer = (state, box) => {
      const mainList = SettingDOM.getMainList(box);
      if (!mainList) return null;

      mainList.querySelectorAll(`:scope .setting_list_subLayer.${CFG.dom.subLayerClass}`).forEach((el) => el.remove());

      const sub = document.createElement("div");
      sub.className = `setting_list_subLayer ${CFG.dom.subLayerClass}`;

      const header = SettingDOM.makeGoBackHeader(mainList);
      header.addEventListener("click", (e) => {
        U.stopAll(e);
        closeAll(state);
      });

      const list = document.createElement("div");
      list.className = "soop_speed_list";

      sub.appendChild(header);
      sub.appendChild(list);
      mainList.appendChild(sub);
      return sub;
    };

    const open = (state, box) => {
      const mainList = SettingDOM.getMainList(box);
      const ul = mainList?.querySelector("ul");
      if (!mainList || !ul) return;

      PopupKiller.kill();
      SettingDOM.stashHideNativeSubLayers(mainList);

      state.activeVideo = Video.chooseBest() || state.activeVideo;

      ul.style.display = "none";
      mainList.classList.add("subLayer_on");

      const sub = buildSubLayer(state, box);
      if (!sub) return;

      sub.classList.add(CFG.dom.openClass);
      state.openSettingBox = box;
      state.openSubLayer = sub;

      if (
        CFG.behavior.forceNormalOnFirstMenuOpen &&
        !state.forcedDefaultDone &&
        !state.userChoseRate &&
        state.activeVideo
      ) {
        Video.setRateSafely(state, state.activeVideo, 1.0);
        state.forcedDefaultDone = true;
      }

      render(state);
      PopupKiller.kill();
      startRenderLoop(state);
    };

    const closeAll = (state) => {
      for (const box of SettingDOM.boxes()) {
        const mainList = SettingDOM.getMainList(box);
        const ul = mainList?.querySelector("ul");

        mainList?.querySelectorAll(`:scope .setting_list_subLayer.${CFG.dom.subLayerClass}`).forEach((el) => el.remove());
        if (ul) ul.style.display = "";

        SettingDOM.restoreNativeSubLayers(mainList);
        mainList?.classList.remove("subLayer_on");
      }

      state.openSettingBox = null;
      state.openSubLayer = null;

      PopupKiller.kill();
      stopRenderLoop(state);
    };

    const render = (state) => {
      const sub = state.openSubLayer;
      if (!sub || !sub.isConnected) return;

      const list = sub.querySelector(".soop_speed_list");
      if (!list) return;

      const vBest = Video.chooseBest();
      if (vBest) state.activeVideo = vBest;

      const v = state.activeVideo;
      const cur = v ? U.normalizeRate(v.playbackRate || 1.0) : 1.0;
      const atEdge = v ? Video.isAtLiveEdge(v) : false;

      const isLiveNow = !!document.querySelector("#liveButton.live_state.on");

      list.innerHTML = "";

      for (const p of CFG.presets) {
        const eps = p.rate === 1.0 ? CFG.behavior.normalSnapEps : 0.001;
        const active = U.approx(p.rate, cur, eps);

        // ✅ LIVE일 때 배속 막기 + (옵션) 라이브 엣지 근처 고배속 막기
        const disabled =
          isLiveNow ||
          (CFG.behavior.disableFastAtLiveEdge && atEdge && p.rate > 1.0);

        const row = document.createElement("button");
        row.type = "button";
        row.disabled = disabled;
        row.style.cssText = [
          "width:100%",
          "padding: 12px 14px",
          "border:0",
          "background: transparent",
          `color:${active ? CFG.ui.activeBlue : "#fff"}`,
          `font-size:${CFG.ui.itemFontPx}px`,
          `cursor:${disabled ? "not-allowed" : "pointer"}`,
          `opacity:${disabled ? 0.35 : 1}`,
          "display:flex",
          "align-items:center",
          "justify-content:space-between",
          "gap:10px",
          "text-align:left",
        ].join(";");

        const left = document.createElement("span");
        left.textContent = p.label;

        const right = document.createElement("span");
        right.textContent = active ? "✓" : "";
        right.style.cssText = `color:${active ? CFG.ui.activeBlue : "#fff"}; font-weight:700;`;

        row.addEventListener("click", (e) => {
          U.stopAll(e);
          if (disabled) return;

          state.userChoseRate = true;

          // ✅ 씹힘 방지: 버스트 적용
          applyRateBurst(state, p.rate);

          // UI 체크 표시 갱신
          setTimeout(() => render(state), 350);
        });

        row.appendChild(left);
        row.appendChild(right);
        list.appendChild(row);
      }

      state.lastShownRate = cur;
      state.lastRenderAt = Date.now();
    };

    const cleanupIfSettingClosed = (state, box) => {
      const mainList = SettingDOM.getMainList(box) || box.querySelector(":scope " + CFG.dom.settingListSelector);
      if (!mainList) return;

      if (!U.isVisible(mainList)) {
        const ul = mainList.querySelector("ul");
        if (ul) ul.style.display = "";

        SettingDOM.restoreNativeSubLayers(mainList);
        mainList.classList.remove("subLayer_on");
        mainList.querySelectorAll(`:scope .setting_list_subLayer.${CFG.dom.subLayerClass}`).forEach((el) => el.remove());

        if (state.openSettingBox === box) {
          state.openSettingBox = null;
          state.openSubLayer = null;
          stopRenderLoop(state);
        }
      }
    };

    return { open, closeAll, render, cleanupIfSettingClosed };
  })();

  // =========================================================
  // Events (speed menu + misc)
  // =========================================================
  const Events = (() => {
    const bindEntryClick = (state) => {
      const handler = (e) => {
        const t = e.target;
        if (!(t instanceof Element)) return;
        const entry = t.closest(`#${CSS.escape(CFG.dom.entryId)}`);
        if (!entry) return;

        U.stopAll(e);
        const box = entry.closest(CFG.dom.settingBoxSelector);
        if (box) SpeedUI.open(state, box);
      };

      ["pointerdown", "mousedown", "touchstart", "click"].forEach((ev) => document.addEventListener(ev, handler, true));
    };

    const bindHotkeys = () => {
      const onHotkey = (e) => {
        const less = e.key === "<" || (e.key === "," && e.shiftKey) || (e.code === "Comma" && e.shiftKey);
        const greater = e.key === ">" || (e.key === "." && e.shiftKey) || (e.code === "Period" && e.shiftKey);
        if (less || greater) U.stopAll(e);
      };
      window.addEventListener("keydown", onHotkey, true);
      window.addEventListener("keypress", onHotkey, true);
    };

    const bindMouseLeaveVideo = (state) => {
      document.addEventListener(
        "mousemove",
        (e) => {
          if (!state.openSubLayer || !state.openSubLayer.isConnected) return;

          const v = Video.chooseBest();
          if (v) state.activeVideo = v;
          if (!state.activeVideo) return;

          const rect = state.activeVideo.getBoundingClientRect();
          const inside = U.insideRect(e.clientX, e.clientY, rect);

          if (state.wasInsideVideoRect && !inside) {
            const inSetting = e.target instanceof Element && !!e.target.closest(CFG.dom.settingBoxSelector);
            if (!inSetting) SpeedUI.closeAll(state);
          }
          state.wasInsideVideoRect = inside;
        },
        true
      );
    };

    return { bindEntryClick, bindHotkeys, bindMouseLeaveVideo };
  })();

  // =========================================================
  // Runtime (Ultra Lite)
  // =========================================================
  const Runtime = (() => {
    const createState = () => ({
      lastPageKey: U.pageKey(),
      forcedDefaultDone: false,
      userChoseRate: false,

      activeVideo: null,
      openSettingBox: null,
      openSubLayer: null,

      lastRenderAt: 0,
      lastShownRate: NaN,

      isSettingRate: false,
      wasInsideVideoRect: true,

      renderLoopTimer: null,
      _rateBurstTimer: null,
    });

    const resetBroadcastState = (state) => {
      state.forcedDefaultDone = false;
      state.userChoseRate = false;

      SpeedUI.closeAll(state);

      state.activeVideo = null;
      state.openSettingBox = null;
      state.openSubLayer = null;
      state.lastShownRate = NaN;
    };

    // DOM 변화가 폭주할 수 있어서 80ms 스로틀
    const mountObserver = (state) => {
      let queued = false;

      const flush = () => {
        queued = false;
        for (const box of SettingDOM.boxes()) {
          if (U.isVisible(box)) SettingDOM.ensureEntry(box);
          SpeedUI.cleanupIfSettingClosed(state, box);
        }
      };

      const mo = new MutationObserver((muts) => {
        for (const m of muts) {
          for (const n of m.addedNodes) if (n instanceof HTMLElement) PopupKiller.kill(n);
        }
        if (queued) return;
        queued = true;
        setTimeout(flush, 80);
      });

      mo.observe(document.documentElement, { childList: true, subtree: true });
    };

    // 페이지 이동/방 변경 감지(저빈도)
    const startPageCheck = (state) => {
      setInterval(() => {
        const k = U.pageKey();
        if (k !== state.lastPageKey) {
          state.lastPageKey = k;
          resetBroadcastState(state);
        }
      }, CFG.loop.pageCheckMs);
    };

    return { createState, mountObserver, startPageCheck };
  })();

  // =========================================================
  // App init
  // =========================================================
  const App = (() => {
    const init = () => {
      Style.init();

      const state = Runtime.createState();

      // 초기 주입(보이는 setting_box만)
      for (const box of SettingDOM.boxes()) if (U.isVisible(box)) SettingDOM.ensureEntry(box);

      Events.bindEntryClick(state);
      Events.bindHotkeys();
      Events.bindMouseLeaveVideo(state);

      Runtime.mountObserver(state);
      Runtime.startPageCheck(state);
    };

    return { init };
  })();

  App.init();
})();