Chzzk Auto Quality & 광고 팝업 제거 + 음소거 설정

Chzzk 자동 선호 화질 설정, 광고 팝업 제거, 음소거 자동 설정/해제 및 스크롤 잠금 해제

// ==UserScript==
// @name         Chzzk Auto Quality & 광고 팝업 제거 + 음소거 설정
// @namespace    http://tampermonkey.net/
// @version      3.5
// @description  Chzzk 자동 선호 화질 설정, 광고 팝업 제거, 음소거 자동 설정/해제 및 스크롤 잠금 해제
// @match        https://chzzk.naver.com/*
// @icon         https://chzzk.naver.com/favicon.ico
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.registerMenuCommand
// @grant        unsafeWindow
// @run-at       document-start
// @license      MIT
// ==/UserScript==
(async () => {
	'use strict';

	let isApplying = false;
	let lastApplyTime = 0;
	const APPLY_COOLDOWN = 1000;

	const CONFIG = {
		minTimeout: 500,
		defaultTimeout: 2000,
		storageKeys: {
			quality: 'chzzkPreferredQuality',
			autoUnmute: 'chzzkAutoUnmute',
			debugLog: 'chzzkDebugLog'
		},
		selectors: {
			popup: 'div[class^="popup_container"]',
			qualityBtn: 'button[class*="pzp-pc-setting-button"]',
			qualityMenu: 'div[class*="pzp-pc-setting-intro-quality"]',
			qualityItems: 'li[class*="quality-item"], li[class*="quality"]'
		},
		styles: {
			success: 'font-weight:bold; color:green',
			error: 'font-weight:bold; color:red',
			info: 'font-weight:bold; color:skyblue',
			warn: 'font-weight:bold; color:orange'
		}
	};

	const common = {
		regex: {
			adBlockDetect: /광고\s*차단\s*프로그램.*사용\s*중/i
		},
		async: {
			sleep: ms => new Promise(r => setTimeout(r, ms)),
			waitFor: (selector, timeout = CONFIG.defaultTimeout) => {
				const effective = Math.max(timeout, CONFIG.minTimeout);
				return new Promise((resolve, reject) => {
					const el = document.querySelector(selector);
					if (el) return resolve(el);
					const mo = new MutationObserver(() => {
						const found = document.querySelector(selector);
						if (found) {
							mo.disconnect();
							resolve(found);
						}
					});
					mo.observe(document.body, {
						childList: true,
						subtree: true
					});
					setTimeout(() => {
						mo.disconnect();
						reject(new Error('Timeout'));
					}, effective);
				});
			}
		},
		text: {
			clean: txt => txt.trim().split(/\s+/).filter(Boolean).join(', '),
			extractResolution: txt => {
				const match = txt.match(/(\d{3,4})p/);
				return match ? parseInt(match[1], 10) : null;
			}
		},
		dom: {
			remove: el => el?.remove(),
			clearStyle: el => el?.removeAttribute('style')
		},
		log: {
			DEBUG: true,
			info: msg => common.log.DEBUG && console.log(`%c${msg}`, CONFIG.styles.info),
			success: msg => common.log.DEBUG && console.log(`%c${msg}`, CONFIG.styles.success),
			warn: msg => console.warn(`%c${msg}`, CONFIG.styles.warn),
			error: msg => console.error(`%c${msg}`, CONFIG.styles.error)
		},
		observeElement: (selector, callback, once = true) => {
			const checkAndRun = () => {
				const el = document.querySelector(selector);
				if (el) {
					callback(el);
					if (once) observer.disconnects[selector]?.();
				}
			};
			const mo = new MutationObserver(checkAndRun);
			mo.observe(document.body, {
				childList: true,
				subtree: true
			});
			observer.disconnects[selector] = () => mo.disconnect();
			checkAndRun();
		}
	};
	const setDebugLogging = async () => {
		const current = await GM.getValue(CONFIG.storageKeys.debugLog, false);
		common.log.DEBUG = current;
		GM.registerMenuCommand('디버그 로그 토글', async () => {
			const newState = !await GM.getValue(CONFIG.storageKeys.debugLog, false);
			await GM.setValue(CONFIG.storageKeys.debugLog, newState);
			alert(`디버그 로그: ${newState ? 'ON' : 'OFF'}\n\n페이지를 새로 고침하여 적용됩니다.`);
			location.reload();
		});
	};

	await setDebugLogging();

	const quality = {
		observeManualSelect() {
			document.body.addEventListener('click', async e => {
				const li = e.target.closest('li[class*="quality"]');
				if (!li) return;
				const raw = li.textContent;
				const res = common.text.extractResolution(raw);
				if (res) {
					await GM.setValue(CONFIG.storageKeys.quality, res);
					console.groupCollapsed('%c💾 [Quality] 수동 화질 저장됨', CONFIG.styles.success);
					console.table([{
						'선택 해상도': res,
						'원본': common.text.clean(raw)
					}]);
					console.groupEnd();
				}
			}, {
				capture: true
			});
		},
		async getPreferred() {
			const stored = await GM.getValue(CONFIG.storageKeys.quality, 1080);
			return parseInt(stored, 10);
		},
		async applyPreferred() {
			const now = Date.now();
			if (isApplying || now - lastApplyTime < APPLY_COOLDOWN) return;
			isApplying = true;
			lastApplyTime = now;

			const target = await this.getPreferred();
			let cleaned = '(선택 실패)',
				pick = null;

			try {
				const btn = await common.async.waitFor(CONFIG.selectors.qualityBtn);
				btn.click();
				const menu = await common.async.waitFor(CONFIG.selectors.qualityMenu);
				menu.click();
				await common.async.sleep(CONFIG.minTimeout);

				const items = Array.from(document.querySelectorAll(CONFIG.selectors.qualityItems));
				pick = items.find(i => common.text.extractResolution(i.textContent) === target) ||
					items.find(i => /\d+p/.test(i.textContent)) || items[0];

				cleaned = pick ? common.text.clean(pick.textContent) : cleaned;

				if (pick) {
					pick.dispatchEvent(new KeyboardEvent('keydown', {
						key: 'Enter'
					}));
				} else {
					common.log.warn('[Quality] 화질 항목을 찾지 못함');
				}
			} catch (e) {
				common.log.error(`[Quality] 선택 실패: ${e.message}`);
			}

			console.groupCollapsed('%c⚙️ [Quality] 자동 화질 적용', CONFIG.styles.info);
			console.table([{
				'대상 해상도': target
			}]);
			console.table([{
				'선택 화질': cleaned,
				'선택 방식': pick ? '자동 (Enter 이벤트)' : '없음'
			}]);
			console.groupEnd();

			isApplying = false;
		}
	};

	const handler = {
		interceptXHR() {
			const oOpen = XMLHttpRequest.prototype.open;
			const oSend = XMLHttpRequest.prototype.send;
			XMLHttpRequest.prototype.open = function(m, u, ...a) {
				this._url = u;
				return oOpen.call(this, m, u, ...a);
			};
			XMLHttpRequest.prototype.send = function(body) {
				if (this._url?.includes('live-detail')) {
					this.addEventListener('readystatechange', () => {
						if (this.readyState === 4 && this.status === 200) {
							try {
								const data = JSON.parse(this.responseText);
								if (data.content?.p2pQuality) {
									data.content.p2pQuality = [];
									const mod = JSON.stringify(data);
									Object.defineProperty(this, 'responseText', {
										value: mod
									});
									Object.defineProperty(this, 'response', {
										value: mod
									});
									setTimeout(() => quality.applyPreferred(), CONFIG.minTimeout);
								}
							} catch (e) {
								common.log.error(`[XHR] JSON 파싱 오류: ${e.message}`);
							}
						}
					});
				}
				return oSend.call(this, body);
			};
			common.log.info('[XHR] live-detail 요청 감시 시작');
		},
		trackURLChange() {
			let lastUrl = location.href,
				lastId = null;
			const getId = url => (url.match(/live\/([\w-]+)/) ?? [])[1] || null;
			const onChange = () => {
				if (location.href === lastUrl) return;
				common.log.info(`[URLChange] ${lastUrl} → ${location.href}`);
				lastUrl = location.href;
				const id = getId(location.href);
				if (!id) return common.log.info('[URLChange] 방송 ID 없음, 설정 생략');
				if (id !== lastId) {
					lastId = id;
					setTimeout(() => quality.applyPreferred(), CONFIG.minTimeout);
				} else {
					common.log.warn(`[URLChange] 같은 방송(${id}), 재설정 생략`);
				}
			};
			['pushState', 'replaceState'].forEach(method => {
				const orig = history[method];
				history[method] = function() {
					const result = orig.apply(this, arguments);
					onChange();
					return result;
				};
			});
			window.addEventListener('popstate', onChange);
		}
	};

	const observer = {
		disconnects: {},
		start() {
			const mo = new MutationObserver(mutations => {
				for (const mutation of mutations) {
					for (const node of mutation.addedNodes) {
						if (node.nodeType !== 1) continue;

						if (node.matches?.(CONFIG.selectors.popup) && common.regex.adBlockDetect.test(node.textContent)) {
							common.dom.remove(node);
							common.dom.clearStyle(document.body);
							common.log.success('[AdPopup] 팝업 제거됨');
						}

						let vid;
						if (node.tagName === 'VIDEO') {
							vid = node;
						} else if (node.querySelector?.('video')) {
							vid = node.querySelector('video');
						}
						if (vid) {
							observer.unmuteAll(vid);
							checkAndFixLowQuality(vid);
						}
					}
				}
				if (document.body.style.overflow === 'hidden') {
					common.dom.clearStyle(document.body);
					common.log.info('[BodyStyle] overflow:hidden 제거됨');
				}
			});

			mo.observe(document.body, {
				childList: true,
				subtree: true,
				attributes: true,
				attributeFilter: ['style']
			});

			common.log.info('[Observer] 통합 감시 시작');
		},
		async unmuteAll(video) {
			const autoUnmute = await GM.getValue(CONFIG.storageKeys.autoUnmute, true);
			if (!autoUnmute) {
				common.log.info('[Unmute] 설정에 따라 자동 해제 스킵');
				return;
			}
			if (video.muted) {
				video.muted = false;
				common.log.success('[Unmute] 새 비디오 muted 속성 해제됨');
			}
			const btn = document.querySelector('button.pzp-pc-volume-button[aria-label*="음소거 해제"]');
			if (btn) {
				btn.click();
				common.log.success('[Unmute] "음소거 해제" 버튼 클릭');
			}
		}
	};

	async function checkAndFixLowQuality(video) {
		if (!video || video.__checkedAlready) return;
		video.__checkedAlready = true;

		await common.async.sleep(CONFIG.defaultTimeout);

		let height = video.videoHeight || 0;
		if (height === 0) {
			await common.async.sleep(1000);
			height = video.videoHeight || 0;
		}
		if (height === 0) {
			return;
		}

		if (height <= 360) {
			const preferred = await quality.getPreferred();
			if (preferred !== height) {
				common.log.warn(`[QualityCheck] 저화질(${height}p) 감지, ${preferred}p로 복구`);
				await quality.applyPreferred();
			} else {
				common.log.warn('[QualityCheck] 현재 해상도가 사용자 선호값과 동일하여 복구 생략');
			}
		}
	}
	async function init() {
		if (document.body.style.overflow === 'hidden') {
			common.dom.clearStyle(document.body);
			common.log.success('[Init] 초기 overflow 제거');
		}
		if ((await GM.getValue(CONFIG.storageKeys.quality)) === undefined) {
			await GM.setValue(CONFIG.storageKeys.quality, 1080);
			common.log.success('[Init] 기본 화질 1080 저장됨');
		}
		if ((await GM.getValue(CONFIG.storageKeys.autoUnmute)) === undefined) {
			await GM.setValue(CONFIG.storageKeys.autoUnmute, true);
			common.log.success('[Init] 기본 음소거 해제 ON 저장됨');
		}

		GM.registerMenuCommand('음소거 자동 해제 토글', async () => {
			const current = await GM.getValue(CONFIG.storageKeys.autoUnmute, true);
			await GM.setValue(CONFIG.storageKeys.autoUnmute, !current);
			alert(`음소거 자동 해제: ${!current ? 'ON' : 'OFF'}\n\n페이지를 새로 고침하여 변경사항을 적용합니다.`);
			location.reload();
		});

		await quality.applyPreferred();
	}

	function onDomReady() {
		console.log('%c🔔 [ChzzkHelper] 스크립트 시작', CONFIG.styles.info);
		quality.observeManualSelect();
		observer.start();
		init();
	}

	handler.interceptXHR();
	handler.trackURLChange();

	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', onDomReady);
	} else {
		onDomReady();
	}
})();