您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork is available in English.
Chzzk 올인원 기반. 실시간 버튼 동작변경, 재생 속도, 자동 새로고침, PIP 단축키 등
当前为
// ==UserScript== // @name Chzzk 어시스턴트 // @namespace http://tampermonkey.net/ // @version 1.0.1 // @description Chzzk 올인원 기반. 실시간 버튼 동작변경, 재생 속도, 자동 새로고침, PIP 단축키 등 // @match https://chzzk.naver.com/* // @icon https://chzzk.naver.com/favicon.ico // @grant GM.info // @grant GM.getValue // @grant GM.setValue // @grant unsafeWindow // @run-at document-start // @license MIT // ==/UserScript== (async() => { "use strict"; /** * @class Config * @description 스크립트의 모든 설정, 선택자, 유틸리티 함수를 중앙에서 관리하는 클래스. */ class Config { #applyCooldown = 1000; #minTimeout = 1500; #defaultTimeout = 2000; #storageKeys = { quality: "chzzkPreferredQuality", autoUnmute: "chzzkAutoUnmute", autoRefresh: "chzzkAutoRefresh", debugLog: "chzzkDebugLog", ignoredUpdate: "chzzkIgnoredUpdateDate", playbackRate: "chzzkPlaybackRate", autoLive1x: "chzzkAutoLive1x", screenSharpness: "chzzkScreenSharp", }; #selectors = { popup: 'div[class^="popup_container"]', woodbtn: 'button[class^="live_chatting_power_button__"]', qualityBtn: 'button[command="SettingCommands.Toggle"]', qualityItems: 'li.pzp-ui-setting-quality-item[role="menuitem"]', pipBtns: [ 'button[aria-label*="pip" i]', 'button[aria-label*="PiP" i]', 'button[aria-label*="미니" i]', 'button[aria-label*="화면 속 화면" i]', 'button[command*="PictureInPicture"]', 'button[command*="Pip"]', ], videoPlayer: 'div[class*="live_information_player__"]', errorDialog: '.pzp-pc-ui-error-dialog--large', rightBoundary: 'div[class*="toolbar_section__"]', chatInput: '.live_chatting_input_input__2F3Et', }; #regex = { adBlockDetect: /광고\s*차단\s*프로그램.*사용\s*중/i, chzzkId: /(?:live|video)\/(?<id>[^/]+)/, }; #constants = { DISPLAY_BLOCK: 'block', DISPLAY_NONE: 'none', RELOAD_DELAY_MS: 100, LIVE_EDGE_SECONDS: 5, QUALITY_RECOVERY_TIMEOUT_MS: 120000, QUALITY_CHECK_INTERVAL_MS: 30000, AUTO_ONEX_INTERVAL_MS: 500, STALL_CHECK_INTERVAL_MS: 5000, STALL_THRESHOLD_MS: 5000, TOAST_DISPLAY_TIME_MS: 1000, PLAYBACK_RATES: [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0], }; #debug = false; /** @returns {number} 자동 적용 기능의 최소 실행 간격 (ms) */ get applyCooldown() { return this.#applyCooldown; } /** @returns {number} 비동기 작업의 최소 타임아웃 (ms) */ get minTimeout() { return this.#minTimeout; } /** @returns {number} 비동기 작업의 기본 타임아웃 (ms) */ get defaultTimeout() { return this.#defaultTimeout; } /** @returns {object} Tampermonkey 저장소 키 목록 */ get storageKeys() { return this.#storageKeys; } /** @returns {object} DOM 요소 선택자 목록 */ get selectors() { return this.#selectors; } /** @returns {object} 정규 표현식 목록 */ get regex() { return this.#regex; } /** @returns {object} 스크립트에서 사용하는 상수 값 목록 */ get constants() { return this.#constants; } /** @returns {boolean} 디버그 로그 활성화 여부 */ get debug() { return this.#debug; } /** @param {boolean} v - 디버그 로그 활성화 상태 */ set debug(v) { this.#debug = !!v; } /** * 지정된 시간(ms)만큼 실행을 지연시킵니다. * @param {number} ms - 지연시킬 시간 (ms). * @returns {Promise<void>} */ sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** * 특정 CSS 선택자에 해당하는 요소가 나타날 때까지 기다립니다. * @param {string} selector - 기다릴 요소의 CSS 선택자. * @param {number} [timeout=this.#defaultTimeout] - 대기할 최대 시간 (ms). * @returns {Promise<Element>} 발견된 요소를 resolve하는 프로미스. */ waitFor = (selector, timeout = this.#defaultTimeout) => { const effective = Math.max(timeout, this.#minTimeout); return new Promise((resolve, reject) => { const el = document.querySelector(selector); if (el) return resolve(el); const mo = new MutationObserver(() => { const found = document.querySelector(selector); if (found) { mo.disconnect(); resolve(found); } }); mo.observe(document.body, { childList: true, subtree: true }); setTimeout(() => { mo.disconnect(); reject(new Error("Timeout waiting for " + selector)); }, effective); }); }; /** * 텍스트에서 해상도 값을 숫자로 추출합니다. (예: "1080p" -> 1080) * @param {string} txt - 해상도 정보가 포함된 텍스트. * @returns {number|null} 추출된 해상도 숫자 또는 null. */ extractResolution = (txt) => { const m = String(txt || "").match(/(\d{3,4})p/); return m ? parseInt(m[1], 10) : null; }; } const C = new Config(); const K = C.constants; /** * 현재 페이지의 비디오 요소를 반환합니다. * @returns {HTMLVideoElement|null} 비디오 요소 또는 null. */ const getVideo = () => document.querySelector("video"); /** * @namespace AllInOneMenu * @description 스크립트 설정 메뉴 UI를 생성하고 관리합니다. */ const AllInOneMenu = { uiInterval: null, /** * 메뉴 UI에 필요한 스타일을 페이지에 주입합니다. */ injectStyles() { if (document.getElementById('chzzk-allinone-styles')) return; const customStyles = document.createElement('style'); customStyles.id = 'chzzk-allinone-styles'; customStyles.textContent = ` .allinone-settings-button:hover { background-color: var(--Surface-Interaction-Lighten-Hovered); border-radius: 6px; } .button_label__fyHZ6 { align-items: center; background-color: var(--Surface-Neutral-Base); border-radius: 6px; box-shadow: 0 2px 2px var(--Shadow-Strong),0 2px 6px 2px var(--Shadow-Base); color: var(--Content-Neutral-Cool-Stronger); display: inline-flex; font-family: -apple-system,BlinkMacSystemFont,Apple SD Gothic Neo,Helvetica,Arial,NanumGothic,나눔고딕,Malgun Gothic,맑은 고딕,Dotum,굴림,gulim,새굴림,noto sans,돋움,sans-serif; font-size: 12px; font-weight: 400; height: 27px; justify-content: center; letter-spacing: -.3px; line-height: 17px; padding: 0 9px; position: absolute; white-space: nowrap; z-index: 15000; } .allinone-tooltip-position { top: calc(100% + 2px); right: -10px; } .chzzk-speed-toast { position: absolute; top: 24px; left: 50%; transform: translateX(-50%); background-color: rgba(0, 0, 0, 0.8); color: #fff; padding: 10px 20px; border-radius: 6px; font-size: 16px; font-weight: bold; z-index: 10000; opacity: 0; transition: opacity 0.2s ease-in-out; pointer-events: none; white-space: nowrap; } .chzzk-speed-toast.visible { opacity: 1; } `; document.head.appendChild(customStyles); }, /** * 설정 메뉴를 여는 버튼을 생성합니다. * @returns {HTMLButtonElement} 생성된 버튼 요소. */ createButton() { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'button_container__ppWwB button_only_icon__kahz5 button_larger__4NrSP allinone-settings-button'; btn.innerHTML = `<svg width="28" height="28" color="currentColor" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform: scale(1.4);"><g transform="translate(8,8)"><path d="M4.5 12a7.5 7.5 0 0 0 15 0m-15 0a7.5 7.5 0 1 1 15 0m-15 0H3m16.5 0H21m-1.5 0H12m-8.457 3.077 1.41-.513m14.095-5.13 1.41-.513M5.106 17.785l1.15-.964m11.49-9.642 1.149-.964M7.501 19.795l.75-1.3m7.5-12.99.75-1.3m-6.063 16.658.26-1.477m2.605-14.772.26-1.477m0 17.726-.26-1.477M10.698 4.614l-.26-1.477M16.5 19.794l-.75-1.299M7.5 4.205 12 12m6.894 5.785-1.149-.964M6.256 7.178l-1.15-.964m15.352 8.864-1.41-.513M4.954 9.435l-1.41-.514M12.002 12l-3.75 6.495"></path></g></svg><span class="blind">어시스턴트 설정</span>`; btn.addEventListener('mouseenter', () => { const parent = btn.parentElement; if (!parent || parent.querySelector('.button_label__fyHZ6')) return; const tooltip = document.createElement('span'); tooltip.className = 'button_label__fyHZ6 allinone-tooltip-position'; tooltip.textContent = '어시스턴트 설정'; parent.appendChild(tooltip); }); btn.addEventListener('mouseleave', () => { const tooltip = btn.parentElement?.querySelector('.button_label__fyHZ6'); if (tooltip) tooltip.remove(); }); return btn; }, /** * 설정 메뉴의 드롭다운 UI를 생성합니다. * @returns {HTMLDivElement} 생성된 메뉴 요소. */ createMenu() { const menu = document.createElement('div'); menu.className = 'allinone-settings-menu'; Object.assign(menu.style, { position: 'absolute', background: 'var(--color-bg-layer-02)', borderRadius: '10px', boxShadow: '0 8px 20px var(--color-shadow-layer01-02), 0 0 1px var(--color-shadow-layer01-01)', color: 'var(--color-content-03)', overflow: 'auto', padding: '18px', right: '0px', top: 'calc(100% + 7px)', width: '240px', zIndex: 13000, display: K.DISPLAY_NONE }); const helpContent = document.createElement('div'); helpContent.className = 'allinone-help-content'; Object.assign(helpContent.style, { display: K.DISPLAY_NONE, margin: '4px 0', padding: '4px 8px 4px 34px', fontFamily: 'Sandoll Nemony2, Apple SD Gothic NEO, Helvetica Neue, Helvetica, NanumGothic, Malgun Gothic, gulim, noto sans, Dotum, sans-serif', fontSize: '14px', color: 'var(--color-content-03)', whiteSpace: 'pre-wrap' }); helpContent.innerHTML = `<h2 style="color: var(--color-content-chzzk-02); margin-bottom:6px;">메뉴 사용법</h2><div style="white-space:pre-wrap; line-height:1.4; font-size:14px; color:inherit;"><strong style="display:block; font-weight:600; margin:6px 0 2px;">1. 자동 언뮤트</strong>방송이 시작되면 자동으로 음소거를 해제합니다.\n\n<strong style="display:block; font-weight:600; margin:6px 0 2px;">2. 자동 새로고침</strong>스트리밍 오류 창이 뜨면 즉시, 영상이 5초 이상 멈추면 잠시 후 페이지를 자동으로 새로고침합니다.\n\n<strong style="display:block; font-weight:600; margin:6px 0 2px;">3. 선명한 화면 2.0</strong>외부 스크립트를 적용하여 기본 선명도 기능을 대체합니다.</div>`; const helpBtn = document.createElement('button'); Object.assign(helpBtn, { className: 'allinone-settings-item', style: 'display: flex; align-items: center; margin: 8px 0; padding: 4px 8px; font-family: Sandoll Nemony2, "Apple SD Gothic NEO", "Helvetica Neue", Helvetica, NanumGothic, "Malgun Gothic", gulim, "noto sans", Dotum, sans-serif; font-size: 14px; color: inherit;' }); helpBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="margin-right:10px;" color="inherit"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 1 1 5.82 1c-.5 1.3-2.91 2-2.91 2"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg><span style="margin-left:8px">도움말</span>`; helpBtn.addEventListener('click', () => { helpContent.style.display = helpContent.style.display === K.DISPLAY_NONE ? K.DISPLAY_BLOCK : K.DISPLAY_NONE; }); menu.appendChild(helpBtn); menu.appendChild(helpContent); const unmuteSvgOff = `<svg class="profile_layer_icon__7g3e-" xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 9.75L19.5 12m0 0l2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"/></svg>`; const unmuteSvgOn = `<svg class="profile_layer_icon__7g3e-" xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"/></svg>`; const refreshSvg = `<svg class="profile_layer_icon__7g3e-" xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>`; const sharpSvg = `<svg class="profile_layer_icon__7g3e-" xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 20.25h12m-7.5-3v3m3-3v3m-10.125-3h17.25c.621 0 1.125-.504 1.125-1.125V4.875c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125Z"/></svg>`; [{ key: C.storageKeys.autoUnmute, svg: unmuteSvgOff, onSvg: unmuteSvgOn, label: '자동 언뮤트', default: true }, { key: C.storageKeys.autoRefresh, svg: refreshSvg, onSvg: refreshSvg, label: '자동 새로고침', default: true }, { key: C.storageKeys.screenSharpness, svg: sharpSvg, onSvg: sharpSvg, label: '선명한 화면 2.0', default: false } ].forEach(item => { const itemBtn = document.createElement('button'); itemBtn.className = 'allinone-settings-item'; Object.assign(itemBtn.style, { display: 'flex', alignItems: 'center', margin: '8px 0', padding: '4px 8px', fontFamily: 'Sandoll Nemony2, "Apple SD Gothic NEO", "Helvetica Neue", Helvetica, NanumGothic, "Malgun Gothic", gulim, "noto sans", Dotum, sans-serif', fontSize: '14px', color: 'inherit' }); itemBtn.innerHTML = `${item.svg}<span style="margin-left:8px">${item.label} <span class="state-text">OFF</span></span>`; GM.getValue(item.key, item.default).then(active => { itemBtn.style.opacity = active ? '1' : '0.4'; if (active && item.onSvg) itemBtn.querySelector('svg').outerHTML = item.onSvg; itemBtn.querySelector('.state-text').textContent = active ? 'ON' : 'OFF'; }); itemBtn.addEventListener('click', async() => { const current = await GM.getValue(item.key, item.default); const active = !current; await GM.setValue(item.key, active); const btnToUpdate = menu.querySelector(`[data-key="${item.key}"]`) || itemBtn; btnToUpdate.style.opacity = active ? '1' : '0.4'; if (active && item.onSvg) btnToUpdate.querySelector('svg').outerHTML = item.onSvg; else btnToUpdate.querySelector('svg').outerHTML = item.svg; btnToUpdate.querySelector('.state-text').textContent = active ? 'ON' : 'OFF'; if (item.key === C.storageKeys.screenSharpness) { setTimeout(() => location.reload(), 100); } }); itemBtn.dataset.key = item.key; menu.appendChild(itemBtn); }); return menu; }, /** * UI에 설정 버튼이 없는 경우 주입합니다. 주기적으로 호출되어 UI 변경에 대응합니다. */ ensureMenuExists() { const toolbar = document.querySelector(C.selectors.rightBoundary); if (!toolbar || toolbar.querySelector('.allinone-settings-wrapper')) return; const profileItemWrapper = toolbar.querySelector('.toolbar_profile_button__tZxIO')?.closest('.toolbar_item__w9Z7l'); const parentBox = profileItemWrapper?.parentElement; if (!profileItemWrapper || !parentBox) return; const itemWrapper = document.createElement('div'); itemWrapper.className = 'toolbar_item__w9Z7l allinone-settings-wrapper'; itemWrapper.style.position = 'relative'; const button = this.createButton(); const menu = this.createMenu(); itemWrapper.appendChild(button); itemWrapper.appendChild(menu); parentBox.insertBefore(itemWrapper, profileItemWrapper); button.addEventListener('click', e => { e.stopPropagation(); menu.style.display = (menu.style.display === K.DISPLAY_BLOCK ? K.DISPLAY_NONE : K.DISPLAY_BLOCK); }); document.addEventListener('click', e => { if (!menu.contains(e.target) && e.target !== button) menu.style.display = K.DISPLAY_NONE; }); }, /** * 설정 메뉴 기능의 초기화를 담당합니다. */ init() { this.injectStyles(); if (this.uiInterval) return; this.uiInterval = setInterval(() => { this.ensureMenuExists(); patchPipButton(); findAndPatchLiveButtons(document); }, 1000); } }; /** * @namespace quality * @description 비디오 화질 설정과 관련된 기능을 관리합니다. */ const quality = { isRecovering: false, _applying: false, _lastApply: 0, /** * 사용자가 수동으로 화질을 변경하는 것을 감지하여 선호 화질로 저장합니다. */ observeManualSelect() { document.body.addEventListener("click", async(e) => { const li = e.target.closest('li[class*="quality"]'); if (!li) return; const res = C.extractResolution(li.textContent); if (res) await GM.setValue(C.storageKeys.quality, res); }, { capture: true }); }, /** * 저장된 선호 화질 값을 불러옵니다. * @returns {Promise<number>} 선호 화질. */ async getPreferred() { return parseInt(await GM.getValue(C.storageKeys.quality, 1080), 10); }, /** * 저장된 선호 화질을 비디오 플레이어에 자동으로 적용합니다. * @returns {Promise<void>} */ async applyPreferred() { const now = Date.now(); if (this._applying || now - this._lastApply < C.applyCooldown) return; this._applying = true; this._lastApply = now; try { const qualityBtn = await C.waitFor(C.selectors.qualityBtn, 3000); if (!document.querySelector(C.selectors.qualityItems)) { qualityBtn.click(); } const items = await C.waitFor(C.selectors.qualityItems, 3000).then(() => Array.from(document.querySelectorAll(C.selectors.qualityItems))); if (!items.length) throw new Error("Quality items not found after opening menu."); const target = await GM.getValue(C.storageKeys.quality, 1080); const targetItem = items.find(i => C.extractResolution(i.textContent) === target) || items.find(i => /\d+p/.test(i.textContent)) || items[0]; const isAlreadySelected = targetItem.className.includes('--checked'); if (!isAlreadySelected) { targetItem.click(); await C.sleep(200); } if (document.querySelector(C.selectors.qualityItems)) { document.body.click(); } } catch (e) { console.error("AIO Script: Failed to apply preferred quality.", e); if (document.querySelector(C.selectors.qualityItems)) { document.body.click(); } } finally { this._applying = false; } }, /** * 비디오 화질이 낮아졌을 경우 선호 화질로 복구를 시도합니다. * @param {HTMLVideoElement} video - 화질을 검사할 비디오 요소. */ checkAndFix(video) { if (!video || video.__qualityMonitorAttached) return; video.__qualityMonitorAttached = true; const performCheck = async() => { if (video.paused || this.isRecovering) return; const currentHeight = video.videoHeight; if (currentHeight === 0) return; const preferred = await this.getPreferred(); if (currentHeight < preferred) { this.isRecovering = true; await this.applyPreferred(); setTimeout(() => { this.isRecovering = false; }, K.QUALITY_RECOVERY_TIMEOUT_MS); } }; video.addEventListener('loadedmetadata', performCheck); setInterval(performCheck, C.constants.QUALITY_CHECK_INTERVAL_MS); } }; /** * @namespace handler * @description 페이지의 네이티브 동작(URL 변경)을 감시하는 기능을 관리합니다. */ const handler = { /** * SPA(Single Page Application) 환경에서 URL 변경을 감지하여 관련 기능을 실행합니다. */ trackURLChange() { let lastUrl = location.href, lastId = null; const getId = (url) => (typeof url === 'string' ? (url.match(C.regex.chzzkId)?.groups?.id || null) : null); const onUrlChange = () => { if (location.href === lastUrl) return; lastUrl = location.href; const id = getId(lastUrl); if (id && id !== lastId) { lastId = id; setTimeout(() => { quality.applyPreferred(); monitorStream(); injectSharpnessScript(); if (window.sharpness?.init && window.sharpness?.observeMenus) { window.sharpness.init(); window.sharpness.observeMenus(); } }, C.minTimeout); } }; ["pushState", "replaceState"].forEach(m => { const orig = history[m]; history[m] = function (...a) { const r = orig.apply(this, a); window.dispatchEvent(new Event("locationchange")); return r; }; }); window.addEventListener("popstate", () => window.dispatchEvent(new Event("locationchange"))); window.addEventListener("locationchange", onUrlChange); } }; /** * @namespace observer * @description MutationObserver를 사용하여 DOM 변경을 감시하고 대응하는 기능을 관리합니다. */ const observer = { /** * DOM 변경을 감시하는 MutationObserver를 시작합니다. */ start() { const mo = new MutationObserver(muts => { for (const mut of muts) { for (const node of mut.addedNodes) { if (node.nodeType !== 1) continue; this.tryRemoveAdPopup(node); this.autoClickPowerButton(node); injectSpeedInlineButton(); findAndPatchLiveButtons(node); patchPipButton(node); let vid = (node.tagName === "VIDEO") ? node : node.querySelector("video"); if (/^\/live\/[^/]+/.test(location.pathname) && vid) { this.unmuteAll(vid); quality.checkAndFix(vid); monitorStream(); (async() => { await new Promise(r => { const w = () => (vid.readyState >= 2) ? r() : setTimeout(w, 100); w(); }); try { await vid.play(); } catch {} applyPlaybackRate(1.0); __updateSpeedActive(1.0); })(); } } } }); mo.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] }); }, /** * 비디오 플레이어의 음소거를 해제합니다. * @param {HTMLVideoElement} video - 음소거를 해제할 비디오 요소. */ async unmuteAll(video) { if (!await GM.getValue(C.storageKeys.autoUnmute, true)) return; if (video.muted) video.muted = false; document.querySelector('button.pzp-pc-volume-button[aria-label*="음소거 해제"]')?.click(); }, /** * 광고 차단 안내 팝업을 감지하고 제거합니다. * @param {Node} root - 검색을 시작할 DOM 노드. */ tryRemoveAdPopup(root = document) { try { const popups = root.querySelectorAll(`${C.selectors.popup}:not([data-popup-handled])`); for (const popup of popups) { if (C.regex.adBlockDetect.test(popup.textContent || "")) { popup.dataset.popupHandled = 'true'; popup.style.display = 'none'; const btn = popup.querySelector('button'); if (!btn) continue; const fiberKey = Object.keys(btn).find(k => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$')); const props = fiberKey ? (btn[fiberKey]?.memoizedProps || btn[fiberKey]?.return ?.memoizedProps) : null; const on = props?.confirmHandler || props?.onClick || props?.onClickHandler; on?.({ isTrusted: true }); return; } } } catch {} }, /** * 19+ 방송의 '네, 확인했습니다.' 버튼을 자동으로 클릭합니다. * @param {Node} root - 검색을 시작할 DOM 노드. */ autoClickPowerButton(root = document) { root.querySelectorAll(C.selectors.woodbtn).forEach(btn => { if (!btn.dataset.powerButtonHandled) { btn.dataset.powerButtonHandled = 'true'; btn.click(); } }); }, }; /** * 저장된 선호 재생 속도를 가져옵니다. * @returns {Promise<number>} 선호 재생 속도. */ async function getPreferredRate() { return Number(await GM.getValue(C.storageKeys.playbackRate, 1.0)) || 1.0; } /** * 새로운 재생 속도를 저장합니다. * @param {number} v - 저장할 재생 속도. */ async function setPreferredRate(v) { await GM.setValue(C.storageKeys.playbackRate, v); } const __tmSpeedUIs = new Set(); /** * 재생 속도 UI의 텍스트를 업데이트합니다. * @param {number} rate - 현재 재생 속도. */ function __updateSpeedActive(rate) { const lbl = document.querySelector('#tm-speed-inline-btn .tm-speed-inline-label'); if (lbl) lbl.textContent = `${rate}x`; __tmSpeedUIs.forEach(fn => { try { fn(rate); } catch {} }); } /** * 비디오 요소에 재생 속도를 적용합니다. * @param {number} rate - 적용할 재생 속도. */ function applyPlaybackRate(rate) { const v = getVideo(); if (v) v.playbackRate = rate; __updateSpeedActive(rate); } /** * 라이브 방송의 맨 끝으로 탐색합니다. */ function seekToLiveEdge() { const v = getVideo(); if (!v) return; try { const end = (v.seekable && v.seekable.length) ? v.seekable.end(v.seekable.length - 1) : (!isNaN(v.duration) ? v.duration : Infinity); if (isFinite(end)) v.currentTime = Math.max(0, end - 0.5); v.play?.(); setTimeout(updateLiveTimeDotColor, 50); } catch {} } /** * 현재 라이브 스트림의 끝에 있는지 확인합니다. * @returns {boolean} 라이브 끝에 있는지 여부. */ const __isLive = () => { const v = getVideo(); if (!v) return false; try { const end = (v.seekable && v.seekable.length) ? v.seekable.end(v.seekable.length - 1) : v.duration; const dist = end - v.currentTime; return isFinite(dist) && dist <= K.LIVE_EDGE_SECONDS; } catch { return false; } } /** * 라이브 시간 표시 점의 색상을 업데이트합니다. */ function updateLiveTimeDotColor() { const color = __isLive() ? '#fb1f1f' : '#838285'; document.querySelectorAll('button[class*="live_time_button"] span[class*="live_time_dot"]').forEach(dot => { if (dot.closest('[class*="live_chatting"]')) return; dot.style.backgroundColor = color; }); } /** * 라이브 시간 표시 점의 색상을 주기적으로 업데이트하는 타이머를 시작합니다. */ function startLiveDotWatcher() { if (window.__chzzkDotTimer) return; window.__chzzkDotTimer = setInterval(updateLiveTimeDotColor, 500); updateLiveTimeDotColor(); } /** * '실시간' 버튼의 기본 동작을 수정하여 라이브 끝으로 탐색하도록 합니다. * @param {Node} root - 검색을 시작할 DOM 노드. */ function findAndPatchLiveButtons(root = document) { const buttons = root.querySelectorAll('button[class*="live_time_button"]'); buttons.forEach(btn => { if (btn.dataset.tmListener) return; btn.dataset.tmListener = 'true'; btn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); seekToLiveEdge(); }, true); btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); }, true); }); } /** * 플레이어 컨트롤 바에 재생 속도 조절 UI를 추가합니다. */ function injectSpeedInlineButton() { const settingsBtn = document.querySelector(C.selectors.qualityBtn); if (!settingsBtn || document.getElementById('tm-speed-inline-btn')) return; let pipBtn = C.selectors.pipBtns.map(s => document.querySelector(s)).find(el => el); const btn = document.createElement('button'); btn.id = 'tm-speed-inline-btn'; btn.type = 'button'; btn.setAttribute('aria-label', '재생 속도'); btn.className = settingsBtn.className; btn.style.position = 'relative'; btn.innerHTML = `<span class="tm-speed-inline-label" style="display:inline-block; min-width:30px; text-align:center; font-weight:700; font-size:12px; color:#fff;">1x</span>`; const target = (pipBtn && pipBtn.parentNode === settingsBtn.parentNode) ? pipBtn : settingsBtn; target.insertAdjacentElement(pipBtn ? 'afterend' : 'beforebegin', btn); const portal = document.createElement('div'); portal.id = 'tm-speed-popover-host'; document.body.appendChild(portal); const sr = portal.attachShadow({ mode: 'open' }); sr.innerHTML = `<style>:host{all:initial}.panel{position:fixed;z-index:2147483647;background:rgba(20,20,24,.98);color:#fff;border:1px solid rgba(255,255,255,.15);border-radius:10px;padding:10px;box-shadow:0 10px 30px rgba(0,0,0,.45);}.grid{display:grid;grid-template-columns:1fr;gap:6px}.btn{all:unset;display:inline-block;text-align:center;cursor:pointer;padding:8px 10px;font-size:12px;line-height:1;border-radius:8px;color:#fff;background:transparent;user-select:none}.btn:hover{background:rgba(255,255,255,.12)}.btn.active{background:rgba(255,255,255,.1);outline:1px solid rgba(255,255,255,.18)}.hidden{display:none}</style>`; const panel = document.createElement('div'); panel.className = 'panel hidden'; const grid = document.createElement('div'); grid.className = 'grid'; K.PLAYBACK_RATES.forEach(r => { const b = document.createElement('button'); b.className = 'btn'; b.textContent = `${r}x`; b.addEventListener('click', async e => { e.preventDefault(); await setPreferredRate(r); applyPlaybackRate(r); hide(); }); grid.appendChild(b); }); panel.appendChild(grid); sr.appendChild(panel); const updateActive = r => sr.querySelectorAll('.btn').forEach(b => b.classList.toggle('active', b.textContent === `${r}x`)); __tmSpeedUIs.add(updateActive); const hide = () => { panel.classList.add('hidden'); window.removeEventListener('pointerdown', onOutside, true); }; const onOutside = e => { if (e.target !== btn && !e.composedPath().includes(panel)) hide(); }; const show = () => { const tooltip = btn.querySelector('.pzp-button__tooltip'); if (tooltip) tooltip.remove(); panel.classList.remove('hidden'); const rect = btn.getBoundingClientRect(); const pw = 60; panel.style.left = Math.max(8, Math.min(window.innerWidth - 8 - pw, rect.left + (rect.width / 2) - (pw / 2))) + 'px'; panel.style.top = ((rect.top - 8 - panel.offsetHeight > 0) ? (rect.top - 8 - panel.offsetHeight) : (rect.bottom + 8)) + 'px'; getPreferredRate().then(updateActive); window.addEventListener('pointerdown', onOutside, true); }; btn.addEventListener('mouseenter', () => { if (panel.classList.contains('hidden') && !btn.querySelector('.pzp-button__tooltip')) { const tooltip = document.createElement('span'); tooltip.className = 'pzp-button__tooltip pzp-button__tooltip--top'; tooltip.textContent = '재생 속도 (<,>)'; btn.appendChild(tooltip); } }); btn.addEventListener('mouseleave', () => { const tooltip = btn.querySelector('.pzp-button__tooltip'); if (tooltip) tooltip.remove(); }); btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); panel.classList.contains('hidden') ? show() : hide(); }); __updateSpeedActive(1.0); } /** * PIP 버튼의 툴팁을 수정하여 단축키 정보를 표시합니다. */ function patchPipButton() { const pipBtn = C.selectors.pipBtns.map(s => document.querySelector(s)).find(el => el); if (!pipBtn) return; const newLabel = 'PIP 보기 (p)'; if (pipBtn.getAttribute('aria-label') !== newLabel) { pipBtn.setAttribute('aria-label', newLabel); } const tooltip = pipBtn.querySelector('.pzp-button__tooltip'); if (tooltip && tooltip.textContent !== newLabel) { tooltip.textContent = newLabel; } } /** * 라이브 방송 시청 시, 재생 속도가 1배속을 초과하면 자동으로 1배속으로 복구합니다. */ function startAutoOneXWatcher() { setInterval(async() => { if (!await GM.getValue(C.storageKeys.autoLive1x, true)) return; const v = getVideo(); if (!v || v.playbackRate <= 1.0) return; if (__isLive()) { v.playbackRate = 1.0; __updateSpeedActive(1.0); } }, K.AUTO_ONEX_INTERVAL_MS); } /** * 스트림 오류 및 멈춤 현상을 감지하여 페이지를 새로고침합니다. */ function monitorStream() { if (window.stallCheckInterval) clearInterval(window.stallCheckInterval); if (window.errorCheckInterval) clearInterval(window.errorCheckInterval); let lastTime = 0; let stallStart = 0; window.errorCheckInterval = setInterval(async() => { const autoRefresh = await GM.getValue(C.storageKeys.autoRefresh, true); if (!autoRefresh || document.hidden) return; const errorDialog = document.querySelector(C.selectors.errorDialog); if (errorDialog && errorDialog.offsetParent !== null) { location.reload(); } }, 1000); window.stallCheckInterval = setInterval(async() => { const autoRefresh = await GM.getValue(C.storageKeys.autoRefresh, true); const video = getVideo(); if (!autoRefresh || !video || video.paused || document.hidden) { stallStart = 0; return; } if (lastTime > 0 && video.currentTime === lastTime) { if (stallStart === 0) { stallStart = Date.now(); } else if (Date.now() - stallStart >= K.STALL_THRESHOLD_MS) { location.reload(); } } else { stallStart = 0; } lastTime = video.currentTime; }, K.STALL_CHECK_INTERVAL_MS); } /** * 재생 속도 변경 시 안내 토스트를 화면에 표시합니다. * @param {number} rate - 표시할 재생 속도. */ let speedToastTimeoutId = null; function showSpeedToast(rate) { const player = document.querySelector(C.selectors.videoPlayer); if (!player) return; let toastContainer = player.querySelector('.chzzk-toast-container'); if (!toastContainer) { toastContainer = document.createElement('div'); toastContainer.className = 'chzzk-toast-container'; Object.assign(toastContainer.style, { position: 'absolute', top: '0', left: '0', width: '100%', height: '100%', zIndex: '9999', pointerEvents: 'none' }); player.appendChild(toastContainer); } let toast = toastContainer.querySelector('.chzzk-speed-toast'); if (!toast) { toast = document.createElement('div'); toast.className = 'chzzk-speed-toast'; toastContainer.appendChild(toast); } toast.textContent = `${rate.toFixed(2).replace(/\.00$/, '')}x`; toast.classList.add('visible'); clearTimeout(speedToastTimeoutId); speedToastTimeoutId = setTimeout(() => { toast.classList.remove('visible'); }, K.TOAST_DISPLAY_TIME_MS); } /** * PIP, 재생 속도 조절 등 키보드 단축키를 설정합니다. */ function setupKeyboardShortcuts() { window.addEventListener('keydown', async(e) => { const activeEl = document.activeElement; if (activeEl && (['INPUT', 'TEXTAREA'].includes(activeEl.tagName) || activeEl.matches(C.selectors.chatInput))) { return; } if (e.key.toLowerCase() === 'p') { e.preventDefault(); const pipBtn = C.selectors.pipBtns.map(s => document.querySelector(s)).find(el => el); pipBtn?.click(); return; } const rates = K.PLAYBACK_RATES; const video = getVideo(); if (!video) return; let currentIndex = rates.indexOf(video.playbackRate); if (currentIndex === -1) { const closest = rates.reduce((prev, curr) => (Math.abs(curr - video.playbackRate) < Math.abs(prev - video.playbackRate) ? curr : prev)); currentIndex = rates.indexOf(closest); } let newIndex = currentIndex; if (e.key === '<' || e.key === ',') { newIndex = Math.max(0, currentIndex - 1); } else if (e.key === '>' || e.key === '.') { newIndex = Math.min(rates.length - 1, currentIndex + 1); } else { return; } if (newIndex !== currentIndex) { const newRate = rates[newIndex]; await setPreferredRate(newRate); applyPlaybackRate(newRate); showSpeedToast(newRate); } }); } /** * '선명한 화면' 기능이 활성화된 경우, 관련 외부 스크립트를 주입합니다. * @returns {Promise<void>} */ async function injectSharpnessScript() { const enabled = await GM.getValue(C.storageKeys.screenSharpness, false); if (!enabled) return; const script = document.createElement("script"); script.src = "https://update.greasyfork.icu/scripts/548009/Chzzk%20%EC%84%A0%EB%AA%85%ED%95%9C%20%ED%99%94%EB%A9%B4%20%EC%97%85%EA%B7%B8%EB%A0%88%EC%9D%B4%EB%93%9C.user.js"; script.async = true; document.head.appendChild(script); } /** * @async * @function init * @description 스크립트의 주요 기능들을 초기화합니다. */ async function init() { await(async() => { C.debug = await GM.getValue(C.storageKeys.debugLog, false); })(); if ((await GM.getValue(C.storageKeys.quality)) === undefined) await GM.setValue(C.storageKeys.quality, 1080); if ((await GM.getValue(C.storageKeys.autoUnmute)) === undefined) await GM.setValue(C.storageKeys.autoUnmute, true); if ((await GM.getValue(C.storageKeys.autoRefresh)) === undefined) await GM.setValue(C.storageKeys.autoRefresh, true); if ((await GM.getValue(C.storageKeys.screenSharpness)) === undefined) await GM.setValue(C.storageKeys.screenSharpness, false); await GM.setValue(C.storageKeys.playbackRate, 1.0); if ((await GM.getValue(C.storageKeys.autoLive1x)) === undefined) await GM.setValue(C.storageKeys.autoLive1x, true); AllInOneMenu.init(); await quality.applyPreferred(); injectSpeedInlineButton(); findAndPatchLiveButtons(document); patchPipButton(document); startLiveDotWatcher(); startAutoOneXWatcher(); monitorStream(); setupKeyboardShortcuts(); injectSharpnessScript(); } /** * @function onDomReady * @description DOM 콘텐츠가 로드된 후 스크립트의 실행을 시작하는 진입점 함수. */ function onDomReady() { quality.observeManualSelect(); observer.start(); init().catch(console.error); handler.trackURLChange(); } if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", onDomReady); else onDomReady(); })();