// ==UserScript== // @name Chzzk 선명한 화면 업그레이드 // @description 선명도 필터 제공 // @namespace http://tampermonkey.net/ // @icon https://chzzk.naver.com/favicon.ico // @version 2.6.5 // @match https://chzzk.naver.com/* // @grant GM.getValue // @grant GM.setValue // @run-at document-idle // @license MIT // @downloadURL none // ==/UserScript== (async () => { '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'; const hasGM = (typeof GM !== 'undefined' && GM.getValue); const getValue = hasGM ? GM.getValue.bind(GM) : async (k, d) => JSON.parse(localStorage.getItem(k) ?? JSON.stringify(d)); const setValue = hasGM ? GM.setValue.bind(GM) : async (k, v) => localStorage.setItem(k, JSON.stringify(v)); 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 = ` `; 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 getValue(STORAGE_KEY_INTENSITY, 1); this.#enabled = await getValue(STORAGE_KEY_ENABLED, false); this.#updateFilter(); if (this.#enabled) this.enable(false); const menu = document.querySelector(MENU_SELECTOR); if (menu) { delete menu.dataset.sharpEnhanceDone; this.addMenuControls(menu); } this.dispatchEvent(new Event('initialized')); } enable(persist = true) { if (this.#enabled) return; this.#enabled = true; this.#style.media = 'all'; if (persist) 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) 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(); 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 = `
선명한 화면
(${this.intensity.toFixed(1)}x 배)
예시 이미지
`; 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(); }, { capture: true }); 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 => { for (const m of muts) { 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); } } }); mo.observe(root, { childList: true, subtree: true }); } } const sharpness = new SharpnessFilter(); await sharpness.init(); sharpness.observeMenus(); (function trackURLChange() { let last = location.href; const handler = async () => { if (location.href === last) return; last = location.href; await 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); })(); })();