CHZZK Live Bar

치지직 라이브 방송 접속 시점부터의 최대 1분 30초간의 재생바를 제공합니다.

// ==UserScript==
// @name         CHZZK Live Bar
// @version      1.0.6
// @match        https://chzzk.naver.com/*
// @description  치지직 라이브 방송 접속 시점부터의 최대 1분 30초간의 재생바를 제공합니다.
// @run-at       document-idle
// @grant        none
// @author       k22pr
// @namespace    k22pr/chzzk-live-bar
// @license MIT
// ==/UserScript==

console.log("live-bar.js");
(function () {
  const VIDEO_ELEMENT_NAME = "video.webplayer-internal-video";
  const BOTTOM_SEL = "div.pzp-pc__bottom";
  const EDGE_EPS = 0.5;
  const LIVE_EPS = 3.5;
  const MAX_VIDEO_DURATION = 60 * 1.5;

  const CSS = `
      .live-bar-box{display:flex !important;position:absolute;left:0px;bottom:30px !important;width:100%;font-size:11px;line-height:1;}
      // .live-bar-box{opacity:1 !important;}
      .live-bar-box .live-bar-ui{width:100%;display:flex;gap:8px;align-items:center;color:#fff;padding:6px 8px;}
      .live-bar-box .go{width:45px;border:0;border-radius:6px;padding:2px 8px;background:#868e96;color:#fff;cursor:pointer;font-size:11px;line-height:1;margin-left:4px;}
      .live-bar-box .go.live{background:rgb(221, 51, 51);box-shadow:0px 0px 4px rgba(221, 51, 51, 0.5);}
      .live-bar-box .t{white-space:nowrap;min-width:20px;text-align:center}
      .live-bar-box .time{display:flex;gap:4px;align-items:center}
      .live-bar-box .slide-box{display:flex;flex:1;position:relative;align-items:center;width:100%; height:3px;padding:8px 0px;cursor:pointer}
      .live-bar-box .slide-box:hover{height:6px;}
      .live-bar-box .slide-box div{transition:height 0.2s;}
      .live-bar-box .slide-box .track{position:absolute;left:0px;width:100%;height:3px;background:rgba(255,255,255,0.5);}
      .live-bar-box .slide-box .rng{position:absolute;left:0px;height:3px;background:#00f889;transition:width 0.1s;transition-delay: 0.1}
      .live-bar-box .slide-box:hover .track{height:6px;border-radius:3px;box-shadow:0px 0px 4px rgba(0,0,0,0.3);}
      .live-bar-box .slide-box:hover .rng{height:6px;border-radius:3px;box-shadow:0px 0px 5px #00f889;}
      .live-bar-box.no-anim .slide-box .rng { transition: none !important; }

      .live-bar-box .hover-tip{
        position:absolute; top:0; transform:translate(-50%,-140%);
        background:rgba(0,0,0,.7); color:#fff; padding:2px 6px; border-radius:4px;
        font-size:11px/1; pointer-events:none;
        opacity:0; transition:opacity .12s;
      }
      .live-bar-box .hover-tip.show{ opacity:1; }
      .live-bar-box .hover-x{
        position:absolute; top:0; bottom:0; width:1px; background:rgba(255,255,255,1);
        transform:translateX(-.5px); opacity:0; transition:opacity .12s;
      }
      .live-bar-box .hover-x.show{ opacity:1; }
  `;

  const styleOnce = (() => {
    let done = false;
    return () => {
      if (done) return;
      done = true;
      const s = document.createElement("style");
      s.textContent = CSS;
      document.head.appendChild(s);
    };
  })();

  const timeFormat = (t) => {
    const h = (t / 3600) | 0,
      m = ((t % 3600) / 60) | 0,
      s = t % 60 | 0;

    return h
      ? `${t > 0 ? "" : "-"}${Math.abs(h)}:${String(Math.abs(m)).padStart(
          2,
          "0"
        )}:${String(Math.abs(s)).padStart(2, "0")}`
      : `${t > 0 ? "" : "-"}${Math.abs(m)}:${String(Math.abs(s)).padStart(
          2,
          "0"
        )}`;
  };

  const getEdges = (v) => {
    const s = v.seekable;
    if (s && s.length) {
      const start = s.start(0);
      const end = s.end(s.length - 1);
      return {
        start: Math.max(start, end - MAX_VIDEO_DURATION),
        end: s.end(s.length - 1),
        ok: true,
      };
    }
    const b = v.buffered;
    if (b && b.length) {
      const start = b.start(0);
      const end = b.end(b.length - 1);
      return {
        start: Math.max(start, end - MAX_VIDEO_DURATION),
        end: b.end(b.length - 1),
        ok: true,
      };
    }
    return { start: 0, end: isFinite(v.duration) ? v.duration : 0, ok: false };
  };

  function findBottomContainer(v) {
    let c = document.querySelector(BOTTOM_SEL);
    if (c) return c;
    let node = v;
    while (node) {
      const root = node.getRootNode?.();
      const host = root && root.host;
      if (!host) break;
      const inHost = root.querySelector?.(BOTTOM_SEL);
      if (inHost) return inHost;
      node = host;
    }
    return null;
  }

  function mount(v) {
    if (v.__liveBarMounted) return;
    const bottom = findBottomContainer(v);
    if (!bottom) return;

    styleOnce();

    if (bottom.querySelector(".live-bar-box")) return;

    document.querySelector(`${BOTTOM_SEL} .slider`)?.remove();

    const wrap = document.createElement("div");
    wrap.className = "live-bar-box pzp-pc__progress-slider";
    wrap.innerHTML = `
      <div class="live-bar-ui">
      <div class='slide-box'>
        <div class='track'></div>
        <div class="rng"></div>
      </div>
        <div class='time'>
          <span class="t total">0:00</span>
          <button class="go">LIVE</button>
        </div>
      </div>
    `;
    bottom.appendChild(wrap);

    const rng = wrap.querySelector(".rng");
    const tTotal = wrap.querySelector(".total");
    const btn = wrap.querySelector(".go");
    const slide = wrap.querySelector(".slide-box");

    const tip = document.createElement("div");
    tip.className = "hover-tip";
    tip.textContent = "0:00";
    const cross = document.createElement("div");
    cross.className = "hover-x";
    slide.appendChild(cross);
    slide.appendChild(tip);
    let raf = 0,
      lastEvt = null;

    const useRVFC = "requestVideoFrameCallback" in v;
    let rafId = 0;

    const onMove = (e) => {
      lastEvt = e;
      if (!raf)
        raf = requestAnimationFrame(() => {
          raf = 0;
          if (lastEvt) renderAt(lastEvt.clientX);
        });
    };

    function seekFromClick(e) {
      e.preventDefault();
      e.stopPropagation();
      e.stopImmediatePropagation();

      wrap.classList.add("no-anim");
      const rect = slide.getBoundingClientRect();
      const x = Math.min(Math.max(e.clientX - rect.left, 0), rect.width);
      const ratio = rect.width ? x / rect.width : 0;

      const { start, end } = getEdges(v);
      const target = start + ratio * (end - start);
      seekTo(target);

      requestAnimationFrame(() => wrap.classList.remove("no-anim"));
    }

    const atLiveEdge = (currentTime) => {
      const { end } = getEdges(v);
      return currentTime - end > -LIVE_EPS;
    };

    function renderAt(clientX) {
      const rect = slide.getBoundingClientRect();
      const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
      const ratio = rect.width ? x / rect.width : 0;

      const { start, end, ok } = getEdges(v);
      const lo = ok ? start : 0;
      const hi =
        ok && isFinite(end) ? end : isFinite(v.duration) ? v.duration : lo;
      if (!isFinite(hi) || hi <= lo) return;

      const t = lo + ratio * (hi - lo);
      const nearLive = atLiveEdge(t);

      tip.textContent = nearLive ? "LIVE" : timeFormat(t - hi);
      cross.style.left = `${x}px`;
      tip.style.left = `${x}px`;
    }

    function updateUI() {
      const { start, end } = getEdges(v);

      const percent = (v.currentTime - start) / (end - start);
      if (end - v.currentTime < LIVE_EPS) {
        rng.style.width = "100%";
      } else {
        rng.style.width = `${Math.min(100, percent * 100)}%`;
      }

      const totalTime = Math.max(0, end - start);
      tTotal.textContent = timeFormat(Math.min(totalTime, MAX_VIDEO_DURATION));

      const live = atLiveEdge(v.currentTime);
      if (live) {
        btn.textContent = "LIVE";
        btn.classList.add("live", live);
      } else {
        btn.textContent = timeFormat(v.currentTime - end + start - totalTime);
        btn.classList.remove("live");
      }
      if (lastEvt) renderAt(lastEvt.clientX);
    }

    function seekTo(val) {
      const { start, end } = getEdges(v);
      const lo = start,
        hi = end;
      v.currentTime = Math.min(hi, Math.max(lo, val));
    }

    slide.addEventListener("mousedown", seekFromClick);
    document.addEventListener("keydown", (e) => {
      if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
        e.preventDefault();
        e.stopPropagation();
        e.stopImmediatePropagation();

        wrap.classList.add("no-anim");

        if (e.key === "ArrowLeft") {
          seekTo(parseFloat(v.currentTime) - 5);
        } else if (e.key === "ArrowRight") {
          seekTo(parseFloat(v.currentTime) + 5);
        }

        requestAnimationFrame(() => wrap.classList.remove("no-anim"));
      }
    });

    btn.addEventListener("click", () => {
      const { end, ok } = getEdges(v);
      if (!ok) return;
      seekTo(end - EDGE_EPS);
    });

    slide.addEventListener(
      "pointerenter",
      (e) => {
        tip.classList.add("show");
        cross.classList.add("show");
        onMove(e);
      },
      { passive: true }
    );

    slide.addEventListener("pointermove", onMove, { passive: true });

    slide.addEventListener("pointerleave", () => {
      tip.classList.remove("show");
      cross.classList.remove("show");
    });

    function loopRVFC() {
      v.requestVideoFrameCallback(() => {
        updateUI();
        loopRVFC();
      });
    }
    function loopRAF() {
      updateUI();
      rafId = requestAnimationFrame(loopRAF);
    }

    [
      "loadedmetadata",
      "durationchange",
      "progress",
      "playing",
      "pause",
      "waiting",
      "seeked",
      "seeking",
      "ratechange",
      "timeupdate",
      "resize",
    ].forEach((ev) => v.addEventListener(ev, updateUI));

    updateUI();
    if (useRVFC) loopRVFC();
    else loopRAF();

    const mo = new MutationObserver(() => {
      if (!document.contains(v) || !document.contains(wrap)) {
        if (rafId) cancelAnimationFrame(rafId);
        wrap.remove();
        mo.disconnect();
      }
    });
    mo.observe(document.documentElement, { childList: true, subtree: true });

    v.__liveBarMounted = true;
  }

  function tryMountAll() {
    const hasLive = location.pathname.includes("/live/");
    if (!hasLive) return;
    document.querySelectorAll(VIDEO_ELEMENT_NAME).forEach((v) => {
      const bottom = findBottomContainer(v);
      if (bottom) mount(v);
    });
  }

  (function () {
    const fireLoc = () => setTimeout(tryMountAll, 0);
    const _ps = history.pushState,
      _rs = history.replaceState;
    history.pushState = function () {
      const r = _ps.apply(this, arguments);
      fireLoc();
      return r;
    };
    history.replaceState = function () {
      const r = _rs.apply(this, arguments);
      fireLoc();
      return r;
    };
    window.addEventListener("popstate", fireLoc);
  })();

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", tryMountAll, { once: true });
  } else {
    tryMountAll();
  }

  new MutationObserver((list) => {
    for (const m of list) {
      for (const n of m.addedNodes) {
        if (n.nodeType !== 1) continue;
        if (
          n.matches?.(VIDEO_ELEMENT_NAME) ||
          n.querySelector?.(VIDEO_ELEMENT_NAME)
        )
          tryMountAll();
        if (n.matches?.(BOTTOM_SEL) || n.querySelector?.(BOTTOM_SEL))
          tryMountAll();
      }
    }
  }).observe(document.documentElement, { childList: true, subtree: true });
})();