Greasy Fork is available in English.
타임머신으로 이전에 방송내용을 볼때 배속으로 볼수 있습니다. 라이브로 도착시 잠시 로딩이 걸립니다.
当前为
// ==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();
})();