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

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

当前为 2025-07-26 提交的版本,查看 最新版本

// ==UserScript==
// @name Chzzk 올인원 스크립트 (Auto Quality + Ad Popup Removal + Unmute)
// @namespace http://tampermonkey.net/
// @version 4.1
// @description Chzzk 방송에서 자동 화질 설정, 광고 팝업 차단, 음소거 자동 해제, 스크롤 잠금 해제, 360p 복구
// @match https://chzzk.naver.com/*
// @icon  https://chzzk.naver.com/favicon.ico
// @grant GM.getValue
// @grant GM.setValue
// @grant unsafeWindow
// @run-at document-start
// @license MIT
// ==/UserScript==
(async () => {
    "use strict";
    class Config {
        #applyCooldown = 1000;
        #minTimeout = 500;
        #defaultTimeout = 2000;
        #storageKeys = {
            quality: "chzzkPreferredQuality",
            autoUnmute: "chzzkAutoUnmute",
            debugLog: "chzzkDebugLog",
            screenSharpness: "chzzkScreenSharp",
        };
        #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,
        };
        #debug = true;

        get applyCooldown() { return this.#applyCooldown; }
        get minTimeout() { return this.#minTimeout; }
        get defaultTimeout() { return this.#defaultTimeout; }
        get storageKeys() { return this.#storageKeys; }
        get selectors() { return this.#selectors; }
        get styles() { return this.#styles; }
        get regex() { return this.#regex; }

        get debug() { return this.#debug; }
        set debug(value) { this.#debug = !!value; }

        sleep = (ms) => new Promise((r) => setTimeout(r, ms));

        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);
            });
        };

        cleanText = (txt) => txt.trim().split(/\s+/).filter(Boolean).join(", ");

        extractResolution = (txt) => {
            const m = txt.match(/(\d{3,4})p/);
            return m ? parseInt(m[1], 10) : null;
        };

        removeElement = (el) => el?.remove();
        clearStyle = (el) => el?.removeAttribute("style");

        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);

        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();
            }
        };
    }

    const C = new Config();

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

    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 });
        },

        async getPreferred() {
            const stored = await GM.getValue(C.storageKeys.quality, 1080);
            return parseInt(stored, 10);
        },

        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;
        },
    };

    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 CHZZK_ID_REGEX = /(?:live|video)\/(?<id>[^/]+)/;
            const getId = (url) => (typeof url === 'string' ? (url.match(CHZZK_ID_REGEX)?.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);
        },
    };

    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}`);
                                }
                            })();
                        }
                    }
                }
                if (document.body.style.overflow === "hidden") {
                    const adPopup = Array.from(document.querySelectorAll(C.selectors.popup)).find(p => C.regex.adBlockDetect.test(p.textContent));
                    if (adPopup) {
                        C.clearStyle(document.body);
                        C.info("[BodyStyle] 광고 팝업으로 인한 overflow:hidden 제거됨");
                    }
                }
            });
            mo.observe(document.body, {
                childList: true,
                subtree: true,
                attributes: true,
                attributeFilter: ["style"],
            });
            C.info("[Observer] 통합 감시 시작");
        },

        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] 버튼 클릭");
            }
        },

        async tryRemoveAdPopup() {
            try {
                const popups = document.querySelectorAll(C.selectors.popup);
                for (const popup of popups) {
                    if (C.regex.adBlockDetect.test(popup.textContent)) {
                        const btn = popup.querySelector('button');
                        if (btn) {
                            const fk = Object.keys(btn).find(k =>
                                k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$')
                            );
                            if (fk) {
                                const props = btn[fk].memoizedProps || btn[fk].return.memoizedProps;
                                if (typeof props.exposureAdBlockPopup === 'function') {
                                    props.exposureAdBlockPopup = () => { };
                                }
                                (props.confirmHandler || props.onClick || props.onClickHandler)?.({ isTrusted: true });
                                C.success("[AdPopup] React 광고 팝업 직접 닫기 실행");
                                return;
                            }
                        }
                    }
                }
            } catch (e) {
                C.error(`[AdPopup] 자동 닫기 실패: ${e.message}`);
            }
        },
    };

    let isRecoveringQuality = false;
    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() {
        C.debug = await GM.getValue(C.storageKeys.debugLog, false);
    }

    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() {
        await setDebugLogging();
        if (document.body.style.overflow === "hidden") {
            C.clearStyle(document.body);
            C.success("[Init] overflow 잠금 해제");
        }
        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() {
        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或关注我们的公众号极客氢云获取最新地址