// ==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 // @downloadURL https://update.greasyfork.icu/scripts/564657/SOOP%20-%20%ED%83%80%EC%9E%84%EB%A8%B8%EC%8B%A0%EC%9A%A9%20%EC%84%A4%EC%A0%95%EB%A9%94%EB%89%B4%20%EC%9E%AC%EC%83%9D%EC%86%8D%EB%8F%84.user.js // @updateURL https://update.greasyfork.icu/scripts/564657/SOOP%20-%20%ED%83%80%EC%9E%84%EB%A8%B8%EC%8B%A0%EC%9A%A9%20%EC%84%A4%EC%A0%95%EB%A9%94%EB%89%B4%20%EC%9E%AC%EC%83%9D%EC%86%8D%EB%8F%84.meta.js // ==/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(); })();