Greasy Fork

Greasy Fork is available in English.

Chzzk 선명한 화면 업그레이드

선명도 필터 제공

当前为 2025-05-22 提交的版本,查看 最新版本

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

(() => {
    'use strict';

    const STORAGE_KEY_ENABLED = 'chzzkSharpnessEnabled';
    const STORAGE_KEY_INTENSITY = 'chzzkSharpnessIntensity';
    const FILTER_ID = 'sharpnessFilter';
    const SVG_ID = 'sharpnessSVGContainer';
    const STYLE_ID = 'sharpnessStyle';
    const MENU_SELECTOR = '.pzp-pc__settings';
    const FILTER_ITEM_SELECTOR = '.pzp-pc-setting-intro-filter';

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

    class SharpnessFilter extends EventTarget {
        #enabled = false;
        #intensity = 1;
        #svgContainer;
        #style;
        controls = null;

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

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

        #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}">
              <feConvolveMatrix order="3" divisor="1" kernelMatrix="" />
            </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}) !important; }
        .sharp-slider { accent-color: var(--sharp-accent, #ccc); }
      `;
            return style;
        }

        #updateFilter() {
            const k = this.#intensity;
            const off = -((k - 1) / 4);
            const matrix = `0 ${off} 0 ${off} ${k} ${off} 0 ${off} 0`;
            this.#svgContainer
                .querySelector('feConvolveMatrix')
                .setAttribute('kernelMatrix', matrix);
        }

        async init() {
            clearSharpness();
            document.body.append(this.#svgContainer);
            document.head.append(this.#style);

            this.#intensity = await GM.getValue(STORAGE_KEY_INTENSITY, 1);
            this.#enabled = await GM.getValue(STORAGE_KEY_ENABLED, false);

            this.#updateFilter();

            if (this.#enabled) this.enable(false);
            this.dispatchEvent(new Event('initialized'));
        }

        enable(persist = true) {
            if (this.#enabled) return;
            this.#enabled = true;
            this.#style.media = 'all';
            if (persist) GM.setValue(STORAGE_KEY_ENABLED, true);
            this.dispatchEvent(new Event('enabled'));
        }

        disable(persist = true) {
            if (!this.#enabled) return;
            this.#enabled = false;
            this.#style.media = 'none';
            if (persist) GM.setValue(STORAGE_KEY_ENABLED, false);
            this.dispatchEvent(new Event('disabled'));
        }

        toggle() {
            this.enabled ? this.disable() : this.enable();
            this.dispatchEvent(new Event('toggle'));
        }

        setIntensity(value) {
            if (this.#intensity === value) return;
            this.#intensity = value;
            this.#updateFilter();
            GM.setValue(STORAGE_KEY_INTENSITY, value);
            this.dispatchEvent(new Event('intensitychange'));
        }

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

        refreshControls() {
            if (!this.controls) return;
            const { wrapper, checkbox, slider, label } = 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 배)`;
        }

        drawTestPattern() {
            const canvas = document.getElementById('sharp-test-canvas');
            if (!canvas) return;
            const ctx = canvas.getContext('2d');
            const { width: w, height: h } = canvas;
            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-slider" class="visually-hidden">강도 조절</label>
            <input id="sharp-slider" type="range" min="1" max="3" step="0.1" class="sharp-slider"
                   role="slider" aria-valuemin="1" aria-valuemax="3"
                   aria-valuenow="${this.intensity.toFixed(1)}"
                   aria-valuetext="강도 ${this.intensity.toFixed(1)} 배">
            <span id="sharp-intensity-label">(${this.intensity.toFixed(1)}x 배)</span>
          </div>
          <div style="display:flex; gap:8px;">
            <canvas id="sharp-test-canvas" width="100" height="100"
              style="border:1px solid #ccc; filter:url(#${FILTER_ID});"></canvas>
            <img id="sharp-example-image"
                 src="https://images.unsplash.com/photo-1596854372745-0906a0593bca?q=80&w=2080&auto=format&fit=crop&ixlib=rb-4.1.0"
                 alt="예시 이미지"
                 width="100" height="100"
                 style="border:1px solid #ccc; filter:url(#${FILTER_ID}); display:block; vertical-align:top;">
          </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');

            checkbox.checked = this.enabled;
            wrapper.setAttribute('aria-checked', String(this.enabled));
            slider.value = this.intensity;
            label.textContent = `(${this.intensity.toFixed(1)}x 배)`;

            wrapper.addEventListener('click', e => {
                e.stopPropagation();
                this.toggle();
            });
            wrapper.addEventListener('keydown', e => {
                if (['Enter', ' '].includes(e.key)) {
                    e.preventDefault();
                    this.toggle();
                }
            });

            slider.addEventListener('input', e => {
                const v = parseFloat(e.target.value);
                this.setIntensity(v);
                this.drawTestPattern();
            });
            slider.addEventListener('keydown', e => {
                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();
            });

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

        observeMenus() {
            const root = document.querySelector('.pzp-pc') || document.body;
            const initial = document.querySelector(MENU_SELECTOR);
            if (initial) this.addMenuControls(initial);

            const mo = new MutationObserver(muts => {
                muts.forEach(m => {
                    m.addedNodes.forEach(node => {
                        if (!(node instanceof HTMLElement)) return;
                        const menu = node.matches(MENU_SELECTOR)
                            ? node
                            : node.querySelector(MENU_SELECTOR);
                        if (menu) this.addMenuControls(menu);
                    });
                });
            });
            mo.observe(root, { childList: true, subtree: true });
        }
    }

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

    (function trackURLChange() {
        let last = location.href;
        function handler() {
            if (location.href === last) return;
            last = location.href;
            sharpness.init();
        }
        ['pushState', 'replaceState'].forEach(m => {
            const orig = history[m];
            history[m] = function (...args) {
                const res = orig.apply(this, args);
                window.dispatchEvent(new Event('locationchange'));
                return res;
            };
        });
        window.addEventListener('popstate', () => window.dispatchEvent(new Event('locationchange')));
        window.addEventListener('locationchange', handler);
    })();
})();