Greasy Fork

Greasy Fork is available in English.

Chzzk 선명한 화면 업그레이드

선명도 필터 제공

目前为 2025-05-07 提交的版本。查看 最新版本

// ==UserScript==
// @name         Chzzk 선명한 화면 업그레이드
// @description  선명도 필터 제공
// @namespace    http://tampermonkey.net/
// @icon         https://chzzk.naver.com/favicon.ico
// @version      2.5.1
// @match        https://chzzk.naver.com/*
// @grant        none
// @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';

  class SharpnessFilter extends EventTarget {
    #enabled = false;
    #intensity = parseFloat(localStorage.getItem(STORAGE_KEY_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;">
          <filter id="${FILTER_ID}">
            <feConvolveMatrix order="3" divisor="1" kernelMatrix="" />
          </filter>
        </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);
    }

    init() {
      document.body.append(this.#svgContainer);
      document.head.append(this.#style);
      this.#updateFilter();
      if (localStorage.getItem(STORAGE_KEY_ENABLED) === 'true') this.enable(false);
      this.dispatchEvent(new Event('initialized'));
    }

    enable(persist = true) {
      if (this.#enabled) return;
      this.#enabled = true;
      this.#style.media = 'all';
      if (persist) localStorage.setItem(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) localStorage.setItem(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();
      localStorage.setItem(STORAGE_KEY_INTENSITY, String(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&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fA%3D%3D"
                 alt="예시 이미지"
                 width="100" height="100"
                 style="border:1px solid #ccc; filter:url(#sharpnessFilter); 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', this.enabled);
      wrapper.addEventListener('click', () => 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();
      });
      ['mousedown', 'click', 'keydown', 'input'].forEach(evt => {
        container.querySelectorAll('.sharp-slider, .sharp-toggle-wrapper')
          .forEach(el => el.addEventListener(evt, e => e.stopPropagation()));
      });
      container.addEventListener('click', e => {
        if (!e.target.closest('.sharp-toggle-wrapper, .sharp-slider')) {
          e.stopImmediatePropagation();
          e.preventDefault();
        }
      }, true);
      this.registerControls({ wrapper, checkbox, slider, label });
      this.drawTestPattern();
    }

    observeMenus() {
      const root = document.querySelector('.pzp-pc') || document.body;
      const initialMenu = document.querySelector(MENU_SELECTOR);
      if (initialMenu) this.addMenuControls(initialMenu);
      const observer = new MutationObserver(mutations => {
        for (const m of mutations) {
          for (const node of m.addedNodes) {
            if (!(node instanceof HTMLElement)) continue;
            const menu = node.matches(MENU_SELECTOR)
              ? node
              : node.querySelector(MENU_SELECTOR);
            if (menu) this.addMenuControls(menu);
          }
        }
      });
      observer.observe(root, { childList: true, subtree: true });
      window.addEventListener('beforeunload', () => {
        observer.disconnect();
        document.getElementById(SVG_ID)?.remove();
        document.getElementById(STYLE_ID)?.remove();
      });
      console.log('%c[🔧 샤프닝 필터 적용 완료]', 'color:skyblue;font-weight:bold;');
    }
  }

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