Greasy Fork

来自缓存

Greasy Fork is available in English.

Chzzk 어시스턴트

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