Greasy Fork is available in English.
soop 방송 딜레이를 목표 시간 이내로 자동 보정
// ==UserScript==
// @name soop 방송 딜레이 자동 조정
// @namespace http://greasyfork.icu/ko/scripts/539405
// @version 2.4
// @description soop 방송 딜레이를 목표 시간 이내로 자동 보정
// @icon https://www.google.com/s2/favicons?sz=64&domain=www.sooplive.co.kr
// @author 다크초코
// @match https://play.sooplive.com/*
// @grant GM_registerMenuCommand
// @license MIT
// @contributor 감귤구이
// ==/UserScript==
(function () {
'use strict';
const OPTIONS = {
ENABLE_PER_CHANNEL_SETTINGS: true,
HIDE_UI_IN_FULLSCREEN: true,
SHOW_CLOSE_BUTTON: false,
SHOW_TOGGLE_BUTTON: true,
SHOW_TARGET_VALUE: true,
SHOW_DELAY_VALUE: true,
SHOW_RATE_VALUE: true,
PANEL_THEME: 'dark',
UI_MODE: 'modern',
DRAG_WITH_CTRL_ONLY: false,
UI_TOGGLE_SHORTCUT: {
enabled: true,
ctrl: true,
alt: false,
shift: false,
meta: false,
key: 'h'
}
};
const CONFIG = {
CHECK_INTERVAL_MS: 100, // 딜레이 체크 주기
HISTORY_DURATION_MS: 1000, // 최근 평균 딜레이 계산 구간
DEFAULT_TARGET_DELAY_MS: 1500, // 기본 목표 딜레이
START_THRESHOLD_MS: 50, // 목표 초과시 조정 시작 임계값 (첫 시작)
RESTART_THRESHOLD_MS: 200, // 목표 초과시 조정 재시작 임계값 (해제 후)
REVERSE_START_THRESHOLD_MS: 200, // 목표 미달시 역방향 조정 시작 임계값
CONSECUTIVE_REQUIRED: 3, // 연속 조건 충족 횟수
ADJUSTMENT_SPEED: 1, // 배속 조정 속도(1~5)
MAX_RATE: 1.5, // 최대 배속
MIN_RATE: 0.8, // 최소 배속
PRECISE_DEADZONE_MS: 50, // 정배속 고정 범위 1
WIDE_DEADZONE_MS: 200, // 정배속 고정 범위 2
RATE_CHANGE_TOLERANCE: 0.02, // 외부 배속 변경 감지 허용 오차
DISPLAY_UPDATE_MS: 100, // UI 표시 갱신 주기
URL_FALLBACK_CHECK_MS: 500, // URL 변경 fallback 체크 주기
RATE_UNLOCK_DELAY_MS: 60 // playbackRate 설정 후 보호 해제 대기시간
};
const STORAGE_KEYS = {
ENABLED: 'soop_delay_enabled',
TARGET_DELAY: 'soop_delay_target_ms',
PANEL_POS: 'soop_delay_panel_pos',
CHANNEL_TARGETS: 'soop_delay_channel_targets',
UI_VISIBLE: 'soop_delay_ui_visible',
SETTINGS: 'soop_delay_ui_settings'
};
const SPEED_MULTIPLIERS = {
1: 0.05,
2: 0.125,
3: 0.25,
4: 0.4,
5: 0.6
};
const DEFAULT_USER_SETTINGS = {
options: {
ENABLE_PER_CHANNEL_SETTINGS: OPTIONS.ENABLE_PER_CHANNEL_SETTINGS,
HIDE_UI_IN_FULLSCREEN: OPTIONS.HIDE_UI_IN_FULLSCREEN,
SHOW_CLOSE_BUTTON: OPTIONS.SHOW_CLOSE_BUTTON,
SHOW_TOGGLE_BUTTON: OPTIONS.SHOW_TOGGLE_BUTTON,
SHOW_TARGET_VALUE: OPTIONS.SHOW_TARGET_VALUE,
SHOW_DELAY_VALUE: OPTIONS.SHOW_DELAY_VALUE,
SHOW_RATE_VALUE: OPTIONS.SHOW_RATE_VALUE,
PANEL_THEME: OPTIONS.PANEL_THEME,
UI_MODE: OPTIONS.UI_MODE,
DRAG_WITH_CTRL_ONLY: OPTIONS.DRAG_WITH_CTRL_ONLY,
UI_TOGGLE_SHORTCUT: { ...OPTIONS.UI_TOGGLE_SHORTCUT }
},
config: {
ADJUSTMENT_SPEED: CONFIG.ADJUSTMENT_SPEED,
MIN_RATE: CONFIG.MIN_RATE,
MAX_RATE: CONFIG.MAX_RATE
}
};
const MAIN_DISPLAY_OPTION_KEYS = [
'SHOW_TOGGLE_BUTTON',
'SHOW_TARGET_VALUE',
'SHOW_DELAY_VALUE',
'SHOW_RATE_VALUE'
];
hydrateSavedSettings();
let video = null;
let intervalId = null;
let routeCheckId = null;
let isSettingRate = false;
let rateUnlockTimer = null;
let videoRateChangeHandler = null;
let delayHistory = [];
let delayHistorySum = 0;
let isEnabled = loadEnabled();
let isUiVisible = loadUiVisible();
let currentChannelId = getCurrentChannelId();
let targetDelayMs = loadTargetDelay();
let isAdjusting = false;
let isReverseAdjusting = false;
let hasBeenAdjusted = false;
let currentPlaybackRate = 1.0;
let lastDisplayUpdate = 0;
let lastKnownUrl = location.href;
let consecutiveOverCount = 0;
let consecutiveUnderCount = 0;
let consecutiveReverseCount = 0;
let consecutiveReverseStopCount = 0;
let initialized = false;
let historyHooked = false;
let isSettingsOpen = false;
function clamp(v, lo, hi) {
return v < lo ? lo : v > hi ? hi : v;
}
function nearlyEqual(a, b, eps = 0.0001) {
return Math.abs(a - b) <= eps;
}
function findVideo() {
return document.querySelector('video');
}
function bindVideo(nextVideo) {
if (video === nextVideo) return;
unbindVideo();
video = nextVideo;
if (!video) return;
attachRateGuard(video);
currentPlaybackRate = safeGetPlaybackRate(video);
}
function unbindVideo() {
if (video && videoRateChangeHandler) {
video.removeEventListener('ratechange', videoRateChangeHandler, true);
}
videoRateChangeHandler = null;
video = null;
}
function safeGetPlaybackRate(videoElement) {
try {
return videoElement ? videoElement.playbackRate : 1.0;
} catch {
return 1.0;
}
}
function calculateDelayMs(videoElement) {
if (!videoElement) return null;
try {
const buffered = videoElement.buffered;
if (!buffered || buffered.length <= 0) return null;
const end = buffered.end(buffered.length - 1);
const now = videoElement.currentTime;
const delaySec = end - now;
return delaySec >= 0 ? delaySec * 1000 : null;
} catch {
return null;
}
}
function clearDelayHistory() {
delayHistory.length = 0;
delayHistorySum = 0;
}
function pushDelayHistory(delayMs) {
const now = Date.now();
delayHistory.push({ delayMs, t: now });
delayHistorySum += delayMs;
const cutoff = now - CONFIG.HISTORY_DURATION_MS;
while (delayHistory.length > 0 && delayHistory[0].t < cutoff) {
delayHistorySum -= delayHistory[0].delayMs;
delayHistory.shift();
}
}
function getAverageDelayMs() {
return delayHistory.length > 0 ? delayHistorySum / delayHistory.length : 0;
}
function computeAutoRate(averageDelayMs, isCurrentlyAdjusting) {
const errorMs = averageDelayMs - targetDelayMs;
const absError = Math.abs(errorMs);
if (absError <= CONFIG.PRECISE_DEADZONE_MS) {
return 1.0;
}
if (!isCurrentlyAdjusting && absError <= CONFIG.WIDE_DEADZONE_MS) {
return 1.0;
}
const kp = SPEED_MULTIPLIERS[CONFIG.ADJUSTMENT_SPEED] || 0.125;
const errorSec = errorMs / 1000;
const rate = 1.0 + kp * errorSec;
return clamp(rate, CONFIG.MIN_RATE, CONFIG.MAX_RATE);
}
function getExpectedRate(avgMs) {
if (!isEnabled) return 1.0;
return computeAutoRate(avgMs, isAdjusting || isReverseAdjusting);
}
function setPlaybackRateSafely(rate) {
if (!video) return;
const nextRate = clamp(rate, CONFIG.MIN_RATE, CONFIG.MAX_RATE);
try {
if (nearlyEqual(video.playbackRate, nextRate, 0.0005)) {
currentPlaybackRate = nextRate;
return;
}
isSettingRate = true;
video.playbackRate = nextRate;
currentPlaybackRate = nextRate;
if (rateUnlockTimer) clearTimeout(rateUnlockTimer);
rateUnlockTimer = setTimeout(() => {
isSettingRate = false;
rateUnlockTimer = null;
}, CONFIG.RATE_UNLOCK_DELAY_MS);
} catch {
isSettingRate = false;
if (rateUnlockTimer) {
clearTimeout(rateUnlockTimer);
rateUnlockTimer = null;
}
}
}
function attachRateGuard(videoElement) {
if (!videoElement) return;
videoRateChangeHandler = () => {
if (!video || video !== videoElement || isSettingRate) return;
if (delayHistory.length === 0) return;
const avgMs = getAverageDelayMs();
const expectedRate = getExpectedRate(avgMs);
const actualRate = safeGetPlaybackRate(videoElement);
if (Math.abs(actualRate - expectedRate) > CONFIG.RATE_CHANGE_TOLERANCE) {
setPlaybackRateSafely(expectedRate);
} else {
currentPlaybackRate = actualRate;
}
};
videoElement.addEventListener('ratechange', videoRateChangeHandler, true);
}
function resetCounters() {
consecutiveOverCount = 0;
consecutiveUnderCount = 0;
consecutiveReverseCount = 0;
consecutiveReverseStopCount = 0;
}
function resetAdjustmentState(resetAdjustedFlag = true) {
isAdjusting = false;
isReverseAdjusting = false;
resetCounters();
if (resetAdjustedFlag) hasBeenAdjusted = false;
}
function formatRateText(rate) {
return `${rate.toFixed(3)}X`;
}
function renderInfo(avgMs) {
const now = Date.now();
if (now - lastDisplayUpdate < CONFIG.DISPLAY_UPDATE_MS) return;
lastDisplayUpdate = now;
const actualRate = video ? safeGetPlaybackRate(video) : 1.0;
const avgNode = document.getElementById('soop-delay-avg');
const rateNode = document.getElementById('soop-delay-rate');
if (avgNode) avgNode.textContent = `${avgMs.toFixed(0)}ms`;
if (rateNode) rateNode.textContent = formatRateText(actualRate);
}
function tick() {
const foundVideo = findVideo();
if (!foundVideo) {
if (video) {
unbindVideo();
clearDelayHistory();
resetAdjustmentState(false);
currentPlaybackRate = 1.0;
}
renderInfo(0);
return;
}
if (video !== foundVideo) {
bindVideo(foundVideo);
clearDelayHistory();
resetAdjustmentState(false);
}
const delayMs = calculateDelayMs(video);
if (delayMs == null) return;
pushDelayHistory(delayMs);
const avgMs = getAverageDelayMs();
renderInfo(avgMs);
if (!isEnabled) {
if (!nearlyEqual(currentPlaybackRate, 1.0, 0.0005) || isAdjusting || isReverseAdjusting) {
resetAdjustmentState(true);
setPlaybackRateSafely(1.0);
}
return;
}
const errorMs = avgMs - targetDelayMs;
const absErrorMs = Math.abs(errorMs);
const thresholdToUse = hasBeenAdjusted ? CONFIG.RESTART_THRESHOLD_MS : CONFIG.START_THRESHOLD_MS;
const avgOverTarget = avgMs > (targetDelayMs + thresholdToUse);
const avgFarUnderTarget = avgMs < (targetDelayMs - CONFIG.REVERSE_START_THRESHOLD_MS);
const inPreciseDeadzone = absErrorMs <= CONFIG.PRECISE_DEADZONE_MS;
if (!isAdjusting && !isReverseAdjusting) {
if (avgOverTarget) {
consecutiveOverCount++;
consecutiveUnderCount = 0;
consecutiveReverseCount = 0;
consecutiveReverseStopCount = 0;
if (consecutiveOverCount >= CONFIG.CONSECUTIVE_REQUIRED) {
isAdjusting = true;
isReverseAdjusting = false;
hasBeenAdjusted = true;
consecutiveUnderCount = 0;
consecutiveReverseStopCount = 0;
}
} else if (avgFarUnderTarget) {
consecutiveReverseCount++;
consecutiveOverCount = 0;
consecutiveUnderCount = 0;
consecutiveReverseStopCount = 0;
if (consecutiveReverseCount >= CONFIG.CONSECUTIVE_REQUIRED) {
isReverseAdjusting = true;
isAdjusting = false;
hasBeenAdjusted = true;
consecutiveUnderCount = 0;
consecutiveReverseStopCount = 0;
}
} else {
resetCounters();
}
} else if (isAdjusting) {
if (inPreciseDeadzone) {
consecutiveUnderCount++;
consecutiveOverCount = 0;
if (consecutiveUnderCount >= CONFIG.CONSECUTIVE_REQUIRED) {
isAdjusting = false;
consecutiveUnderCount = 0;
}
} else {
consecutiveUnderCount = 0;
}
} else if (isReverseAdjusting) {
if (inPreciseDeadzone) {
consecutiveReverseStopCount++;
consecutiveReverseCount = 0;
if (consecutiveReverseStopCount >= CONFIG.CONSECUTIVE_REQUIRED) {
isReverseAdjusting = false;
consecutiveReverseStopCount = 0;
}
} else {
consecutiveReverseStopCount = 0;
}
}
const desiredRate = getExpectedRate(avgMs);
setPlaybackRateSafely(desiredRate);
}
function start() {
stop();
intervalId = setInterval(tick, CONFIG.CHECK_INTERVAL_MS);
}
function stop() {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
}
function cleanup() {
stop();
clearDelayHistory();
resetAdjustmentState(true);
currentPlaybackRate = 1.0;
if (video) {
try {
video.playbackRate = 1.0;
} catch {}
}
unbindVideo();
if (rateUnlockTimer) {
clearTimeout(rateUnlockTimer);
rateUnlockTimer = null;
}
isSettingRate = false;
}
function loadEnabled() {
try {
const v = localStorage.getItem(STORAGE_KEYS.ENABLED);
return v == null ? true : v === '1';
} catch {
return true;
}
}
function saveEnabled(v) {
try {
localStorage.setItem(STORAGE_KEYS.ENABLED, v ? '1' : '0');
} catch {}
}
function loadUiVisible() {
try {
const v = localStorage.getItem(STORAGE_KEYS.UI_VISIBLE);
if (v == null) return true;
return v === '1';
} catch {
return true;
}
}
function saveUiVisible(v) {
try {
localStorage.setItem(STORAGE_KEYS.UI_VISIBLE, v ? '1' : '0');
} catch {}
}
function getCurrentChannelId() {
try {
const match = location.pathname.match(/\/([^\/]+)\/[^\/]+$/);
return match ? match[1] : null;
} catch {
return null;
}
}
function loadChannelTargets() {
try {
const data = localStorage.getItem(STORAGE_KEYS.CHANNEL_TARGETS) || '{}';
return JSON.parse(data);
} catch {
return {};
}
}
function saveChannelTargets(targets) {
try {
localStorage.setItem(STORAGE_KEYS.CHANNEL_TARGETS, JSON.stringify(targets));
} catch {}
}
function loadChannelTargetDelay(channelId) {
const targets = loadChannelTargets();
const delay = targets[channelId];
return (delay && delay >= 200 && delay <= 8000) ? delay : CONFIG.DEFAULT_TARGET_DELAY_MS;
}
function saveChannelTargetDelay(channelId, ms) {
const targets = loadChannelTargets();
targets[channelId] = ms;
saveChannelTargets(targets);
}
function loadTargetDelay() {
if (OPTIONS.ENABLE_PER_CHANNEL_SETTINGS && currentChannelId) {
return loadChannelTargetDelay(currentChannelId);
}
try {
const v = parseInt(localStorage.getItem(STORAGE_KEYS.TARGET_DELAY) || '', 10);
if (isFinite(v) && v >= 200 && v <= 8000) return v;
} catch {}
return CONFIG.DEFAULT_TARGET_DELAY_MS;
}
function saveTargetDelay(ms) {
if (OPTIONS.ENABLE_PER_CHANNEL_SETTINGS && currentChannelId) {
saveChannelTargetDelay(currentChannelId, ms);
} else {
try {
localStorage.setItem(STORAGE_KEYS.TARGET_DELAY, String(ms));
} catch {}
}
}
function loadPanelPos() {
try {
const pos = JSON.parse(localStorage.getItem(STORAGE_KEYS.PANEL_POS) || 'null');
if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
return { x: pos.x, y: pos.y };
}
} catch {}
return null;
}
function savePanelPos(x, y) {
try {
localStorage.setItem(STORAGE_KEYS.PANEL_POS, JSON.stringify({ x, y }));
} catch {}
}
function clearPanelPos() {
try {
localStorage.removeItem(STORAGE_KEYS.PANEL_POS);
} catch {}
}
function normalizeThemeName(theme) {
return theme === 'light' ? 'light' : 'dark';
}
function normalizeUiMode(mode) {
return mode === 'legacy' ? 'legacy' : 'modern';
}
function isLegacyUiMode() {
return normalizeUiMode(OPTIONS.UI_MODE) === 'legacy';
}
function normalizeShortcutKey(key, fallback = 'h') {
const next = String(key == null ? fallback : key).trim();
return next ? next.slice(0, 12) : fallback;
}
function cloneShortcutSettings(shortcut) {
return {
enabled: !!shortcut.enabled,
ctrl: !!shortcut.ctrl,
alt: !!shortcut.alt,
shift: !!shortcut.shift,
meta: !!shortcut.meta,
key: normalizeShortcutKey(shortcut.key, 'h')
};
}
function normalizeShortcutSettings(shortcut) {
return cloneShortcutSettings({
enabled: shortcut && typeof shortcut.enabled === 'boolean' ? shortcut.enabled : OPTIONS.UI_TOGGLE_SHORTCUT.enabled,
ctrl: shortcut && typeof shortcut.ctrl === 'boolean' ? shortcut.ctrl : OPTIONS.UI_TOGGLE_SHORTCUT.ctrl,
alt: shortcut && typeof shortcut.alt === 'boolean' ? shortcut.alt : OPTIONS.UI_TOGGLE_SHORTCUT.alt,
shift: shortcut && typeof shortcut.shift === 'boolean' ? shortcut.shift : OPTIONS.UI_TOGGLE_SHORTCUT.shift,
meta: shortcut && typeof shortcut.meta === 'boolean' ? shortcut.meta : OPTIONS.UI_TOGGLE_SHORTCUT.meta,
key: shortcut && shortcut.key != null ? shortcut.key : OPTIONS.UI_TOGGLE_SHORTCUT.key
});
}
function normalizePlaybackConfig() {
const speedLevel = parseInt(CONFIG.ADJUSTMENT_SPEED, 10);
CONFIG.ADJUSTMENT_SPEED = clamp(isFinite(speedLevel) ? speedLevel : 3, 1, 5);
const minRate = Number(CONFIG.MIN_RATE);
const maxRate = Number(CONFIG.MAX_RATE);
CONFIG.MIN_RATE = clamp(isFinite(minRate) ? minRate : 0.8, 0.5, 1.0);
CONFIG.MAX_RATE = clamp(isFinite(maxRate) ? maxRate : 1.5, 1.0, 3.0);
if (CONFIG.MIN_RATE > CONFIG.MAX_RATE) {
CONFIG.MAX_RATE = CONFIG.MIN_RATE;
}
}
function hydrateSavedSettings() {
try {
const raw = localStorage.getItem(STORAGE_KEYS.SETTINGS);
if (!raw) return;
const saved = JSON.parse(raw);
if (!saved || typeof saved !== 'object') return;
if (saved.options && typeof saved.options === 'object') {
if (typeof saved.options.ENABLE_PER_CHANNEL_SETTINGS === 'boolean') {
OPTIONS.ENABLE_PER_CHANNEL_SETTINGS = saved.options.ENABLE_PER_CHANNEL_SETTINGS;
}
if (typeof saved.options.HIDE_UI_IN_FULLSCREEN === 'boolean') {
OPTIONS.HIDE_UI_IN_FULLSCREEN = saved.options.HIDE_UI_IN_FULLSCREEN;
}
if (typeof saved.options.SHOW_CLOSE_BUTTON === 'boolean') {
OPTIONS.SHOW_CLOSE_BUTTON = saved.options.SHOW_CLOSE_BUTTON;
}
if (typeof saved.options.SHOW_TOGGLE_BUTTON === 'boolean') {
OPTIONS.SHOW_TOGGLE_BUTTON = saved.options.SHOW_TOGGLE_BUTTON;
}
if (typeof saved.options.SHOW_TARGET_VALUE === 'boolean') {
OPTIONS.SHOW_TARGET_VALUE = saved.options.SHOW_TARGET_VALUE;
}
if (typeof saved.options.SHOW_DELAY_VALUE === 'boolean') {
OPTIONS.SHOW_DELAY_VALUE = saved.options.SHOW_DELAY_VALUE;
}
if (typeof saved.options.SHOW_RATE_VALUE === 'boolean') {
OPTIONS.SHOW_RATE_VALUE = saved.options.SHOW_RATE_VALUE;
}
if (typeof saved.options.DRAG_WITH_CTRL_ONLY === 'boolean') {
OPTIONS.DRAG_WITH_CTRL_ONLY = saved.options.DRAG_WITH_CTRL_ONLY;
}
OPTIONS.PANEL_THEME = normalizeThemeName(saved.options.PANEL_THEME);
OPTIONS.UI_MODE = normalizeUiMode(saved.options.UI_MODE);
OPTIONS.UI_TOGGLE_SHORTCUT = normalizeShortcutSettings(saved.options.UI_TOGGLE_SHORTCUT);
}
if (saved.config && typeof saved.config === 'object') {
if (saved.config.ADJUSTMENT_SPEED != null) {
CONFIG.ADJUSTMENT_SPEED = saved.config.ADJUSTMENT_SPEED;
}
if (saved.config.MIN_RATE != null) {
CONFIG.MIN_RATE = saved.config.MIN_RATE;
}
if (saved.config.MAX_RATE != null) {
CONFIG.MAX_RATE = saved.config.MAX_RATE;
}
}
normalizePlaybackConfig();
} catch {}
}
function saveUserSettings() {
normalizePlaybackConfig();
OPTIONS.PANEL_THEME = normalizeThemeName(OPTIONS.PANEL_THEME);
OPTIONS.UI_MODE = normalizeUiMode(OPTIONS.UI_MODE);
OPTIONS.UI_TOGGLE_SHORTCUT = normalizeShortcutSettings(OPTIONS.UI_TOGGLE_SHORTCUT);
try {
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify({
options: {
ENABLE_PER_CHANNEL_SETTINGS: OPTIONS.ENABLE_PER_CHANNEL_SETTINGS,
HIDE_UI_IN_FULLSCREEN: OPTIONS.HIDE_UI_IN_FULLSCREEN,
SHOW_CLOSE_BUTTON: OPTIONS.SHOW_CLOSE_BUTTON,
SHOW_TOGGLE_BUTTON: OPTIONS.SHOW_TOGGLE_BUTTON,
SHOW_TARGET_VALUE: OPTIONS.SHOW_TARGET_VALUE,
SHOW_DELAY_VALUE: OPTIONS.SHOW_DELAY_VALUE,
SHOW_RATE_VALUE: OPTIONS.SHOW_RATE_VALUE,
PANEL_THEME: OPTIONS.PANEL_THEME,
UI_MODE: OPTIONS.UI_MODE,
DRAG_WITH_CTRL_ONLY: OPTIONS.DRAG_WITH_CTRL_ONLY,
UI_TOGGLE_SHORTCUT: cloneShortcutSettings(OPTIONS.UI_TOGGLE_SHORTCUT)
},
config: {
ADJUSTMENT_SPEED: CONFIG.ADJUSTMENT_SPEED,
MIN_RATE: CONFIG.MIN_RATE,
MAX_RATE: CONFIG.MAX_RATE
}
}));
} catch {}
}
function resetUserSettings() {
OPTIONS.ENABLE_PER_CHANNEL_SETTINGS = DEFAULT_USER_SETTINGS.options.ENABLE_PER_CHANNEL_SETTINGS;
OPTIONS.HIDE_UI_IN_FULLSCREEN = DEFAULT_USER_SETTINGS.options.HIDE_UI_IN_FULLSCREEN;
OPTIONS.SHOW_CLOSE_BUTTON = DEFAULT_USER_SETTINGS.options.SHOW_CLOSE_BUTTON;
OPTIONS.SHOW_TOGGLE_BUTTON = DEFAULT_USER_SETTINGS.options.SHOW_TOGGLE_BUTTON;
OPTIONS.SHOW_TARGET_VALUE = DEFAULT_USER_SETTINGS.options.SHOW_TARGET_VALUE;
OPTIONS.SHOW_DELAY_VALUE = DEFAULT_USER_SETTINGS.options.SHOW_DELAY_VALUE;
OPTIONS.SHOW_RATE_VALUE = DEFAULT_USER_SETTINGS.options.SHOW_RATE_VALUE;
OPTIONS.PANEL_THEME = DEFAULT_USER_SETTINGS.options.PANEL_THEME;
OPTIONS.UI_MODE = DEFAULT_USER_SETTINGS.options.UI_MODE;
OPTIONS.DRAG_WITH_CTRL_ONLY = DEFAULT_USER_SETTINGS.options.DRAG_WITH_CTRL_ONLY;
OPTIONS.UI_TOGGLE_SHORTCUT = normalizeShortcutSettings(DEFAULT_USER_SETTINGS.options.UI_TOGGLE_SHORTCUT);
CONFIG.ADJUSTMENT_SPEED = DEFAULT_USER_SETTINGS.config.ADJUSTMENT_SPEED;
CONFIG.MIN_RATE = DEFAULT_USER_SETTINGS.config.MIN_RATE;
CONFIG.MAX_RATE = DEFAULT_USER_SETTINGS.config.MAX_RATE;
normalizePlaybackConfig();
saveUserSettings();
}
function countEnabledMainDisplayItems(nextValues = {}) {
return MAIN_DISPLAY_OPTION_KEYS.reduce((count, key) => {
const enabled = Object.prototype.hasOwnProperty.call(nextValues, key) ? nextValues[key] : OPTIONS[key];
return count + (enabled ? 1 : 0);
}, 0);
}
function updateMainDisplayOption(optionKey, checked, checkboxInput) {
if (!checked && countEnabledMainDisplayItems({ [optionKey]: false }) < 1) {
if (checkboxInput) checkboxInput.checked = true;
return;
}
OPTIONS[optionKey] = checked;
saveUserSettings();
rebuildPanel();
}
function restartAdjustmentCycle(resetPlayback = true) {
clearDelayHistory();
resetAdjustmentState(true);
if (resetPlayback && video) {
setPlaybackRateSafely(1.0);
}
}
function syncPlaybackFromCurrentState() {
if (!video) return;
if (!isEnabled || delayHistory.length === 0) {
setPlaybackRateSafely(1.0);
return;
}
setPlaybackRateSafely(getExpectedRate(getAverageDelayMs()));
}
function getPanelElement() {
return document.getElementById('soop-delay-panel') || document.getElementById('delay-info');
}
function removePanels() {
['soop-delay-panel', 'delay-info'].forEach((id) => {
const node = document.getElementById(id);
if (node) node.remove();
});
}
function rebuildPanel() {
removePanels();
createPanel();
lastDisplayUpdate = 0;
renderInfo(getAverageDelayMs());
}
function saveCurrentPanelPosition(panel) {
try {
if (isLegacyUiMode()) return;
if (!panel || !panel.isConnected || panel.getClientRects().length === 0) return;
const rect = panel.getBoundingClientRect();
savePanelPos(rect.left, rect.top);
} catch {}
}
function stabilizePanelAfterContentResize(panel, previousRect) {
try {
if (!panel || !previousRect || !panel.isConnected || panel.getClientRects().length === 0) {
return;
}
const isTopAnchored = !!panel.style.top && panel.style.top !== 'auto';
if (isTopAnchored) {
const desiredTop = previousRect.top;
if (Number.isFinite(desiredTop)) {
panel.style.top = `${desiredTop}px`;
panel.style.bottom = 'auto';
}
ensurePanelInViewport(panel);
saveCurrentPanelPosition(panel);
return;
}
ensurePanelInViewport(panel);
} catch {
ensurePanelInViewport(panel);
}
}
function getPanelThemeStyle() {
if (isLegacyUiMode()) {
return {
panelBg: 'rgba(0,0,0,0.7)',
panelBorder: 'none',
panelColor: '#ffffff',
inputBg: 'rgba(255,255,255,0.08)',
inputColor: '#ffffff',
inputBorder: '1px solid rgba(255,255,255,0.25)',
switchOn: 'rgba(46, 204, 113, 0.85)',
switchOff: 'rgba(255,255,255,0.15)',
switchBorder: '1px solid rgba(255,255,255,0.25)',
knobBg: '#ffffff',
closeColor: 'rgba(255,255,255,0.52)',
closeHover: '#ffffff',
boxShadow: 'none',
subtleText: 'rgba(255,255,255,0.82)',
divider: 'rgba(255,255,255,0.12)',
buttonBg: 'rgba(255,255,255,0.08)',
buttonHoverBg: 'rgba(255,255,255,0.12)',
buttonActiveBg: 'rgba(255,255,255,0.16)',
buttonBorder: '1px solid rgba(255,255,255,0.20)',
settingsBg: 'rgba(0,0,0,0.86)',
optionBg: '#101010',
optionColor: '#ffffff',
controlScheme: 'dark'
};
}
if (normalizeThemeName(OPTIONS.PANEL_THEME) === 'dark') {
return {
panelBg: 'rgba(18,18,20,0.82)',
panelBorder: '1px solid rgba(255,255,255,0.12)',
panelColor: '#f5f5f7',
inputBg: 'rgba(255,255,255,0.06)',
inputColor: '#f5f5f7',
inputBorder: '1px solid rgba(255,255,255,0.14)',
switchOn: '#4ade80',
switchOff: 'rgba(255,255,255,0.14)',
switchBorder: '1px solid rgba(255,255,255,0.14)',
knobBg: '#ffffff',
closeColor: 'rgba(255,255,255,0.42)',
closeHover: '#ffffff',
boxShadow: '0 10px 30px rgba(0,0,0,0.28)',
subtleText: 'rgba(255,255,255,0.72)',
divider: 'rgba(255,255,255,0.10)',
buttonBg: 'rgba(255,255,255,0.06)',
buttonHoverBg: 'rgba(255,255,255,0.12)',
buttonActiveBg: 'rgba(255,255,255,0.16)',
buttonBorder: '1px solid rgba(255,255,255,0.12)',
settingsBg: 'rgba(255,255,255,0.03)',
optionBg: '#1b1b1f',
optionColor: '#f5f5f7',
controlScheme: 'dark'
};
}
return {
panelBg: 'rgba(250,250,252,0.88)',
panelBorder: '1px solid rgba(15,23,42,0.08)',
panelColor: '#0f172a',
inputBg: 'rgba(255,255,255,0.78)',
inputColor: '#111827',
inputBorder: '1px solid rgba(15,23,42,0.10)',
switchOn: '#2563eb',
switchOff: 'rgba(148,163,184,0.22)',
switchBorder: '1px solid rgba(15,23,42,0.10)',
knobBg: '#ffffff',
closeColor: 'rgba(15,23,42,0.35)',
closeHover: '#0f172a',
boxShadow: '0 8px 24px rgba(15,23,42,0.10)',
subtleText: 'rgba(15,23,42,0.72)',
divider: 'rgba(15,23,42,0.08)',
buttonBg: 'rgba(255,255,255,0.74)',
buttonHoverBg: 'rgba(226,232,240,0.78)',
buttonActiveBg: 'rgba(191,219,254,0.72)',
buttonBorder: '1px solid rgba(15,23,42,0.10)',
settingsBg: 'rgba(255,255,255,0.35)',
optionBg: '#ffffff',
optionColor: '#111827',
controlScheme: 'light'
};
}
function getPanelLayoutMetrics() {
if (isLegacyUiMode()) {
return {
panelOffset: '10px',
panelGap: '0',
panelPadding: '3px 5px',
panelRadius: '3px',
panelFont: '7pt/1.2 monospace',
panelBackdropFilter: 'none',
panelMaxWidth: 'min(92vw, 500px)',
panelOpacity: '0.8',
mainRowGap: '0',
fieldRadius: '2px',
fieldPadding: '0 2px',
fieldHeight: '16px',
fieldFontSize: '9px',
selectPaddingRight: '14px',
actionPadding: '0 0 0 5px',
actionHeight: 'auto',
actionRadius: '0',
actionFontSize: '7pt',
checkboxGap: '2px',
checkboxFontSize: '9px',
settingsRowGap: '3px',
settingsTitleMinWidth: '30px',
settingsTitleFontSize: '9px',
switchWidth: '28px',
switchHeight: '16px',
switchKnobSize: '12px',
switchKnobOnLeft: '13px',
targetInputWidth: '46px',
avgValueMinWidth: '32px',
rateValueMinWidth: '34px',
settingsWrapGap: '3px',
settingsWrapRadius: '3px',
settingsWrapPadding: '4px',
settingsButtonMarginLeft: '5px',
closeButtonMarginLeft: '5px',
uiModeSelectWidth: '70px',
themeSelectWidth: '68px',
resetButtonHeight: '16px',
resetButtonPadding: '0 4px',
resetButtonRadius: '3px',
resetButtonFontSize: '9px',
shortcutKeyWidth: '34px',
speedSelectWidth: '60px',
rateInputWidth: '40px'
};
}
return {
panelOffset: '12px',
panelGap: '6px',
panelPadding: '4px 6px',
panelRadius: '8px',
panelFont: '10px/1.2 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans KR",sans-serif',
panelBackdropFilter: 'blur(10px)',
panelMaxWidth: 'min(92vw, 560px)',
panelOpacity: '1',
mainRowGap: '4px',
fieldRadius: '6px',
fieldPadding: '1px 4px',
fieldHeight: '18px',
fieldFontSize: '10px',
selectPaddingRight: '14px',
actionPadding: '0 4px',
actionHeight: '18px',
actionRadius: '6px',
actionFontSize: '10px',
checkboxGap: '4px',
checkboxFontSize: '10px',
settingsRowGap: '6px',
settingsTitleMinWidth: '40px',
settingsTitleFontSize: '10px',
switchWidth: '32px',
switchHeight: '18px',
switchKnobSize: '14px',
switchKnobOnLeft: '15px',
targetInputWidth: '55px',
avgValueMinWidth: '42px',
rateValueMinWidth: '44px',
settingsWrapGap: '6px',
settingsWrapRadius: '8px',
settingsWrapPadding: '6px',
settingsButtonMarginLeft: '1px',
closeButtonMarginLeft: '0',
uiModeSelectWidth: '76px',
themeSelectWidth: '78px',
resetButtonHeight: '18px',
resetButtonPadding: '0 6px',
resetButtonRadius: '6px',
resetButtonFontSize: '10px',
shortcutKeyWidth: '48px',
speedSelectWidth: '72px',
rateInputWidth: '52px'
};
}
function applyPanelVisibility() {
const panel = getPanelElement();
if (!panel) return;
const fs = !!(
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement
);
const shouldHideForFs = OPTIONS.HIDE_UI_IN_FULLSCREEN && fs;
const visible = isUiVisible && !shouldHideForFs;
panel.style.display = visible ? 'flex' : 'none';
if (visible) ensurePanelInViewport(panel);
}
function handleFullscreenChange() {
applyPanelVisibility();
}
function createPanel() {
if (getPanelElement()) return;
const theme = getPanelThemeStyle();
const ui = getPanelLayoutMetrics();
const isLegacyUi = isLegacyUiMode();
if (isLegacyUi) {
const panel = document.createElement('div');
panel.id = 'soop-delay-panel';
panel.style.cssText = [
'position: fixed',
'right: 10px',
'bottom: 10px',
'display: flex',
'flex-direction: column',
'align-items: stretch',
'gap: 3px',
'padding: 3px 4px',
'border-radius: 4px',
'background: rgba(0,0,0,0.75)',
'color: #fff',
'font: 10px/1.2 monospace',
'font-variant-numeric: tabular-nums',
'z-index: 10000',
'user-select: none',
'cursor: default',
'white-space: nowrap'
].join(';');
const mainRow = document.createElement('div');
mainRow.style.cssText = [
'display: flex',
'align-items: center',
'gap: 2px',
'white-space: nowrap'
].join(';');
panel.appendChild(mainRow);
let isDragging = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
panel.addEventListener('mousedown', (e) => {
try {
if (e.target instanceof Element && e.target.closest('button, input, select, label, [data-no-drag="1"]')) return;
if (OPTIONS.DRAG_WITH_CTRL_ONLY && !e.ctrlKey) return;
} catch {}
isDragging = true;
const rect = panel.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left;
dragOffsetY = e.clientY - rect.top;
e.preventDefault();
});
const handleMove = (e) => {
if (!isDragging) return;
const x = Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, e.clientX - dragOffsetX));
const y = Math.max(0, Math.min(window.innerHeight - panel.offsetHeight, e.clientY - dragOffsetY));
panel.style.left = `${x}px`;
panel.style.top = `${y}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
};
const handleUp = () => {
if (!isDragging) return;
isDragging = false;
const rect = panel.getBoundingClientRect();
savePanelPos(rect.left, rect.top);
ensurePanelInViewport(panel);
};
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleUp);
let switchState = isEnabled;
const switchBtn = document.createElement('button');
switchBtn.type = 'button';
switchBtn.style.cssText = [
'position: relative',
'width: 32px',
'height: 18px',
'border-radius: 9px',
'border: 1px solid rgba(255,255,255,0.25)',
'padding: 0',
'background: transparent',
'cursor: pointer'
].join(';');
const knob = document.createElement('span');
knob.style.cssText = [
'position: absolute',
'top: 1px',
'left: 1px',
'width: 14px',
'height: 14px',
'border-radius: 50%',
'background: #fff',
'transition: left 120ms ease'
].join(';');
switchBtn.appendChild(knob);
function updateSwitch() {
switchBtn.style.background = switchState ? 'rgba(46, 204, 113, 0.85)' : 'rgba(255,255,255,0.15)';
knob.style.left = switchState ? '16px' : '1px';
}
updateSwitch();
switchBtn.addEventListener('click', (e) => {
switchState = !switchState;
isEnabled = switchState;
saveEnabled(isEnabled);
updateSwitch();
if (!isEnabled) setPlaybackRateSafely(1.0);
hasBeenAdjusted = false;
consecutiveOverCount = 0;
consecutiveUnderCount = 0;
e.preventDefault();
e.stopPropagation();
ensurePanelInViewport(panel);
});
const targetInput = document.createElement('input');
targetInput.id = 'soop-delay-target-input';
targetInput.type = 'number';
targetInput.min = '200';
targetInput.max = '8000';
targetInput.step = '50';
targetInput.value = String(targetDelayMs);
targetInput.style.width = '55px';
targetInput.style.color = '#fff';
targetInput.style.background = 'rgba(255,255,255,0.08)';
targetInput.style.border = '1px solid rgba(255,255,255,0.25)';
targetInput.style.borderRadius = '3px';
targetInput.style.padding = '1px 3px';
targetInput.style.height = '18px';
targetInput.style.fontSize = '10px';
targetInput.style.boxSizing = 'border-box';
targetInput.style.outline = 'none';
targetInput.style.caretColor = '#fff';
targetInput.addEventListener('input', () => {
let v = parseInt(targetInput.value || '0', 10);
if (!isFinite(v)) return;
v = clamp(v, 200, 8000);
targetDelayMs = v;
saveTargetDelay(v);
hasBeenAdjusted = false;
consecutiveOverCount = 0;
consecutiveUnderCount = 0;
});
let preservedCompositionState = null;
targetInput.addEventListener('focus', () => {
if (preservedCompositionState) {
try {
const selection = window.getSelection();
if (selection && preservedCompositionState.range) {
selection.removeAllRanges();
selection.addRange(preservedCompositionState.range);
}
} catch {}
}
});
targetInput.addEventListener('blur', () => {
try {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
preservedCompositionState = {
range: selection.getRangeAt(0).cloneRange(),
composition: document.querySelector('input:focus') === targetInput
};
}
} catch {}
});
targetInput.addEventListener('compositionstart', () => {
preservedCompositionState = { composing: true };
});
targetInput.addEventListener('compositionend', () => {
if (preservedCompositionState) {
preservedCompositionState.composing = false;
}
});
const msText = document.createElement('span');
msText.textContent = 'ms';
const avgVal = document.createElement('span');
avgVal.id = 'soop-delay-avg';
avgVal.textContent = '-ms';
avgVal.style.display = 'inline-block';
avgVal.style.minWidth = '24px';
avgVal.style.textAlign = 'right';
const rateVal = document.createElement('span');
rateVal.id = 'soop-delay-rate';
rateVal.textContent = '1.00X';
rateVal.style.display = 'inline-block';
rateVal.style.minWidth = '22px';
rateVal.style.textAlign = 'right';
function applyLegacyFieldStyle(el, width, extra = []) {
el.dataset.noDrag = '1';
el.style.cssText = [
`width: ${width}`,
`color: ${theme.inputColor}`,
`background: ${theme.inputBg}`,
`border: ${theme.inputBorder}`,
`border-radius: ${ui.fieldRadius}`,
`padding: ${ui.fieldPadding}`,
`height: ${ui.fieldHeight}`,
`font-size: ${ui.fieldFontSize}`,
'font-weight: 500',
'box-sizing: border-box',
'outline: none',
...extra
].join(';');
el.style.caretColor = theme.inputColor;
el.style.colorScheme = theme.controlScheme;
}
function applyLegacySelectStyle(selectEl, width) {
applyLegacyFieldStyle(selectEl, width, [`padding-right: ${ui.selectPaddingRight}`, 'appearance: auto']);
Array.from(selectEl.options || []).forEach((option) => {
option.style.color = theme.optionColor;
option.style.backgroundColor = theme.optionBg;
});
}
function setLegacyActionButtonState(button, active = false) {
button.dataset.active = active ? '1' : '0';
button.style.background = 'transparent';
button.style.color = active ? theme.panelColor : theme.closeColor;
}
function createLegacyActionButton(text, title, active = false) {
const button = document.createElement('button');
button.type = 'button';
button.dataset.noDrag = '1';
button.textContent = text;
button.title = title;
button.style.cssText = [
'cursor: pointer',
`padding: ${ui.actionPadding}`,
`height: ${ui.actionHeight}`,
`border-radius: ${ui.actionRadius}`,
'border: none',
'background: transparent',
`color: ${theme.closeColor}`,
`font-size: ${ui.actionFontSize}`,
'font-weight: 700',
'line-height: 1',
'transition: color 0.15s ease'
].join(';');
setLegacyActionButtonState(button, active);
button.addEventListener('mouseenter', () => {
button.style.color = theme.closeHover;
});
button.addEventListener('mouseleave', () => {
setLegacyActionButtonState(button, button.dataset.active === '1');
});
return button;
}
function createLegacyCheckbox(labelText, checked, onChange) {
const label = document.createElement('label');
label.dataset.noDrag = '1';
label.style.cssText = [
'display: inline-flex',
'align-items: center',
`gap: ${ui.checkboxGap}`,
'cursor: pointer',
`font-size: ${ui.checkboxFontSize}`,
`color: ${theme.subtleText}`
].join(';');
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = !!checked;
input.dataset.noDrag = '1';
input.style.accentColor = theme.switchOn;
input.addEventListener('change', () => onChange(input.checked));
const text = document.createElement('span');
text.textContent = labelText;
label.appendChild(input);
label.appendChild(text);
return { label, input };
}
function createLegacySettingsRow(title) {
const row = document.createElement('div');
row.dataset.noDrag = '1';
row.style.cssText = [
'display: flex',
'align-items: center',
'flex-wrap: wrap',
`gap: ${ui.settingsRowGap}`
].join(';');
const titleNode = document.createElement('span');
titleNode.textContent = title;
titleNode.style.cssText = [
'display: inline-flex',
'align-items: center',
`min-width: ${ui.settingsTitleMinWidth}`,
`font-size: ${ui.settingsTitleFontSize}`,
`color: ${theme.subtleText}`,
'font-weight: 600'
].join(';');
row.appendChild(titleNode);
return row;
}
const settingsWrap = document.createElement('div');
settingsWrap.dataset.noDrag = '1';
settingsWrap.style.cssText = [
`display: ${isSettingsOpen ? 'flex' : 'none'}`,
'flex-direction: column',
`gap: ${ui.settingsWrapGap}`,
`border-top: 1px solid ${theme.divider}`,
`background: ${theme.settingsBg}`,
`border-radius: ${ui.settingsWrapRadius}`,
`padding: ${ui.settingsWrapPadding}`
].join(';');
const settingsBtn = createLegacyActionButton('⚙', '설정 열기/닫기', isSettingsOpen);
settingsBtn.style.marginLeft = ui.settingsButtonMarginLeft;
settingsBtn.addEventListener('click', () => {
const previousRect = panel.getBoundingClientRect();
isSettingsOpen = !isSettingsOpen;
settingsWrap.style.display = isSettingsOpen ? 'flex' : 'none';
setLegacyActionButtonState(settingsBtn, isSettingsOpen);
stabilizePanelAfterContentResize(panel, previousRect);
});
const closeBtn = createLegacyActionButton('✖', '패널 숨기기');
closeBtn.style.marginLeft = ui.closeButtonMarginLeft;
closeBtn.addEventListener('click', () => {
isUiVisible = false;
saveUiVisible(false);
applyPanelVisibility();
});
if (OPTIONS.SHOW_TOGGLE_BUTTON) {
mainRow.appendChild(switchBtn);
}
if (OPTIONS.SHOW_TARGET_VALUE) {
mainRow.appendChild(document.createTextNode(' 목표:'));
mainRow.appendChild(targetInput);
mainRow.appendChild(msText);
}
if (OPTIONS.SHOW_DELAY_VALUE) {
mainRow.appendChild(document.createTextNode(' 딜레이:'));
mainRow.appendChild(avgVal);
}
if (OPTIONS.SHOW_RATE_VALUE) {
mainRow.appendChild(document.createTextNode(' 배속:'));
mainRow.appendChild(rateVal);
}
mainRow.appendChild(settingsBtn);
if (OPTIONS.SHOW_CLOSE_BUTTON) {
mainRow.appendChild(closeBtn);
}
const optionRow = createLegacySettingsRow('옵션');
const perChannelOption = createLegacyCheckbox('채널별 목표', OPTIONS.ENABLE_PER_CHANNEL_SETTINGS, (checked) => {
OPTIONS.ENABLE_PER_CHANNEL_SETTINGS = checked;
saveUserSettings();
targetDelayMs = loadTargetDelay();
targetInput.value = String(targetDelayMs);
restartAdjustmentCycle(true);
});
const fullscreenOption = createLegacyCheckbox('전체화면 숨김', OPTIONS.HIDE_UI_IN_FULLSCREEN, (checked) => {
OPTIONS.HIDE_UI_IN_FULLSCREEN = checked;
saveUserSettings();
applyPanelVisibility();
});
const dragOption = createLegacyCheckbox('Ctrl 드래그', OPTIONS.DRAG_WITH_CTRL_ONLY, (checked) => {
OPTIONS.DRAG_WITH_CTRL_ONLY = checked;
saveUserSettings();
});
const closeButtonOption = createLegacyCheckbox('닫기 버튼', OPTIONS.SHOW_CLOSE_BUTTON, (checked) => {
OPTIONS.SHOW_CLOSE_BUTTON = checked;
saveUserSettings();
rebuildPanel();
});
optionRow.appendChild(perChannelOption.label);
optionRow.appendChild(fullscreenOption.label);
optionRow.appendChild(dragOption.label);
optionRow.appendChild(closeButtonOption.label);
settingsWrap.appendChild(optionRow);
const displayRow = createLegacySettingsRow('표시');
let toggleVisibilityOption;
toggleVisibilityOption = createLegacyCheckbox('토글', OPTIONS.SHOW_TOGGLE_BUTTON, (checked) => {
updateMainDisplayOption('SHOW_TOGGLE_BUTTON', checked, toggleVisibilityOption.input);
});
let targetVisibilityOption;
targetVisibilityOption = createLegacyCheckbox('목표값', OPTIONS.SHOW_TARGET_VALUE, (checked) => {
updateMainDisplayOption('SHOW_TARGET_VALUE', checked, targetVisibilityOption.input);
});
let delayVisibilityOption;
delayVisibilityOption = createLegacyCheckbox('딜레이', OPTIONS.SHOW_DELAY_VALUE, (checked) => {
updateMainDisplayOption('SHOW_DELAY_VALUE', checked, delayVisibilityOption.input);
});
let rateVisibilityOption;
rateVisibilityOption = createLegacyCheckbox('배속', OPTIONS.SHOW_RATE_VALUE, (checked) => {
updateMainDisplayOption('SHOW_RATE_VALUE', checked, rateVisibilityOption.input);
});
displayRow.appendChild(toggleVisibilityOption.label);
displayRow.appendChild(targetVisibilityOption.label);
displayRow.appendChild(delayVisibilityOption.label);
displayRow.appendChild(rateVisibilityOption.label);
settingsWrap.appendChild(displayRow);
const uiRow = createLegacySettingsRow('UI');
const uiModeSelect = document.createElement('select');
[['modern', 'Modern'], ['legacy', 'Legacy']].forEach(([value, label]) => {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
uiModeSelect.appendChild(option);
});
applyLegacySelectStyle(uiModeSelect, ui.uiModeSelectWidth);
uiModeSelect.value = normalizeUiMode(OPTIONS.UI_MODE);
uiModeSelect.addEventListener('change', () => {
OPTIONS.UI_MODE = normalizeUiMode(uiModeSelect.value);
if (OPTIONS.UI_MODE === 'legacy') {
isSettingsOpen = false;
}
saveUserSettings();
rebuildPanel();
});
uiRow.appendChild(uiModeSelect);
settingsWrap.appendChild(uiRow);
const themeRow = createLegacySettingsRow('테마');
const themeSelect = document.createElement('select');
[['dark', 'Dark'], ['light', 'Light']].forEach(([value, label]) => {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
themeSelect.appendChild(option);
});
applyLegacySelectStyle(themeSelect, ui.themeSelectWidth);
themeSelect.value = normalizeThemeName(OPTIONS.PANEL_THEME);
themeSelect.disabled = true;
themeSelect.title = '레거시 모드에서는 클래식 테마를 사용합니다.';
themeSelect.style.opacity = '0.55';
themeRow.appendChild(themeSelect);
const resetPosBtn = document.createElement('button');
resetPosBtn.type = 'button';
resetPosBtn.dataset.noDrag = '1';
resetPosBtn.textContent = '위치 초기화';
resetPosBtn.style.cssText = [
`height: ${ui.resetButtonHeight}`,
`padding: ${ui.resetButtonPadding}`,
`border-radius: ${ui.resetButtonRadius}`,
`border: ${theme.buttonBorder}`,
`background: ${theme.buttonBg}`,
`color: ${theme.panelColor}`,
'cursor: pointer',
`font-size: ${ui.resetButtonFontSize}`
].join(';');
resetPosBtn.addEventListener('click', () => {
clearPanelPos();
panel.style.left = 'auto';
panel.style.top = 'auto';
panel.style.right = ui.panelOffset;
panel.style.bottom = ui.panelOffset;
ensurePanelInViewport(panel);
});
themeRow.appendChild(resetPosBtn);
settingsWrap.appendChild(themeRow);
const manageRow = createLegacySettingsRow('관리');
const resetSettingsBtn = document.createElement('button');
resetSettingsBtn.type = 'button';
resetSettingsBtn.dataset.noDrag = '1';
resetSettingsBtn.textContent = '설정 초기화';
resetSettingsBtn.style.cssText = [
`height: ${ui.resetButtonHeight}`,
`padding: ${ui.resetButtonPadding}`,
`border-radius: ${ui.resetButtonRadius}`,
`border: ${theme.buttonBorder}`,
`background: ${theme.buttonBg}`,
`color: ${theme.panelColor}`,
'cursor: pointer',
`font-size: ${ui.resetButtonFontSize}`
].join(';');
resetSettingsBtn.addEventListener('click', () => {
resetUserSettings();
rebuildPanel();
syncPlaybackFromCurrentState();
applyPanelVisibility();
});
manageRow.appendChild(resetSettingsBtn);
settingsWrap.appendChild(manageRow);
const shortcutRow = createLegacySettingsRow('단축키');
const shortcutEnabled = createLegacyCheckbox('사용', OPTIONS.UI_TOGGLE_SHORTCUT.enabled, (checked) => {
OPTIONS.UI_TOGGLE_SHORTCUT.enabled = checked;
saveUserSettings();
});
shortcutRow.appendChild(shortcutEnabled.label);
[
['ctrl', 'Ctrl'],
['alt', 'Alt'],
['shift', 'Shift'],
['meta', 'win/cmd']
].forEach(([key, label]) => {
const checkbox = createLegacyCheckbox(label, OPTIONS.UI_TOGGLE_SHORTCUT[key], (checked) => {
OPTIONS.UI_TOGGLE_SHORTCUT[key] = checked;
saveUserSettings();
});
shortcutRow.appendChild(checkbox.label);
});
const shortcutKeyInput = document.createElement('input');
shortcutKeyInput.type = 'text';
shortcutKeyInput.value = OPTIONS.UI_TOGGLE_SHORTCUT.key;
shortcutKeyInput.placeholder = 'h';
shortcutKeyInput.maxLength = 12;
applyLegacyFieldStyle(shortcutKeyInput, ui.shortcutKeyWidth);
function commitShortcutKey() {
OPTIONS.UI_TOGGLE_SHORTCUT.key = normalizeShortcutKey(shortcutKeyInput.value, OPTIONS.UI_TOGGLE_SHORTCUT.key).toLowerCase();
shortcutKeyInput.value = OPTIONS.UI_TOGGLE_SHORTCUT.key;
saveUserSettings();
}
shortcutKeyInput.addEventListener('change', commitShortcutKey);
shortcutKeyInput.addEventListener('blur', commitShortcutKey);
shortcutKeyInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
commitShortcutKey();
shortcutKeyInput.blur();
}
});
const shortcutKeyLabel = document.createElement('span');
shortcutKeyLabel.textContent = 'Key';
shortcutKeyLabel.style.color = theme.subtleText;
shortcutRow.appendChild(shortcutKeyLabel);
shortcutRow.appendChild(shortcutKeyInput);
settingsWrap.appendChild(shortcutRow);
const speedRow = createLegacySettingsRow('조정속도');
const speedSelect = document.createElement('select');
for (let i = 1; i <= 5; i += 1) {
const option = document.createElement('option');
option.value = String(i);
option.textContent = `x${i}`;
speedSelect.appendChild(option);
}
applyLegacySelectStyle(speedSelect, ui.speedSelectWidth);
speedSelect.value = String(CONFIG.ADJUSTMENT_SPEED);
speedSelect.addEventListener('change', () => {
CONFIG.ADJUSTMENT_SPEED = clamp(parseInt(speedSelect.value || '3', 10), 1, 5);
saveUserSettings();
syncPlaybackFromCurrentState();
});
speedRow.appendChild(speedSelect);
const minRateLabel = document.createElement('span');
minRateLabel.textContent = 'Min';
minRateLabel.style.color = theme.subtleText;
speedRow.appendChild(minRateLabel);
const minRateInput = document.createElement('input');
minRateInput.type = 'number';
minRateInput.min = '0.5';
minRateInput.max = '1.0';
minRateInput.step = '0.05';
minRateInput.value = String(CONFIG.MIN_RATE);
applyLegacyFieldStyle(minRateInput, ui.rateInputWidth);
speedRow.appendChild(minRateInput);
const maxRateLabel = document.createElement('span');
maxRateLabel.textContent = 'Max';
maxRateLabel.style.color = theme.subtleText;
speedRow.appendChild(maxRateLabel);
const maxRateInput = document.createElement('input');
maxRateInput.type = 'number';
maxRateInput.min = '1.0';
maxRateInput.max = '3.0';
maxRateInput.step = '0.05';
maxRateInput.value = String(CONFIG.MAX_RATE);
applyLegacyFieldStyle(maxRateInput, ui.rateInputWidth);
speedRow.appendChild(maxRateInput);
function commitRateBounds() {
const nextMin = parseFloat(minRateInput.value);
const nextMax = parseFloat(maxRateInput.value);
if (isFinite(nextMin)) CONFIG.MIN_RATE = nextMin;
if (isFinite(nextMax)) CONFIG.MAX_RATE = nextMax;
normalizePlaybackConfig();
minRateInput.value = CONFIG.MIN_RATE.toFixed(2);
maxRateInput.value = CONFIG.MAX_RATE.toFixed(2);
saveUserSettings();
syncPlaybackFromCurrentState();
}
['change', 'blur'].forEach((eventName) => {
minRateInput.addEventListener(eventName, commitRateBounds);
maxRateInput.addEventListener(eventName, commitRateBounds);
});
[minRateInput, maxRateInput].forEach((input) => {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
commitRateBounds();
input.blur();
}
});
});
settingsWrap.appendChild(speedRow);
panel.appendChild(settingsWrap);
document.body.appendChild(panel);
ensurePanelInViewport(panel);
const saved = loadPanelPos();
if (saved) {
panel.style.left = `${saved.x}px`;
panel.style.top = `${saved.y}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
ensurePanelInViewport(panel);
}
applyPanelVisibility();
return;
}
const panel = document.createElement('div');
panel.id = 'soop-delay-panel';
panel.style.cssText = [
'position: fixed',
`right: ${ui.panelOffset}`,
`bottom: ${ui.panelOffset}`,
'display: flex',
'flex-direction: column',
'align-items: stretch',
`gap: ${ui.panelGap}`,
`padding: ${ui.panelPadding}`,
`border-radius: ${ui.panelRadius}`,
`background: ${theme.panelBg}`,
`border: ${theme.panelBorder}`,
`color: ${theme.panelColor}`,
`font: ${ui.panelFont}`,
'font-variant-numeric: tabular-nums',
`backdrop-filter: ${ui.panelBackdropFilter}`,
`-webkit-backdrop-filter: ${ui.panelBackdropFilter}`,
'z-index: 10000',
'user-select: none',
'cursor: default',
'white-space: nowrap',
`max-width: ${ui.panelMaxWidth}`,
`opacity: ${ui.panelOpacity}`,
`box-shadow: ${theme.boxShadow}`
].join(';');
const mainRow = document.createElement('div');
mainRow.style.cssText = [
`display: ${isLegacyUi ? 'block' : 'flex'}`,
`align-items: ${isLegacyUi ? 'stretch' : 'center'}`,
`flex-wrap: ${isLegacyUi ? 'nowrap' : 'wrap'}`,
`gap: ${ui.mainRowGap}`
].join(';');
panel.appendChild(mainRow);
const fieldBaseStyle = [
`color: ${theme.inputColor}`,
`background: ${theme.inputBg}`,
`border: ${theme.inputBorder}`,
`border-radius: ${ui.fieldRadius}`,
`padding: ${ui.fieldPadding}`,
`height: ${ui.fieldHeight}`,
`font-size: ${ui.fieldFontSize}`,
'font-weight: 500',
'box-sizing: border-box',
'outline: none'
];
function applyFieldStyle(el, width, extra = []) {
el.dataset.noDrag = '1';
el.style.cssText = [`width: ${width}`, ...fieldBaseStyle, ...extra].join(';');
el.style.caretColor = theme.inputColor;
el.style.colorScheme = theme.controlScheme;
}
function applySelectStyle(selectEl, width) {
applyFieldStyle(selectEl, width, [`padding-right: ${ui.selectPaddingRight}`, 'appearance: auto']);
selectEl.style.colorScheme = theme.controlScheme;
Array.from(selectEl.options || []).forEach((option) => {
option.style.color = theme.optionColor;
option.style.backgroundColor = theme.optionBg;
});
}
function setActionButtonState(button, active = false) {
button.dataset.active = active ? '1' : '0';
button.style.background = isLegacyUi ? 'transparent' : (active ? theme.buttonActiveBg : 'transparent');
button.style.color = active ? theme.panelColor : theme.closeColor;
}
function createActionButton(text, title, active = false) {
const button = document.createElement('button');
button.type = 'button';
button.dataset.noDrag = '1';
button.textContent = text;
button.title = title;
button.style.cssText = [
'cursor: pointer',
`padding: ${ui.actionPadding}`,
`height: ${ui.actionHeight}`,
`border-radius: ${ui.actionRadius}`,
'border: none',
'background: transparent',
`color: ${theme.closeColor}`,
`font-size: ${ui.actionFontSize}`,
`font-weight: ${isLegacyUi ? '400' : '700'}`,
`line-height: ${isLegacyUi ? '1.2' : '1'}`,
'transition: color 0.15s ease, background 0.15s ease'
].join(';');
setActionButtonState(button, active);
button.addEventListener('mouseenter', () => {
const isActive = button.dataset.active === '1';
button.style.color = theme.closeHover;
button.style.background = isLegacyUi ? 'transparent' : (isActive ? theme.buttonActiveBg : theme.buttonHoverBg);
});
button.addEventListener('mouseleave', () => {
setActionButtonState(button, button.dataset.active === '1');
});
return button;
}
function createCheckbox(labelText, checked, onChange) {
const label = document.createElement('label');
label.dataset.noDrag = '1';
label.style.cssText = [
'display: inline-flex',
'align-items: center',
`gap: ${ui.checkboxGap}`,
'cursor: pointer',
`font-size: ${ui.checkboxFontSize}`,
`color: ${theme.subtleText}`
].join(';');
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = !!checked;
input.dataset.noDrag = '1';
input.style.accentColor = theme.switchOn;
input.addEventListener('change', () => onChange(input.checked));
const text = document.createElement('span');
text.textContent = labelText;
label.appendChild(input);
label.appendChild(text);
return { label, input };
}
function createSettingsRow(title) {
const row = document.createElement('div');
row.dataset.noDrag = '1';
row.style.cssText = [
'display: flex',
'align-items: center',
'flex-wrap: wrap',
`gap: ${ui.settingsRowGap}`
].join(';');
const titleNode = document.createElement('span');
titleNode.textContent = title;
titleNode.style.cssText = [
'display: inline-flex',
'align-items: center',
`min-width: ${ui.settingsTitleMinWidth}`,
`font-size: ${ui.settingsTitleFontSize}`,
`color: ${theme.subtleText}`,
'font-weight: 600'
].join(';');
row.appendChild(titleNode);
return row;
}
panel.addEventListener('mousedown', (e) => {
if (isLegacyUi) return;
try {
if (e.target instanceof Element && e.target.closest('button, input, select, label, [data-no-drag="1"]')) return;
if (OPTIONS.DRAG_WITH_CTRL_ONLY && !e.ctrlKey) return;
} catch {}
const rect = panel.getBoundingClientRect();
const dragOffsetX = e.clientX - rect.left;
const dragOffsetY = e.clientY - rect.top;
const handleMove = (event) => {
const x = Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, event.clientX - dragOffsetX));
const y = Math.max(0, Math.min(window.innerHeight - panel.offsetHeight, event.clientY - dragOffsetY));
panel.style.left = `${x}px`;
panel.style.top = `${y}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
};
const handleUp = () => {
window.removeEventListener('mousemove', handleMove);
const currentRect = panel.getBoundingClientRect();
savePanelPos(currentRect.left, currentRect.top);
ensurePanelInViewport(panel);
};
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleUp, { once: true });
e.preventDefault();
});
const switchBtn = document.createElement('button');
switchBtn.type = 'button';
switchBtn.dataset.noDrag = '1';
switchBtn.style.cssText = [
'position: relative',
`width: ${ui.switchWidth}`,
`height: ${ui.switchHeight}`,
'border-radius: 999px',
`border: ${theme.switchBorder}`,
'padding: 0',
'cursor: pointer',
'outline: none',
'transition: background 0.16s ease, border-color 0.16s ease'
].join(';');
const knob = document.createElement('span');
knob.style.cssText = [
'position: absolute',
'top: 1px',
'left: 1px',
`width: ${ui.switchKnobSize}`,
`height: ${ui.switchKnobSize}`,
'border-radius: 50%',
`background: ${theme.knobBg}`,
'box-shadow: 0 1px 3px rgba(15,23,42,0.18)',
'transition: left 120ms ease'
].join(';');
switchBtn.appendChild(knob);
function updateSwitch() {
switchBtn.style.background = isEnabled ? theme.switchOn : theme.switchOff;
knob.style.left = isEnabled ? ui.switchKnobOnLeft : '1px';
}
updateSwitch();
switchBtn.addEventListener('click', (e) => {
isEnabled = !isEnabled;
saveEnabled(isEnabled);
updateSwitch();
restartAdjustmentCycle(true);
if (!isEnabled) {
setPlaybackRateSafely(1.0);
}
e.preventDefault();
e.stopPropagation();
});
const targetInput = document.createElement('input');
targetInput.id = 'soop-delay-target-input';
targetInput.type = 'number';
targetInput.min = '200';
targetInput.max = '8000';
targetInput.step = '50';
targetInput.value = String(targetDelayMs);
applyFieldStyle(targetInput, ui.targetInputWidth);
function commitTargetInput() {
let v = parseInt(targetInput.value || '0', 10);
if (!isFinite(v)) {
targetInput.value = String(targetDelayMs);
return;
}
v = clamp(v, 200, 8000);
targetInput.value = String(v);
if (v !== targetDelayMs) {
targetDelayMs = v;
saveTargetDelay(v);
restartAdjustmentCycle(false);
}
}
targetInput.addEventListener('change', commitTargetInput);
targetInput.addEventListener('blur', commitTargetInput);
targetInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
commitTargetInput();
targetInput.blur();
}
});
const msText = document.createElement('span');
msText.textContent = 'ms';
msText.style.color = theme.subtleText;
const avgVal = document.createElement('span');
avgVal.id = 'soop-delay-avg';
avgVal.textContent = '-ms';
avgVal.style.display = isLegacyUi ? 'inline' : 'inline-block';
avgVal.style.minWidth = isLegacyUi ? 'auto' : ui.avgValueMinWidth;
avgVal.style.textAlign = isLegacyUi ? 'left' : 'right';
const rateVal = document.createElement('span');
rateVal.id = 'soop-delay-rate';
rateVal.textContent = formatRateText(1.0);
rateVal.style.display = isLegacyUi ? 'inline' : 'inline-block';
rateVal.style.minWidth = isLegacyUi ? 'auto' : ui.rateValueMinWidth;
rateVal.style.textAlign = isLegacyUi ? 'left' : 'right';
if (OPTIONS.SHOW_TOGGLE_BUTTON) {
mainRow.appendChild(switchBtn);
}
if (OPTIONS.SHOW_TARGET_VALUE) {
mainRow.appendChild(document.createTextNode(' 목표:'));
mainRow.appendChild(targetInput);
mainRow.appendChild(msText);
}
if (OPTIONS.SHOW_DELAY_VALUE) {
mainRow.appendChild(document.createTextNode(' 딜레이:'));
mainRow.appendChild(avgVal);
}
if (OPTIONS.SHOW_RATE_VALUE) {
mainRow.appendChild(document.createTextNode(' 배속:'));
mainRow.appendChild(rateVal);
}
const settingsWrap = document.createElement('div');
settingsWrap.dataset.noDrag = '1';
settingsWrap.style.cssText = [
`display: ${isSettingsOpen ? 'flex' : 'none'}`,
'flex-direction: column',
`gap: ${ui.settingsWrapGap}`,
`border-top: 1px solid ${theme.divider}`,
`background: ${theme.settingsBg}`,
`border-radius: ${ui.settingsWrapRadius}`,
`padding: ${ui.settingsWrapPadding}`
].join(';');
const settingsBtn = createActionButton(isLegacyUi ? '설정' : '⚙', '설정 열기/닫기', isSettingsOpen);
settingsBtn.style.marginLeft = ui.settingsButtonMarginLeft;
settingsBtn.addEventListener('click', () => {
const previousRect = panel.getBoundingClientRect();
isSettingsOpen = !isSettingsOpen;
settingsWrap.style.display = isSettingsOpen ? 'flex' : 'none';
setActionButtonState(settingsBtn, isSettingsOpen);
stabilizePanelAfterContentResize(panel, previousRect);
});
const closeBtn = createActionButton(isLegacyUi ? '닫기' : '✖', '패널 숨기기');
closeBtn.style.marginLeft = ui.closeButtonMarginLeft;
closeBtn.addEventListener('click', () => {
isUiVisible = false;
saveUiVisible(false);
applyPanelVisibility();
});
if (!isLegacyUi) {
mainRow.appendChild(settingsBtn);
if (OPTIONS.SHOW_CLOSE_BUTTON) {
mainRow.appendChild(closeBtn);
}
}
if (isLegacyUi) {
const basicRow = createSettingsRow('기본');
const targetLabel = document.createElement('span');
targetLabel.textContent = '목표';
targetLabel.style.color = theme.subtleText;
basicRow.appendChild(switchBtn);
basicRow.appendChild(targetLabel);
basicRow.appendChild(targetInput);
basicRow.appendChild(msText);
settingsWrap.appendChild(basicRow);
}
const optionRow = createSettingsRow('옵션');
const perChannelOption = createCheckbox('채널별 목표', OPTIONS.ENABLE_PER_CHANNEL_SETTINGS, (checked) => {
OPTIONS.ENABLE_PER_CHANNEL_SETTINGS = checked;
saveUserSettings();
targetDelayMs = loadTargetDelay();
targetInput.value = String(targetDelayMs);
restartAdjustmentCycle(true);
});
const fullscreenOption = createCheckbox('전체화면 숨김', OPTIONS.HIDE_UI_IN_FULLSCREEN, (checked) => {
OPTIONS.HIDE_UI_IN_FULLSCREEN = checked;
saveUserSettings();
applyPanelVisibility();
});
const dragOption = createCheckbox('Ctrl 드래그', OPTIONS.DRAG_WITH_CTRL_ONLY, (checked) => {
OPTIONS.DRAG_WITH_CTRL_ONLY = checked;
saveUserSettings();
});
const closeButtonOption = createCheckbox('닫기 버튼', OPTIONS.SHOW_CLOSE_BUTTON, (checked) => {
OPTIONS.SHOW_CLOSE_BUTTON = checked;
saveUserSettings();
rebuildPanel();
});
optionRow.appendChild(perChannelOption.label);
optionRow.appendChild(fullscreenOption.label);
optionRow.appendChild(dragOption.label);
optionRow.appendChild(closeButtonOption.label);
settingsWrap.appendChild(optionRow);
const displayRow = createSettingsRow('표시');
let toggleVisibilityOption;
toggleVisibilityOption = createCheckbox('토글', OPTIONS.SHOW_TOGGLE_BUTTON, (checked) => {
updateMainDisplayOption('SHOW_TOGGLE_BUTTON', checked, toggleVisibilityOption.input);
});
let targetVisibilityOption;
targetVisibilityOption = createCheckbox('목표값', OPTIONS.SHOW_TARGET_VALUE, (checked) => {
updateMainDisplayOption('SHOW_TARGET_VALUE', checked, targetVisibilityOption.input);
});
let delayVisibilityOption;
delayVisibilityOption = createCheckbox('딜레이', OPTIONS.SHOW_DELAY_VALUE, (checked) => {
updateMainDisplayOption('SHOW_DELAY_VALUE', checked, delayVisibilityOption.input);
});
let rateVisibilityOption;
rateVisibilityOption = createCheckbox('배속', OPTIONS.SHOW_RATE_VALUE, (checked) => {
updateMainDisplayOption('SHOW_RATE_VALUE', checked, rateVisibilityOption.input);
});
displayRow.appendChild(toggleVisibilityOption.label);
displayRow.appendChild(targetVisibilityOption.label);
displayRow.appendChild(delayVisibilityOption.label);
displayRow.appendChild(rateVisibilityOption.label);
settingsWrap.appendChild(displayRow);
const uiRow = createSettingsRow('UI');
const uiModeSelect = document.createElement('select');
[['modern', 'Modern'], ['legacy', 'Legacy']].forEach(([value, label]) => {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
uiModeSelect.appendChild(option);
});
applySelectStyle(uiModeSelect, ui.uiModeSelectWidth);
uiModeSelect.value = normalizeUiMode(OPTIONS.UI_MODE);
uiModeSelect.addEventListener('change', () => {
OPTIONS.UI_MODE = normalizeUiMode(uiModeSelect.value);
if (OPTIONS.UI_MODE === 'legacy') {
isSettingsOpen = false;
}
saveUserSettings();
rebuildPanel();
});
uiRow.appendChild(uiModeSelect);
settingsWrap.appendChild(uiRow);
const themeRow = createSettingsRow('테마');
const themeSelect = document.createElement('select');
[['dark', 'Dark'], ['light', 'Light']].forEach(([value, label]) => {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
themeSelect.appendChild(option);
});
applySelectStyle(themeSelect, ui.themeSelectWidth);
themeSelect.value = normalizeThemeName(OPTIONS.PANEL_THEME);
themeSelect.disabled = isLegacyUi;
themeSelect.title = isLegacyUi ? '레거시 모드에서는 클래식 테마를 사용합니다.' : '패널 테마';
themeSelect.style.opacity = isLegacyUi ? '0.55' : '1';
themeSelect.addEventListener('change', () => {
OPTIONS.PANEL_THEME = normalizeThemeName(themeSelect.value);
saveUserSettings();
rebuildPanel();
});
themeRow.appendChild(themeSelect);
const resetPosBtn = document.createElement('button');
resetPosBtn.type = 'button';
resetPosBtn.dataset.noDrag = '1';
resetPosBtn.textContent = '위치 초기화';
resetPosBtn.style.cssText = [
`height: ${ui.resetButtonHeight}`,
`padding: ${ui.resetButtonPadding}`,
`border-radius: ${ui.resetButtonRadius}`,
`border: ${theme.buttonBorder}`,
`background: ${theme.buttonBg}`,
`color: ${theme.panelColor}`,
'cursor: pointer',
`font-size: ${ui.resetButtonFontSize}`
].join(';');
resetPosBtn.addEventListener('click', () => {
clearPanelPos();
panel.style.left = 'auto';
panel.style.top = 'auto';
panel.style.right = ui.panelOffset;
panel.style.bottom = ui.panelOffset;
ensurePanelInViewport(panel);
});
themeRow.appendChild(resetPosBtn);
settingsWrap.appendChild(themeRow);
const manageRow = createSettingsRow('관리');
const resetSettingsBtn = document.createElement('button');
resetSettingsBtn.type = 'button';
resetSettingsBtn.dataset.noDrag = '1';
resetSettingsBtn.textContent = '설정 초기화';
resetSettingsBtn.style.cssText = [
`height: ${ui.resetButtonHeight}`,
`padding: ${ui.resetButtonPadding}`,
`border-radius: ${ui.resetButtonRadius}`,
`border: ${theme.buttonBorder}`,
`background: ${theme.buttonBg}`,
`color: ${theme.panelColor}`,
'cursor: pointer',
`font-size: ${ui.resetButtonFontSize}`
].join(';');
resetSettingsBtn.addEventListener('click', () => {
resetUserSettings();
rebuildPanel();
syncPlaybackFromCurrentState();
applyPanelVisibility();
});
manageRow.appendChild(resetSettingsBtn);
settingsWrap.appendChild(manageRow);
const shortcutRow = createSettingsRow('단축키');
const shortcutEnabled = createCheckbox('사용', OPTIONS.UI_TOGGLE_SHORTCUT.enabled, (checked) => {
OPTIONS.UI_TOGGLE_SHORTCUT.enabled = checked;
saveUserSettings();
});
shortcutRow.appendChild(shortcutEnabled.label);
[
['ctrl', 'Ctrl'],
['alt', 'Alt'],
['shift', 'Shift'],
['meta', 'win/cmd']
].forEach(([key, label]) => {
const checkbox = createCheckbox(label, OPTIONS.UI_TOGGLE_SHORTCUT[key], (checked) => {
OPTIONS.UI_TOGGLE_SHORTCUT[key] = checked;
saveUserSettings();
});
shortcutRow.appendChild(checkbox.label);
});
const shortcutKeyInput = document.createElement('input');
shortcutKeyInput.type = 'text';
shortcutKeyInput.value = OPTIONS.UI_TOGGLE_SHORTCUT.key;
shortcutKeyInput.placeholder = 'h';
shortcutKeyInput.maxLength = 12;
applyFieldStyle(shortcutKeyInput, ui.shortcutKeyWidth);
function commitShortcutKey() {
OPTIONS.UI_TOGGLE_SHORTCUT.key = normalizeShortcutKey(shortcutKeyInput.value, OPTIONS.UI_TOGGLE_SHORTCUT.key).toLowerCase();
shortcutKeyInput.value = OPTIONS.UI_TOGGLE_SHORTCUT.key;
saveUserSettings();
}
shortcutKeyInput.addEventListener('change', commitShortcutKey);
shortcutKeyInput.addEventListener('blur', commitShortcutKey);
shortcutKeyInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
commitShortcutKey();
shortcutKeyInput.blur();
}
});
const shortcutKeyLabel = document.createElement('span');
shortcutKeyLabel.textContent = 'Key';
shortcutKeyLabel.style.color = theme.subtleText;
shortcutRow.appendChild(shortcutKeyLabel);
shortcutRow.appendChild(shortcutKeyInput);
settingsWrap.appendChild(shortcutRow);
const speedRow = createSettingsRow('조정속도');
const speedSelect = document.createElement('select');
for (let i = 1; i <= 5; i += 1) {
const option = document.createElement('option');
option.value = String(i);
option.textContent = `x${i}`;
speedSelect.appendChild(option);
}
applySelectStyle(speedSelect, ui.speedSelectWidth);
speedSelect.value = String(CONFIG.ADJUSTMENT_SPEED);
speedSelect.addEventListener('change', () => {
CONFIG.ADJUSTMENT_SPEED = clamp(parseInt(speedSelect.value || '3', 10), 1, 5);
saveUserSettings();
syncPlaybackFromCurrentState();
});
speedRow.appendChild(speedSelect);
const minRateLabel = document.createElement('span');
minRateLabel.textContent = 'Min';
minRateLabel.style.color = theme.subtleText;
speedRow.appendChild(minRateLabel);
const minRateInput = document.createElement('input');
minRateInput.type = 'number';
minRateInput.min = '0.5';
minRateInput.max = '1.0';
minRateInput.step = '0.05';
minRateInput.value = String(CONFIG.MIN_RATE);
applyFieldStyle(minRateInput, ui.rateInputWidth);
speedRow.appendChild(minRateInput);
const maxRateLabel = document.createElement('span');
maxRateLabel.textContent = 'Max';
maxRateLabel.style.color = theme.subtleText;
speedRow.appendChild(maxRateLabel);
const maxRateInput = document.createElement('input');
maxRateInput.type = 'number';
maxRateInput.min = '1.0';
maxRateInput.max = '3.0';
maxRateInput.step = '0.05';
maxRateInput.value = String(CONFIG.MAX_RATE);
applyFieldStyle(maxRateInput, ui.rateInputWidth);
speedRow.appendChild(maxRateInput);
function commitRateBounds() {
const nextMin = parseFloat(minRateInput.value);
const nextMax = parseFloat(maxRateInput.value);
if (isFinite(nextMin)) CONFIG.MIN_RATE = nextMin;
if (isFinite(nextMax)) CONFIG.MAX_RATE = nextMax;
normalizePlaybackConfig();
minRateInput.value = CONFIG.MIN_RATE.toFixed(2);
maxRateInput.value = CONFIG.MAX_RATE.toFixed(2);
saveUserSettings();
syncPlaybackFromCurrentState();
}
['change', 'blur'].forEach((eventName) => {
minRateInput.addEventListener(eventName, commitRateBounds);
maxRateInput.addEventListener(eventName, commitRateBounds);
});
[minRateInput, maxRateInput].forEach((input) => {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
commitRateBounds();
input.blur();
}
});
});
settingsWrap.appendChild(speedRow);
panel.appendChild(settingsWrap);
document.body.appendChild(panel);
const saved = !isLegacyUi ? loadPanelPos() : null;
if (saved) {
panel.style.left = `${saved.x}px`;
panel.style.top = `${saved.y}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
}
ensurePanelInViewport(panel);
applyPanelVisibility();
}
function ensurePanelInViewport(panel) {
try {
if (!panel || !panel.isConnected || panel.getClientRects().length === 0) {
return;
}
const rect = panel.getBoundingClientRect();
const margin = 8;
let newLeft = rect.left;
let newTop = rect.top;
if (rect.right > window.innerWidth - margin) newLeft -= rect.right - (window.innerWidth - margin);
if (rect.left < margin) newLeft = margin;
if (rect.bottom > window.innerHeight - margin) newTop -= rect.bottom - (window.innerHeight - margin);
if (rect.top < margin) newTop = margin;
if (newLeft !== rect.left || newTop !== rect.top) {
panel.style.left = `${newLeft}px`;
panel.style.top = `${newTop}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
const r2 = panel.getBoundingClientRect();
savePanelPos(r2.left, r2.top);
}
} catch {}
}
function updateChannelSettings() {
const newChannelId = getCurrentChannelId();
if (newChannelId === currentChannelId) return;
currentChannelId = newChannelId;
if (OPTIONS.ENABLE_PER_CHANNEL_SETTINGS) {
targetDelayMs = loadTargetDelay();
const targetInput = document.getElementById('soop-delay-target-input');
if (targetInput) targetInput.value = String(targetDelayMs);
}
restartAdjustmentCycle(true);
}
function handleRouteChange() {
if (location.href === lastKnownUrl) return;
lastKnownUrl = location.href;
cleanup();
updateChannelSettings();
createPanel();
start();
}
function installHistoryHooks() {
if (historyHooked) return;
historyHooked = true;
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function () {
const result = originalPushState.apply(this, arguments);
setTimeout(handleRouteChange, 0);
return result;
};
history.replaceState = function () {
const result = originalReplaceState.apply(this, arguments);
setTimeout(handleRouteChange, 0);
return result;
};
window.addEventListener('popstate', handleRouteChange);
}
function startRouteWatcher() {
if (routeCheckId) clearInterval(routeCheckId);
routeCheckId = setInterval(handleRouteChange, CONFIG.URL_FALLBACK_CHECK_MS);
}
function handleVisibilityChange() {
if (document.visibilityState === 'visible') {
clearDelayHistory();
resetAdjustmentState(false);
if (video) setPlaybackRateSafely(1.0);
}
}
function preventBackgroundThrottling() {
document.addEventListener('visibilitychange', handleVisibilityChange);
}
function matchesShortcut(e, shortcut) {
if (!shortcut || !shortcut.enabled) return false;
if (!!e.ctrlKey !== !!shortcut.ctrl) return false;
if (!!e.altKey !== !!shortcut.alt) return false;
if (!!e.shiftKey !== !!shortcut.shift) return false;
if (!!e.metaKey !== !!shortcut.meta) return false;
return String(e.key || '').toLowerCase() === String(shortcut.key || '').toLowerCase();
}
function init() {
if (initialized) return;
initialized = true;
preventBackgroundThrottling();
installHistoryHooks();
startRouteWatcher();
createPanel();
start();
[
'fullscreenchange',
'webkitfullscreenchange',
'mozfullscreenchange',
'MSFullscreenChange'
].forEach(ev => document.addEventListener(ev, handleFullscreenChange));
window.addEventListener('resize', () => {
const panel = getPanelElement();
if (panel && panel.getClientRects().length > 0) ensurePanelInViewport(panel);
});
window.addEventListener('keydown', (e) => {
if (!matchesShortcut(e, OPTIONS.UI_TOGGLE_SHORTCUT)) return;
e.preventDefault();
isUiVisible = !isUiVisible;
saveUiVisible(isUiVisible);
applyPanelVisibility();
});
if (typeof GM_registerMenuCommand !== 'undefined') {
GM_registerMenuCommand('패널 토글', () => {
isUiVisible = !isUiVisible;
saveUiVisible(isUiVisible);
applyPanelVisibility();
});
GM_registerMenuCommand('설정 패널 토글', () => {
isUiVisible = true;
saveUiVisible(true);
isSettingsOpen = !isSettingsOpen;
rebuildPanel();
applyPanelVisibility();
});
}
window.addEventListener('beforeunload', () => {
cleanup();
if (routeCheckId) {
clearInterval(routeCheckId);
routeCheckId = null;
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();