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