Chzzk 선명한 화면 업그레이드

선명도 필터 제공

// ==UserScript==
// @name         Chzzk 선명한 화면 업그레이드
// @description  선명도 필터 제공
// @namespace    http://tampermonkey.net/
// @icon         https://chzzk.naver.com/favicon.ico
// @version      2.8
// @match        https://chzzk.naver.com/*
// @grant        GM.getValue
// @grant        GM.setValue
// @run-at       document-idle
// @license      MIT
// ==/UserScript==
; (async () => {
    'use strict';

    const STORAGE_KEY_ENABLED = 'chzzkSharpnessEnabled';
    const STORAGE_KEY_INTENSITY = 'chzzkSharpnessIntensity';
    const STORAGE_KEY_MODE = 'chzzkSharpnessMode';
    const FILTER_ID_DEFAULT = 'sharp_default';
    const FILTER_ID_NATURAL = 'sharp_natural';
    const SVG_ID = 'sharpnessSVGContainer';
    const STYLE_ID = 'sharpnessStyle';
    const MENU_SELECTOR = '.pzp-pc__settings';
    const FILTER_ITEM_SELECTOR = '.pzp-pc-setting-intro-filter';

    const hasGM = typeof GM !== 'undefined' && typeof GM.getValue === 'function';
    const getValue = hasGM
        ? GM.getValue.bind(GM)
        : async (k, d) => {
            const v = localStorage.getItem(k);
            return v == null ? d : JSON.parse(v);
        };
    const setValue = hasGM
        ? GM.setValue.bind(GM)
        : async (k, v) => localStorage.setItem(k, JSON.stringify(v));

    function isLivePage() {
        return /^\/live\/[^/]+/.test(location.pathname);
    }

    function clearSharpness() {
        document.getElementById(SVG_ID)?.remove();
        document.getElementById(STYLE_ID)?.remove();
    }

    class SharpnessFilter extends EventTarget {
        #enabled = false;
        #intensity = 1;
        #mode = 'default'; // 'default' or 'natural'
        #svg;
        #style;
        controls = null;

        constructor() {
            super();
            this.#svg = this.#createSVG();
            this.#style = this.#createStyle();
            this.#style.media = 'none';
        }

        get enabled() { return this.#enabled; }
        get intensity() { return this.#intensity; }
        get mode() { return this.#mode; }

        #createSVG() {
            const div = document.createElement('div');
            div.id = SVG_ID;
            div.innerHTML = `
                <svg xmlns="http://www.w3.org/2000/svg" style="position:absolute;width:0;height:0;">
                  <defs>
                    <filter id="${FILTER_ID_DEFAULT}">
                      <feConvolveMatrix order="3" divisor="1" kernelMatrix="" />
                    </filter>
                    <filter id="${FILTER_ID_NATURAL}">
                      <feConvolveMatrix order="3" divisor="1" kernelMatrix="" />
                      <feColorMatrix type="saturate" values="1.2" />
                    </filter>
                  </defs>
                </svg>`;
            return div;
        }

        #createStyle() {
            const style = document.createElement('style');
            style.id = STYLE_ID;
            style.textContent = `
                /* 비디오 필터 적용 (기본) */
                .pzp-pc .webplayer-internal-video {
                    filter: url(#${FILTER_ID_DEFAULT}) !important;
                }

                /* 슬라이더 accent-color */
                .sharp-slider {
                    accent-color: var(--sharp-accent, #00f889);
                }

                /* 드롭다운 (#sharp-filter-select) 기본 스타일 */
                #sharp-filter-select {
                    border: 1px solid #00f889;
                    border-radius: 4px;
                    padding: 4px 8px;
                    font-size: 13px;
                }
                /* 옵션 리스트 열렸을 때 옵션들 스타일 */
                #sharp-filter-select:focus {
                outline: 2px solid #00f889;
                }
            `;
            return style;
        }

        #updateFilterMatrix() {
            const k = this.#intensity;
            const off = -((k - 1) / 4);
            const matrix = `0 ${off} 0 ${off} ${k} ${off} 0 ${off} 0`;
            const matElems = this.#svg.querySelectorAll('feConvolveMatrix');
            matElems.forEach(elem => {
                elem.setAttribute('kernelMatrix', matrix);
            });
        }

        #applyModeToStyle() {
            const filterId = this.#mode === 'natural' ? FILTER_ID_NATURAL : FILTER_ID_DEFAULT;
            this.#style.textContent = `
                /* 비디오 필터 적용 (선택된 모드) */
                .pzp-pc .webplayer-internal-video {
                    filter: url(#${filterId}) !important;
                }
                /* 슬라이더 accent-color 유지 */
                .sharp-slider {
                    accent-color: var(--sharp-accent, #00f889);
                }

                /* 드롭다운 (#sharp-filter-select) 스타일 (항상 동일하게 유지) */
                #sharp-filter-select {
                    border: 1px solid #00f889;
                    border-radius: 4px;
                    padding: 4px 8px;
                    font-size: 13px;
                }
                #sharp-filter-select:focus {
                outline: 2px solid #00f889;
                }
            `;
            // 필터가 켜진 상태일 때만 위 CSS가 동작하도록
            this.#style.media = this.#enabled ? 'all' : 'none';
        }

        async init() {
            if (isLivePage()) {
                clearSharpness();
                document.body.append(this.#svg);
                document.head.append(this.#style);
            }

            this.#intensity = await getValue(STORAGE_KEY_INTENSITY, 1);
            this.#enabled = await getValue(STORAGE_KEY_ENABLED, false);
            this.#mode = await getValue(STORAGE_KEY_MODE, 'default');

            this.#updateFilterMatrix();
            this.#applyModeToStyle();

            const menu = document.querySelector(MENU_SELECTOR);
            if (menu) {
                delete menu.dataset.sharpEnhanceDone;
                this.addMenuControls(menu);
            }
            if (this.controls) this.refreshControls();
            this.dispatchEvent(new Event('initialized'));
        }

        enable(persist = true) {
            this.#enabled = true;
            if (persist) setValue(STORAGE_KEY_ENABLED, true);
            this.#applyModeToStyle();
            this.refreshControls();
        }

        disable(persist = true) {
            this.#enabled = false;
            if (persist) setValue(STORAGE_KEY_ENABLED, false);
            this.#applyModeToStyle();
            this.refreshControls();
        }

        toggle() {
            this.enabled ? this.disable() : this.enable();
        }

        setIntensity(v) {
            this.#intensity = v;
            this.#updateFilterMatrix();
            setValue(STORAGE_KEY_INTENSITY, v);
            this.refreshControls();
        }

        setMode(m) {
            if (m !== 'default' && m !== 'natural') return;
            this.#mode = m;
            this.#applyModeToStyle();
            setValue(STORAGE_KEY_MODE, m);
        }

        registerControls({ wrapper, checkbox, slider, label, select }) {
            this.controls = { wrapper, checkbox, slider, label, select };
            ['enabled', 'disabled', 'intensitychange', 'modechange'].forEach(evt =>
                this.addEventListener(evt, () => this.refreshControls())
            );
            this.refreshControls();
        }

        refreshControls() {
            if (!this.controls) return;
            const { wrapper, checkbox, slider, label, select } = this.controls;
            checkbox.checked = this.enabled;
            wrapper.setAttribute('aria-checked', String(this.enabled));

            slider.style.accentColor = this.enabled ? '#00f889' : 'gray';
            slider.value = this.intensity;
            slider.setAttribute('aria-valuenow', this.intensity.toFixed(1));
            slider.setAttribute('aria-valuetext', `강도 ${this.intensity.toFixed(1)} 배`);
            label.textContent = `(${this.intensity.toFixed(1)}x 배)`;

            select.value = this.mode;
        }

        drawTestPattern() {
            const c = document.getElementById('sharp-test-canvas');
            if (!c) return;
            const ctx = c.getContext('2d');
            const { width: w, height: h } = c;
            ctx.clearRect(0, 0, w, h);
            ctx.strokeStyle = '#888';
            ctx.lineWidth = 1;
            for (let x = 0; x <= w; x += 10) {
                ctx.beginPath();
                ctx.moveTo(x, 0);
                ctx.lineTo(x, h);
                ctx.stroke();
            }
            for (let y = 0; y <= h; y += 10) {
                ctx.beginPath();
                ctx.moveTo(0, y);
                ctx.lineTo(w, y);
                ctx.stroke();
            }
            ctx.strokeStyle = '#444';
            ctx.beginPath();
            ctx.moveTo(0, 0);
            ctx.lineTo(w, h);
            ctx.stroke();
            ctx.beginPath();
            ctx.moveTo(w, 0);
            ctx.lineTo(0, h);
            ctx.stroke();
        }

        addMenuControls(menu) {
            if (menu.dataset.sharpEnhanceDone) return;
            menu.dataset.sharpEnhanceDone = 'true';

            let container = menu.querySelector(FILTER_ITEM_SELECTOR);
            if (!container) {
                container = document.createElement('div');
                container.className = 'pzp-ui-setting-home-item';
                container.setAttribute('role', 'menuitem');
                container.tabIndex = 0;
                menu.append(container);
            }

            container.innerHTML = `
                <div class="pzp-ui-setting-home-item__top">
                  <div class="pzp-ui-setting-home-item__left">
                    <span class="pzp-ui-setting-home-item__label">선명한 화면</span>
                  </div>
                  <div class="pzp-ui-setting-home-item__right">
                    <div role="switch" class="pzp-ui-toggle sharp-toggle-wrapper"
                         aria-label="샤프닝 필터 토글"
                         aria-checked="${this.enabled}" tabindex="0">
                      <input type="checkbox" class="pzp-ui-toggle__checkbox sharp-toggle" tabindex="-1">
                      <div class="pzp-ui-toggle__handle"></div>
                    </div>
                  </div>
                </div>
                <div class="pzp-ui-setting-home-item__bottom" style="padding:8px;display:flex;flex-direction:column;gap:8px;">
                  <!-- 드롭다운 메뉴 -->
                  <div style="display:flex;align-items:center;gap:8px;">
                    <label for="sharp-filter-select" class="visually-hidden">필터 선택</label>
                    <select id="sharp-filter-select">
                      <option value="default">현재 필터 (기본 값)</option>
                      <option value="natural">색상 보정 필터</option>
                    </select>
                  </div>
                  <!-- 강도 조절 슬라이더 -->
                  <div style="display:flex;align-items:center;gap:8px;">
                    <label for="sharp-slider" class="visually-hidden">강도 조절</label>
                    <input id="sharp-slider" type="range" min="1" max="3" step="0.1" class="sharp-slider"
                           aria-valuemin="1" aria-valuemax="3">
                    <span id="sharp-intensity-label"></span>
                  </div>
                  <!-- 테스트 캔버스 및 예시 이미지 -->
                  <div style="display:flex;gap:8px;">
                    <canvas id="sharp-test-canvas" width="100" height="100"
                      style="border:1px solid #ccc;filter:url(#${this.mode === 'natural' ? FILTER_ID_NATURAL : FILTER_ID_DEFAULT});"></canvas>
                    <img id="sharp-example-image"
                         src="https://images.unsplash.com/photo-1596854372745-0906a0593bca?q=80&w=2080"
                         alt="예시 이미지" width="100" height="100"
                         style="border:1px solid #ccc;filter:url(#${this.mode === 'natural' ? FILTER_ID_NATURAL : FILTER_ID_DEFAULT});display:block;vertical-align:top;">
                  </div>
                </div>`;

            const wrapper = container.querySelector('.sharp-toggle-wrapper');
            const checkbox = container.querySelector('.sharp-toggle');
            const slider = container.querySelector('#sharp-slider');
            const label = container.querySelector('#sharp-intensity-label');
            const select = container.querySelector('#sharp-filter-select');

            // 초기 상태 반영
            checkbox.checked = this.enabled;
            wrapper.setAttribute('aria-checked', String(this.enabled));
            slider.value = this.intensity;
            label.textContent = `(${this.intensity.toFixed(1)}x 배)`;
            select.value = this.mode;

            // 컨테이너 클릭 시: 토글만 예외, 나머지 클릭은 전파 차단
            container.addEventListener('click', e => {
                if (
                    e.target.closest('.sharp-toggle-wrapper') ||
                    e.target.closest('.pzp-ui-toggle__handle')
                ) {
                    return;
                }
                if (
                    e.target.closest('#sharp-slider') ||
                    e.target.closest('#sharp-filter-select') ||
                    e.target.closest('option')
                ) {
                    e.stopPropagation();
                    return;
                }
                e.stopPropagation();
            }, { capture: true });

            // 토글 클릭 & 키보드
            wrapper.addEventListener('click', e => {
                e.stopPropagation();
                this.toggle();
            }, { capture: true });
            wrapper.addEventListener('keydown', e => {
                if (['Enter', ' '].includes(e.key)) {
                    e.preventDefault();
                    this.toggle();
                }
            });

            // 슬라이더 이벤트(전파 차단 / 입력 처리)
            ['mousedown', 'pointerdown', 'touchstart'].forEach(evt => {
                slider.addEventListener(evt, e => e.stopPropagation(), { capture: true });
            });
            slider.addEventListener('input', e => {
                const v = parseFloat(e.target.value);
                this.setIntensity(v);
                this.drawTestPattern();
            });
            slider.addEventListener('keydown', e => {
                if (!this.enabled) return;
                let v = this.intensity;
                if (['ArrowRight', 'ArrowUp'].includes(e.key)) {
                    v = Math.min(v + 0.1, 3);
                } else if (['ArrowLeft', 'ArrowDown'].includes(e.key)) {
                    v = Math.max(v - 0.1, 1);
                } else {
                    return;
                }
                e.preventDefault();
                this.setIntensity(v);
                slider.value = v;
                this.drawTestPattern();
            });

            // 드롭다운 이벤트: mousedown/pointerdown/mouseup 단계에서 전파만 차단
            ['mousedown', 'pointerdown', 'mouseup'].forEach(evt => {
                select.addEventListener(evt, e => e.stopPropagation(), { capture: true });
            });
            select.addEventListener('change', e => {
                const newMode = e.target.value;
                this.setMode(newMode);
                this.drawTestPattern();
                const canvas = document.getElementById('sharp-test-canvas');
                const img = document.getElementById('sharp-example-image');
                const flip = this.mode === 'natural' ? FILTER_ID_NATURAL : FILTER_ID_DEFAULT;
                if (canvas) canvas.style.filter = `url(#${flip})`;
                if (img) img.style.filter = `url(#${flip})`;
            });

            this.registerControls({ wrapper, checkbox, slider, label, select });
            this.drawTestPattern();
        }

        observeMenus() {
            const root = document.querySelector('.pzp-pc') || document.body;
            const initMenu = document.querySelector(MENU_SELECTOR);
            if (initMenu) this.addMenuControls(initMenu);
            new MutationObserver(ms => {
                for (const m of ms) {
                    for (const n of m.addedNodes) {
                        if (!(n instanceof HTMLElement)) continue;
                        const menu = n.matches(MENU_SELECTOR) ? n : n.querySelector(MENU_SELECTOR);
                        if (menu) this.addMenuControls(menu);
                    }
                }
            }).observe(root, { childList: true, subtree: true });
        }
    }

    const sharpness = new SharpnessFilter();
    await sharpness.init();
    sharpness.observeMenus();

    // SPA 네비게이션 감지 (pushState/replaceState/popstate)
    (() => {
        let last = location.href;
        const onChange = async () => {
            if (location.href === last) return;
            last = location.href;
            if (isLivePage()) {
                await sharpness.init();
            }
        };
        ['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', onChange);
    })();
})();