// ==UserScript== // @name soop 방송 딜레이 자동 조정 // @namespace https://greasyfork.org/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 감귤구이 // @downloadURL https://update.greasyfork.icu/scripts/539405/soop%20%EB%B0%A9%EC%86%A1%20%EB%94%9C%EB%A0%88%EC%9D%B4%20%EC%9E%90%EB%8F%99%20%EC%A1%B0%EC%A0%95.user.js // @updateURL https://update.greasyfork.icu/scripts/539405/soop%20%EB%B0%A9%EC%86%A1%20%EB%94%9C%EB%A0%88%EC%9D%B4%20%EC%9E%90%EB%8F%99%20%EC%A1%B0%EC%A0%95.meta.js // ==/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(); } })();