Chzzk 올인원 스크립트 (Auto Quality + Ad Popup Removal + Unmute)

Chzzk 방송에서 자동 화질 설정, 광고 팝업 차단, 음소거 자동 해제, 스크롤 잠금 해제, 360p 복구

// ==UserScript==
// @name Chzzk 올인원 스크립트 (Auto Quality + Ad Popup Removal + Unmute)
// @namespace http://tampermonkey.net/
// @version 4.1.2
// @description Chzzk 방송에서 자동 화질 설정, 광고 팝업 차단, 음소거 자동 해제, 스크롤 잠금 해제, 360p 복구
// @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";
    /**
     * @typedef {object} RegexConfig
     * @property {RegExp} adBlockDetect - 광고 차단 팝업을 감지하는 정규식
     * @property {RegExp} chzzkId - URL에서 방송 ID를 추출하는 정규식
     * @property {RegExp} version - 메타 정보에서 스크립트 버전을 추출하는 정규식.
     * @class Config
     * @description 스크립트의 모든 설정, 선택자, 유틸리티 함수를 중앙에서 관리하는 클래스.
     */
    class Config {
        #applyCooldown = 1000;
        #minTimeout = 500;
        #defaultTimeout = 2000;
        #storageKeys = {
            quality: "chzzkPreferredQuality",
            autoUnmute: "chzzkAutoUnmute",
            debugLog: "chzzkDebugLog",
            screenSharpness: "chzzkScreenSharp",
            ignoredUpdate: "chzzkIgnoredUpdateDate",
        };
        #selectors = {
            popup: 'div[class^="popup_container"]',
            qualityBtn: 'button[command="SettingCommands.Toggle"]',
            qualityMenu: 'div[class*="pzp-pc-setting-intro-quality"]',
            qualityItems: 'li.pzp-ui-setting-quality-item[role="menuitem"]',
            headerMenu: ".header_service__DyG7M",
        };
        #styles = {
            success: "font-weight:bold; color:green",
            error: "font-weight:bold; color:red",
            info: "font-weight:bold; color:skyblue",
            warn: "font-weight:bold; color:orange",
        };
        #regex = {
            adBlockDetect: /광고\s*차단\s*프로그램.*사용\s*중/i,
            chzzkId: /(?:live|video)\/(?<id>[^/]+)/,
            version: /^\s*\/\/\s*@version\s+([\d.]+)/m,
        };
        #debug = true;

        /** @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 styles() { return this.#styles; }
        /** @returns {RegexConfig} 정규 표현식 목록 */
        get regex() { return this.#regex; }
        /** @returns {boolean} 디버그 로그 활성화 여부 */
        get debug() { return this.#debug; }
        /** @param {boolean} value - 디버그 로그 활성화 상태 */
        set debug(value) { this.#debug = !!value; }
        /**
         * 지정된 시간(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);
            });
        };
        /**
         * 텍스트에서 불필요한 공백을 정리합니다.
         * @param {string} txt - 정리할 원본 텍스트.
         * @returns {string} 정리된 텍스트.
         */
        cleanText = (txt) => txt.trim().split(/\s+/).filter(Boolean).join(", ");
        /**
         * 텍스트에서 해상도 값을 숫자로 추출합니다. (예: "1080p" -> 1080)
         * @param {string} txt - 해상도 정보가 포함된 텍스트.
         * @returns {number|null} 추출된 해상도 숫자 또는 null.
         */
        extractResolution = (txt) => {
            const m = txt.match(/(\d{3,4})p/);
            return m ? parseInt(m[1], 10) : null;
        };
        /**
         * DOM 요소를 제거합니다.
         * @param {Element} el - 제거할 요소.
         */
        removeElement = (el) => el?.remove();
        /**
         * DOM 요소의 인라인 스타일을 모두 제거합니다.
         * @param {Element} el - 스타일을 제거할 요소.
         */
        clearStyle = (el) => el?.removeAttribute("style");
        // --- Logger Methods ---
        info = (...args) => this.#debug && console.log(...args);
        success = (...args) => this.#debug && console.log(...args);
        warn = (...args) => this.#debug && console.warn(...args);
        error = (...args) => this.#debug && console.error(...args);
        groupCollapsed = (...args) => this.#debug && console.groupCollapsed(...args);
        table = (...args) => this.#debug && console.table(...args);
        groupEnd = (...args) => this.#debug && console.groupEnd(...args);
        /**
         * 특정 요소가 나타나면 콜백 함수를 실행하는 MutationObserver를 등록합니다.
         * @param {string} selector - 감시할 요소의 CSS 선택자.
         * @param {function(Element): void} callback - 요소가 발견됐을 때 실행할 콜백 함수.
         * @param {boolean} [once=true] - 한 번만 실행할지 여부.
         */
        observeElement = (selector, callback, once = true) => {
            const mo = new MutationObserver(() => {
                const el = document.querySelector(selector);
                if (el) {
                    callback(el);
                    if (once) mo.disconnect();
                }
            });
            mo.observe(document.body, { childList: true, subtree: true });
            const initial = document.querySelector(selector);
            if (initial) {
                callback(initial);
                if (once) mo.disconnect();
            }
        };
    }
    /** @type {Config} 스크립트 전역 설정 및 유틸리티 인스턴스 */

    const C = new Config();

    /**
     * @namespace updateChecker
     * @description 스크립트의 새로운 버전을 확인하고 사용자에게 알림을 표시합니다.
     */
    const updateChecker = {
        /**
         * 현재 날짜를 YYYY-MM-DD 형식의 문자열로 반환합니다.
         * @returns {string}
         */
        getTodayDate() {
            const d = new Date();
            return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
        },

        /**
         * 두 버전 문자열을 비교합니다.
         * @param {string} v1
         * @param {string} v2
         * @returns {number} v1 > v2 이면 1, v1 < v2 이면 -1, 같으면 0
         */
        compareVersions(v1, v2) {
            const parts1 = v1.split('.').map(Number);
            const parts2 = v2.split('.').map(Number);
            const len = Math.max(parts1.length, parts2.length);
            for (let i = 0; i < len; i++) {
                const p1 = parts1[i] || 0;
                const p2 = parts2[i] || 0;
                if (p1 > p2) return 1;
                if (p1 < p2) return -1;
            }
            return 0;
        },

        /**
         * 업데이트 알림 UI를 화면에 표시합니다.
         * @param {string} currentVersion - 현재 설치된 버전
         * @param {string} newVersion - 새로 발견된 버전
         */
        showNotification(currentVersion, newVersion) {
            const notificationId = 'chzzk-update-notification';
            if (document.getElementById(notificationId)) return;

            let updateLink = '#';
            const scriptId = "534791";

            if (scriptId) {
                updateLink = `https://gf.qytechs.cn/ko/scripts/${scriptId}`;
            }

            console.log(`[UpdateCheck] 생성된 업데이트 링크: ${updateLink}`);

            const notice = document.createElement('div');
            notice.id = notificationId;
            Object.assign(notice.style, {
                position: 'fixed',
                bottom: '20px',
                right: '20px',
                backgroundColor: 'rgba(30, 30, 30, 0.8)',
                backdropFilter: 'blur(45px)',
                webkitBackdropFilter: 'blur(45px)',
                color: 'var(--color-content-01)',
                padding: '20px',
                borderRadius: '12px',
                boxShadow: '0 4px 20px rgba(0, 0, 0, 0.25)',
                zIndex: '99999',
                fontFamily: 'sans-serif',
                fontSize: '14px',
                border: '1px solid var(--color-content-chzzk-02)',
                transition: 'opacity 0.3s ease, transform 0.3s ease',
                opacity: '0',
                transform: 'translateY(10px)',
            });

            notice.innerHTML = `
            <h4 style="margin: 0 0 10px; font-size: 16px; color: var(--color-content-chzzk-02);">올인원 스크립트 업데이트 알림</h4>
            <p style="margin: 0 0 15px;">새로운 버전이 출시되었습니다! (v${currentVersion} → <strong>v${newVersion}</strong>)</p>
            <a href="${updateLink}" target="_blank" style="display: inline-block; background-color: #00c73c; color: white; padding: 8px 15px; border-radius: 6px; text-decoration: none; font-weight: bold; margin-right: 10px;">지금 업데이트</a>
            <button id="chzzk-update-close" style="background: none; border: 1px solid var(--color-content-01); color: var(--color-content-01); padding: 7px 12px; border-radius: 6px; cursor: pointer;">닫기</button>
            <div style="margin-top: 15px; display: flex; align-items: center;">
            <input type="checkbox" id="chzzk-ignore-today" style="margin-right: 5px; accent-color: #00c73c;">
            <label for="chzzk-ignore-today" style="font-size: 12px; color: var(--color-content-03); cursor: pointer;">오늘 하루 보지 않기</label>
            </div>
            `;

            document.body.appendChild(notice);

            setTimeout(() => {
                notice.style.opacity = '1';
                notice.style.transform = 'translateY(0)';
            }, 10);

            notice.querySelector('#chzzk-update-close').addEventListener('click', async () => {
                if (notice.querySelector('#chzzk-ignore-today').checked) {
                    await GM.setValue(C.storageKeys.ignoredUpdate, this.getTodayDate());
                }
                notice.style.opacity = '0';
                notice.style.transform = 'translateY(10px)';
                setTimeout(() => {
                    notice.remove();
                }, 300);
            });
        },

        /**
         * 스크립트 업데이트를 확인합니다.
         */
        async check() {
            console.log("[UpdateCheck] >> 업데이트 확인 프로세스 시작 (최종 디버그 방식)");
            try {
                const ignoredDate = await GM.getValue(C.storageKeys.ignoredUpdate, null);
                const today = this.getTodayDate();

                if (ignoredDate === today) {
                    return;
                }

                const scriptId = "534791";

                if (!scriptId) {
                    C.error("[UpdateCheck] >> 스크립트 ID가 지정되지 않았습니다.");
                    return;
                }

                const jsonApiUrl = `https://gf.qytechs.cn/en/scripts/${scriptId}/versions.json`;
                const response = await fetch(jsonApiUrl, { cache: 'no-cache' });

                if (!response.ok) {
                    C.error(`[UpdateCheck] >> 네트워크 오류(${response.status})로 JSON API 확인에 실패했습니다.`);
                    return;
                }

                const versionsData = await response.json();

                if (!Array.isArray(versionsData) || versionsData.length === 0 || !versionsData[0].version) {
                    C.error("[UpdateCheck] >> 수신된 JSON 데이터가 올바르지 않거나 버전 정보가 없습니다.");
                    return;
                }

                const latestVersion = versionsData[0].version;
                const currentVersion = GM.info.script.version;

                C.success(`[UpdateCheck] 현재 설치된 버전: ${currentVersion}`);
                C.success(`[UpdateCheck] 서버에서 확인된 최신 버전: ${latestVersion}`);

                const comparisonResult = this.compareVersions(latestVersion, currentVersion);

                C.success(`[UpdateCheck] 버전 비교 결과: ${comparisonResult}`);

                if (comparisonResult > 0) {
                    C.success("[UpdateCheck] >> 새 버전을 발견하여 알림을 표시합니다.");
                    this.showNotification(currentVersion, latestVersion);
                } else {
                    C.success("[UpdateCheck] >> 새 버전이 없으므로 알림을 표시하지 않습니다.");
                }
            } catch (error) {
                if (error instanceof TypeError && error.message.includes("GM.info")) {
                    C.warn("[UpdateCheck] >> 스크립트 정보를 읽어오는 중 호환성 문제가 발생했으나, 업데이트 확인은 계속 진행합니다.");
                } else {
                    C.error("[UpdateCheck] >> 업데이트 확인 중 예외 오류 발생:", error);
                }
            }
        }
    };

    /**
     * @async
     * @function addHeaderMenu
     * @description 치지직 헤더에 스크립트 설정 메뉴 UI를 추가합니다.
     * @returns {Promise<void>}
     */
    async function addHeaderMenu() {
        if (!document.getElementById('chzzk-allinone-styles')) {
            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;
                }
            `;
            document.head.appendChild(customStyles);
        }

        const toolbar = await C.waitFor('.toolbar_section__maAwZ');
        if (!toolbar || toolbar.querySelector('.allinone-settings-wrapper')) return;

        const boxWrapper = document.createElement('div');
        boxWrapper.className = 'toolbar_box__2DzCd';

        const itemWrapper = document.createElement('div');
        itemWrapper.className = 'toolbar_item__w9Z7l allinone-settings-wrapper';
        itemWrapper.style.position = 'relative';

        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', () => {
            if (itemWrapper.querySelector('.button_label__fyHZ6')) return;
            const tooltip = document.createElement('span');
            tooltip.className = 'button_label__fyHZ6 allinone-tooltip-position';
            tooltip.textContent = '올인원 환경설정';
            itemWrapper.appendChild(tooltip);
        });

        btn.addEventListener('mouseleave', () => {
            const tooltip = itemWrapper.querySelector('.button_label__fyHZ6');
            if (tooltip) tooltip.remove();
        });

        itemWrapper.appendChild(btn);
        boxWrapper.appendChild(itemWrapper);

        const profileBox = toolbar.querySelector('.toolbar_profile_button__tZxIO')?.closest('.toolbar_box__2DzCd');
        if (profileBox) {
            toolbar.insertBefore(boxWrapper, profileBox);
        } else {
            toolbar.appendChild(boxWrapper);
        }

        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: 'none'
        });

        itemWrapper.appendChild(menu);

        const helpContent = document.createElement('div');
        helpContent.className = 'allinone-help-content';

        Object.assign(helpContent.style, {
            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>' +
            '“선명한 화면 2.0” 옵션을 활성화하면 개발자가 제작한 외부 스크립트를 적용하여, 기본 제공되는 선명도 기능을 대체합니다.' +
            '</div>';

        const helpBtn = document.createElement('button');
        helpBtn.className = 'allinone-settings-item';
        helpBtn.style.display = 'flex';
        helpBtn.style.alignItems = 'center';
        helpBtn.style.margin = '8px 0';
        helpBtn.style.padding = '4px 8px';
        helpBtn.style.fontFamily = 'Sandoll Nemony2, Apple SD Gothic NEO, Helvetica Neue, Helvetica, NanumGothic, Malgun Gothic, gulim, noto sans, Dotum, sans-serif';
        helpBtn.style.fontSize = '14px';
        helpBtn.style.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 === 'none' ? 'block' : '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 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"/></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>`;

        const items = [
            { key: C.storageKeys.autoUnmute, svg: unmuteSvgOff, onSvg: unmuteSvgOn, label: '자동 언뮤트' },
            { key: C.storageKeys.screenSharpness, svg: sharpSvg, onSvg: sharpSvg, label: '선명한 화면 2.0' },
        ];

        items.forEach(item => {
            const itemBtn = document.createElement('button');
            itemBtn.className = 'allinone-settings-item';
            itemBtn.style.display = 'flex';
            itemBtn.style.alignItems = 'center';
            itemBtn.style.margin = '8px 0';
            itemBtn.style.padding = '4px 8px';
            itemBtn.style.fontFamily = 'Sandoll Nemony2, Apple SD Gothic NEO, Helvetica Neue, Helvetica, NanumGothic, Malgun Gothic, gulim, noto sans, Dotum, sans-serif';
            itemBtn.style.fontSize = '14px';
            itemBtn.style.color = 'inherit';
            itemBtn.innerHTML = `
            ${item.svg}
            <span style="margin-left:8px">${item.label}${item.key ? ' <span class="state-text">OFF</span>' : ''}</span>
        `;

            if (!item.key) {
                itemBtn.style.opacity = '1';
                itemBtn.addEventListener('click', item.onClick);
            } else {
                GM.getValue(item.key, false).then(active => {
                    itemBtn.style.opacity = active ? '1' : '0.4';
                    if (active) itemBtn.querySelector('svg').outerHTML = item.onSvg;
                    const stateSpan = itemBtn.querySelector('.state-text');
                    stateSpan.textContent = active ? 'ON' : 'OFF';
                });
                itemBtn.addEventListener('click', async () => {
                    const active = await GM.getValue(item.key, false);
                    const newActive = !active;
                    await GM.setValue(item.key, newActive);
                    setTimeout(() => {
                        location.reload();
                    }, 100);
                });
            }
            menu.appendChild(itemBtn);
        });

        btn.addEventListener('click', e => {
            e.stopPropagation();
            menu.style.display = menu.style.display === 'block' ? 'none' : 'block';
        });

        document.addEventListener('click', e => {
            if (!menu.contains(e.target) && e.target !== btn) {
                menu.style.display = 'none';
            }
        });
    }

    window.addHeaderMenu = addHeaderMenu;

    unsafeWindow.toggleDebugLogs = async () => {
        const key = C.storageKeys.debugLog;
        const current = await GM.getValue(key, false);
        const next = !current;
        await GM.setValue(key, next);
        C.debug = next;
        console.log(`🛠️ Debug logs ${next ? 'ENABLED' : 'DISABLED'}`);
    };
    /**
     * @namespace quality
     * @description 비디오 화질 설정과 관련된 기능을 관리합니다.
     */
    const quality = {
        observeManualSelect() {
            document.body.addEventListener("click", async (e) => {
                const li = e.target.closest('li[class*="quality"]');
                if (!li) return;
                const raw = li.textContent;
                const res = C.extractResolution(raw);
                if (res) {
                    await GM.setValue(C.storageKeys.quality, res);
                    C.groupCollapsed("%c💾 [Quality] 수동 화질 저장됨", C.styles.success);
                    C.table([{ "선택 해상도": res, 원본: C.cleanText(raw) }]);
                    C.groupEnd();
                }
            }, { capture: true });
        },
        /**
         * 저장된 선호 화질 값을 불러옵니다.
         * @returns {Promise<number>} 선호 화질.
         */
        async getPreferred() {
            const stored = await GM.getValue(C.storageKeys.quality, 1080);
            return parseInt(stored, 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;

            const target = await this.getPreferred();
            let cleaned = "(선택 실패)", pick = null;
            try {
                const btn = await C.waitFor(C.selectors.qualityBtn);
                btn.click();
                const menu = await C.waitFor(C.selectors.qualityMenu);
                menu.click();
                await C.sleep(C.minTimeout);

                const items = Array.from(document.querySelectorAll(C.selectors.qualityItems));
                pick =
                    items.find((i) => C.extractResolution(i.textContent) === target) ||
                    items.find((i) => /\d+p/.test(i.textContent)) ||
                    items[0];
                cleaned = pick ? C.cleanText(pick.textContent) : cleaned;
                if (pick) {
                    pick.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
                } else {
                    C.warn("[Quality] 화질 항목을 찾지 못함");
                }
            } catch (e) {
                C.error(`[Quality] 선택 실패: ${e.message}`);
            }
            C.groupCollapsed("%c⚙️ [Quality] 자동 화질 적용", C.styles.info);
            C.table([{ "대상 해상도": target }]);
            C.table([{ "선택 화질": cleaned, "선택 방식": pick ? "자동" : "없음" }]);
            C.groupEnd();
            this._applying = false;
        },
    };
    /**
     * @namespace handler
     * @description 페이지의 네이티브 동작(XHR, URL 변경)을 가로채거나 감시하는 기능을 관리합니다.
     */
    const handler = {
        interceptXHR() {
            const oOpen = XMLHttpRequest.prototype.open;
            const oSend = XMLHttpRequest.prototype.send;
            XMLHttpRequest.prototype.open = function (m, u, ...a) {
                this._url = u;
                return oOpen.call(this, m, u, ...a);
            };
            XMLHttpRequest.prototype.send = function (body) {
                if (this._url?.includes("live-detail")) {
                    this.addEventListener("readystatechange", () => {
                        if (this.readyState === 4 && this.status === 200) {
                            try {
                                const data = JSON.parse(this.responseText);
                                if (data.content?.p2pQuality) {
                                    data.content.p2pQuality = [];
                                    const mod = JSON.stringify(data);
                                    Object.defineProperty(this, "responseText", { value: mod });
                                    Object.defineProperty(this, "response", { value: mod });
                                    setTimeout(() => quality.applyPreferred(), C.minTimeout);
                                }
                            } catch (e) {
                                C.error(`[XHR] JSON 파싱 오류: ${e.message}`);
                            }
                        }
                    });
                }
                return oSend.call(this, body);
            };
            C.info("[XHR] live-detail 요청 감시 시작");
        },
        trackURLChange() {
            let lastUrl = location.href;
            let lastId = null;

            const getId = (url) => (typeof url === 'string' ? (url.match(C.regex.chzzkId)?.groups?.id || null) : null);
            const onUrlChange = () => {
                const currentUrl = location.href;
                if (currentUrl === lastUrl) return;

                lastUrl = currentUrl;

                const id = getId(currentUrl);
                if (!id) {
                    C.info("[URLChange] 방송 ID 없음");
                } else if (id !== lastId) {
                    lastId = id;
                    setTimeout(() => {
                        quality.applyPreferred();
                        injectSharpnessScript();
                    }, C.minTimeout);
                } else {
                    C.warn(`[URLChange] 같은 방송(${id}), 스킵`);
                }
                const svg = document.getElementById("sharpnessSVGContainer");
                const style = document.getElementById("sharpnessStyle");
                if (svg) svg.remove();
                if (style) style.remove();
                if (window.sharpness) {
                    window.sharpness.init();
                    window.sharpness.observeMenus();
                }
            };
            ["pushState", "replaceState"].forEach((method) => {
                const original = history[method];
                history[method] = function (...args) {
                    const result = original.apply(this, args);
                    window.dispatchEvent(new Event("locationchange"));
                    return result;
                };
            });
            window.addEventListener("popstate", () =>
                window.dispatchEvent(new Event("locationchange"))
            );
            window.addEventListener("locationchange", onUrlChange);
        },
    };
    /**
     * @namespace observer
     * @description MutationObserver를 사용하여 DOM 변경을 감시하고 대응하는 기능을 관리합니다.
     */
    const observer = {
        start() {
            const mo = new MutationObserver((muts) => {
                for (const mut of muts) {
                    for (const node of mut.addedNodes) {
                        if (node.nodeType !== 1) continue;
                        this.tryRemoveAdPopup();
                        let vid = null;
                        if (node.tagName === "VIDEO") {
                            vid = node;
                        } else if (node.querySelector) {
                            vid = node.querySelector("video");
                        }
                        if (/^\/live\/[^/]+/.test(location.pathname) && vid) {
                            this.unmuteAll(vid);
                            checkAndFixLowQuality(vid);
                            (async () => {
                                await new Promise((resolve) => {
                                    const waitForReady = () => {
                                        if (vid.readyState >= 4) return resolve();
                                        setTimeout(waitForReady, 100);
                                    };
                                    waitForReady();
                                });
                                try {
                                    await vid.play();
                                    C.success("%c▶️ [AutoPlay] 재생 성공", C.styles.info);
                                } catch (e) {
                                    C.error(`⚠️ [AutoPlay] 재생 실패: ${e.message}`);
                                }
                            })();
                        }
                    }
                }
            });
            mo.observe(document.body, {
                childList: true,
                subtree: true,
                attributes: true,
                attributeFilter: ["style"],
            });
            C.info("[Observer] 통합 감시 시작");
        },
        /**
         * 비디오 플레이어의 음소거를 해제합니다.
         * @param {HTMLVideoElement} video - 음소거를 해제할 비디오 요소.
         * @returns {Promise<void>}
         */
        async unmuteAll(video) {
            const autoUnmute = await GM.getValue(C.storageKeys.autoUnmute, true);
            if (!autoUnmute) return C.info("[Unmute] 설정에 따라 스킵");
            if (video.muted) {
                video.muted = false;
                C.success("[Unmute] video.muted 해제");
            }
            const btn = document.querySelector('button.pzp-pc-volume-button[aria-label*="음소거 해제"]');
            if (btn) {
                btn.click();
                C.success("[Unmute] 버튼 클릭");
            }
        },
        /**
         * 광고 차단 안내 팝업을 감지하고 제거합니다.
         * @returns {Promise<void>}
         */
        async tryRemoveAdPopup() {
            try {
                const popups = document.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');

                        C.groupCollapsed("✅ 광고 차단 팝업 발견! (자세한 정보는 클릭)");
                        C.info("발견된 전체 팝업 구조", popup);

                        if (!btn) {
                            C.warn("팝업 내 버튼 요소를 찾지 못했습니다.");
                            C.groupEnd();
                            return;
                        }
                        C.info("내부에서 찾은 버튼 요소", btn);

                        const fiberKey = Object.keys(btn).find(k =>
                            k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$')
                        );

                        if (!fiberKey) {
                            C.warn("React Fiber 키를 찾지 못했습니다.");
                            C.groupEnd();
                            return;
                        }

                        C.info("사용한 React Fiber 키:", fiberKey.split('$')[1]);

                        const props = btn[fiberKey]?.memoizedProps || btn[fiberKey]?.return?.memoizedProps;
                        C.info("버튼의 React Props", props);

                        C.groupEnd();

                        let handlerName = null;
                        let handlerFunc = null;

                        if (typeof props.confirmHandler === 'function') {
                            handlerName = 'confirmHandler';
                            handlerFunc = props.confirmHandler;
                        } else if (typeof props.onClick === 'function') {
                            handlerName = 'onClick';
                            handlerFunc = props.onClick;
                        } else if (typeof props.onClickHandler === 'function') {
                            handlerName = 'onClickHandler';
                            handlerFunc = props.onClickHandler;
                        }

                        if (handlerFunc) {
                            handlerFunc({ isTrusted: true });
                            C.success(`[AdPopup] 성공: '${handlerName}' 핸들러를 사용하여 팝업을 닫았습니다.`);
                        }
                        return;
                    }
                }
            } catch (e) {
                C.error(`[AdPopup] 자동 닫기 실패: ${e.message}`);
            }
        },
    };
    /** @type {boolean} 저화질 복구 기능이 현재 동작 중인지 여부를 나타내는 플래그 */
    let isRecoveringQuality = false;
    /**
     * @async
     * @function checkAndFixLowQuality
     * @description 비디오 화질이 낮아졌을 경우 선호 화질로 복구를 시도합니다.
     * @param {HTMLVideoElement} video - 화질을 검사할 비디오 요소.
     * @returns {Promise<void>}
     */
    async function checkAndFixLowQuality(video) {
        if (!video || video.__qualityMonitorAttached) return;
        video.__qualityMonitorAttached = true;
        C.info("[QualityCheck] 화질 모니터링 시작");
        const performCheck = async () => {
            if (video.paused || isRecoveringQuality) return;
            const currentHeight = video.videoHeight;
            if (currentHeight === 0) return;
            const preferred = await quality.getPreferred();
            if (currentHeight < preferred) {
                C.warn(`[QualityCheck] 저화질(${currentHeight}p) 감지. 선호 화질(${preferred}p)로 복구 시도.`);
                isRecoveringQuality = true;
                await quality.applyPreferred();
                setTimeout(() => {
                    isRecoveringQuality = false;
                    C.info("[QualityCheck] 화질 복구 쿨다운 종료.");
                }, 120000);
            }
        };
        video.addEventListener('loadedmetadata', performCheck);
        setInterval(performCheck, 30000);
    }
    /**
     * @async
     * @function setDebugLogging
     * @description 저장된 설정에 따라 디버그 로그 출력 여부를 설정합니다.
     * @returns {Promise<void>}
     */
    async function setDebugLogging() {
        C.debug = await GM.getValue(C.storageKeys.debugLog, false);
    }
    /**
     * @async
     * @function injectSharpnessScript
     * @description '선명한 화면' 기능이 활성화된 경우, 관련 외부 스크립트를 주입합니다.
     * @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.gf.qytechs.cn/scripts/534918/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);
        C.success("%c[Sharpness] 외부 스크립트 삽입 완료", C.styles.info);
    }
    /**
     * @async
     * @function init
     * @description 스크립트의 주요 기능들을 초기화합니다.
     * @returns {Promise<void>}
     */
    async function init() {
        await setDebugLogging();
        updateChecker.check();

        if ((await GM.getValue(C.storageKeys.quality)) === undefined) {
            await GM.setValue(C.storageKeys.quality, 1080);
            C.success("[Init] 기본 화질 1080 저장");
        }
        if ((await GM.getValue(C.storageKeys.autoUnmute)) === undefined) {
            await GM.setValue(C.storageKeys.autoUnmute, true);
            C.success("[Init] 기본 언뮤트 ON 저장");
        }
        await addHeaderMenu();
        C.observeElement(C.selectors.headerMenu, () => {
            addHeaderMenu().catch(console.error);
        }, false);

        await quality.applyPreferred();
        await injectSharpnessScript();
    }
    /**
     * @function onDomReady
     * @description DOM 콘텐츠가 로드된 후 스크립트의 실행을 시작하는 진입점 함수.
     */
    function onDomReady() {
        console.log("%c🔔 [ChzzkHelper] 스크립트 시작", C.styles.info);
        quality.observeManualSelect();
        observer.start();
        init().catch(console.error);
    }

    handler.interceptXHR();
    handler.trackURLChange();

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", onDomReady);
    } else {
        onDomReady();
    }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址