您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
치지직 라이브 방송 접속 시점부터의 최대 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 }); })();