Greasy Fork is available in English.
닉네임 (형광펜 제거, 길이 제한, 투명 제거) / 미션&고정채팅 자동 펼치기 / 드롭스 토글 / 키보드 단축키 추가
// ==UserScript==
// @name Chzzk_Utils: Chatting Plus Simple Edition
// @name:ko Chzzk_유틸: 채팅플러스 SE
// @namespace Chzzk_Live&VOD: Chatting Plus
// @version 4.0
// @description 닉네임 (형광펜 제거, 길이 제한, 투명 제거) / 미션&고정채팅 자동 펼치기 / 드롭스 토글 / 키보드 단축키 추가
// @author DOGJIP
// @match https://chzzk.naver.com/*
// @grant GM_addStyle
// @run-at document-end
// @license MIT
// @icon https://www.google.com/s2/favicons?sz=64&domain=chzzk.naver.com
// ==/UserScript==
(function() {
'use strict';
// ========================================
// 1. 닉네임 수정 & 드롭스 토글 등 스타일
// ========================================
GM_addStyle(`
/* 닉네임 길이 제한 */
.live_chatting_username_nickname__dDbbj {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
/* 형광펜(배경색) 제거 */
.live_chatting_username_nickname__dDbbj [style*="background-color"] {
background-color: transparent !important;
}
/* 드롭스 토글 관련 스타일 */
#drops_info.drops-collapsed .live_information_drops_wrapper__gQBUq,
#drops_info.drops-collapsed .live_information_drops_text__xRtWS,
#drops_info.drops-collapsed .live_information_drops_default__jwWot,
#drops_info.drops-collapsed .live_information_drops_area__7VJJr {
display: none !important;
}
.live_information_drops_icon_drops__2YXie {
transition: transform .2s;
}
#drops_info.drops-collapsed .live_information_drops_icon_drops__2YXie {
transform: rotate(-90deg);
}
.live_information_drops_toggle_icon {
margin-left: 10px;
font-size: 18px;
cursor: pointer;
display: inline-block;
}
`);
// ========================================
// 2. 투명 닉네임 수정 기능 (채팅플러스와 동일하게 수정)
// ========================================
// ========================================
// 2. 투명 닉네임 수정 기능 (채팅플러스와 동일하게 수정)
// ========================================
class NicknameColorFixer {
constructor() {
this.colorCache = new Map();
this.observer = null;
this.pendingElements = new Set(); // 추가
this.rafId = null; // 추가
}
fixUnreadableColor(nicknameElem) {
if (!nicknameElem) return;
// inline style 먼저 체크 (getComputedStyle보다 빠름)
const inlineColor = nicknameElem.style.color;
if (inlineColor && this.colorCache.has(inlineColor)) {
if (this.colorCache.get(inlineColor) === false) {
nicknameElem.style.color = '';
}
return;
}
const cssColor = window.getComputedStyle(nicknameElem).color;
if (this.colorCache.has(cssColor)) {
if (this.colorCache.get(cssColor) === false) {
nicknameElem.style.color = '';
}
return;
}
const rgbaMatch = cssColor.match(/rgba?\((\d+), ?(\d+), ?(\d+)(?:, ?([0-9.]+))?\)/);
if (!rgbaMatch) return;
const r = parseInt(rgbaMatch[1], 10);
const g = parseInt(rgbaMatch[2], 10);
const b = parseInt(rgbaMatch[3], 10);
const a = rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1;
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
const visibility = brightness * a;
// 가시성이 50 미만이면 색상 제거
if (visibility < 50) {
nicknameElem.style.color = '';
}
this.colorCache.set(cssColor, visibility >= 50);
}
// RAF를 사용한 배치 처리 추가
scheduleColorFix(nicknameElem) {
this.pendingElements.add(nicknameElem);
if (!this.rafId) {
this.rafId = requestAnimationFrame(() => {
this.pendingElements.forEach(elem => {
if (document.contains(elem)) {
this.fixUnreadableColor(elem);
}
});
this.pendingElements.clear();
this.rafId = null;
});
}
}
processMessage(messageElem) {
if (!messageElem) return;
if (messageElem.getAttribute('data-color-fixed') === 'true') return;
const nicknameElem = messageElem.querySelector('.live_chatting_username_nickname__dDbbj');
if (nicknameElem) {
this.scheduleColorFix(nicknameElem); // 변경: RAF 사용
}
messageElem.setAttribute('data-color-fixed', 'true');
}
setupObserver(containerSelector) {
const container = document.querySelector(containerSelector);
if (!container) {
setTimeout(() => this.setupObserver(containerSelector), 500);
return;
}
if (this.observer) this.observer.disconnect();
// 기존 메시지 먼저 처리
container.querySelectorAll('[class^="live_chatting_message_chatting_message__"]')
.forEach(msg => this.processMessage(msg));
// 추가: 디바운스용 타이머와 큐
let mutationQueue = [];
let debounceTimer = null;
const DEBOUNCE_DELAY = 16; // ~60fps (채팅 많을 때)
const BATCH_SIZE = 10; // 한 번에 처리할 최대 메시지 수
const processBatch = () => {
const batch = mutationQueue.splice(0, BATCH_SIZE);
batch.forEach(node => {
if (!document.contains(node)) return; // DOM에서 제거된 노드 스킵
// 직접 메시지 노드인 경우
if (node.className && typeof node.className === 'string' &&
node.className.includes('live_chatting_message_chatting_message__')) {
this.processMessage(node);
}
// 컨테이너인 경우 (여러 메시지가 한번에 추가될 때)
else if (node.querySelectorAll) {
const messages = node.querySelectorAll('[class^="live_chatting_message_chatting_message__"]');
messages.forEach(msg => this.processMessage(msg));
}
});
// 남은 작업이 있으면 다음 프레임에 처리
if (mutationQueue.length > 0) {
debounceTimer = setTimeout(processBatch, 0);
} else {
debounceTimer = null;
}
};
this.observer = new MutationObserver((mutations) => {
// 추가된 노드만 큐에 추가
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1) { // Element 노드만
mutationQueue.push(node);
}
});
});
// 디바운스: 연속된 mutation을 묶어서 처리
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(processBatch, DEBOUNCE_DELAY);
});
this.observer.observe(container, {
childList: true,
subtree: false
});
// destroy 시 타이머 정리를 위해 참조 저장
this._debounceTimer = debounceTimer;
}
destroy() {
if (this.observer) this.observer.disconnect();
if (this.rafId) cancelAnimationFrame(this.rafId);
if (this._debounceTimer) clearTimeout(this._debounceTimer);
this.pendingElements.clear();
this.colorCache.clear();
}
}
// ========================================
// 3. 미션 + 고정챗 자동 접기/펼치기
// ========================================
function setupMissionAutoCollapse(retry = 0) {
const fixedWrapper = document.querySelector('.live_chatting_list_fixed__Wy3TT');
if (!fixedWrapper) {
if (retry < 10) {
return setTimeout(() => setupMissionAutoCollapse(retry + 1), 500);
}
return;
}
let cachedButtons = {
mission: null,
chat: null,
party: null,
chatContainer: null,
isValid: true // 추가: 캐시 유효성 플래그
};
const updateButtonCache = () => {
cachedButtons.mission = fixedWrapper.querySelector('.live_chatting_fixed_mission_folded_button__bBWS2');
cachedButtons.chatContainer = document.querySelector('.live_chatting_fixed_container__2tQz6');
cachedButtons.chat = cachedButtons.chatContainer?.querySelector('.live_chatting_fixed_control__FCHpN button:not([aria-haspopup])');
cachedButtons.party = fixedWrapper.querySelector('.live_chatting_fixed_party_header__TMos5');
cachedButtons.isValid = true; // 캐시 갱신 후 유효 표시
};
// 개선: isConnected 사용 (document.contains()보다 훨씬 빠름)
const getButtons = () => {
if (!cachedButtons.isValid) {
updateButtonCache();
}
return cachedButtons;
};
const toggleButton = (button, shouldOpen) => {
if (!button || !button.isConnected) return; // isConnected 체크 추가
const isExpanded = button.getAttribute('aria-expanded') === 'true';
if (shouldOpen && !isExpanded) {
button.click();
} else if (!shouldOpen && isExpanded) {
button.click();
}
};
const openAll = () => {
const buttons = getButtons();
toggleButton(buttons.mission, true);
toggleButton(buttons.chat, true);
toggleButton(buttons.party, true);
};
const closeAll = () => {
const buttons = getButtons();
toggleButton(buttons.mission, false);
toggleButton(buttons.chat, false);
toggleButton(buttons.party, false);
};
updateButtonCache();
closeAll();
if (fixedWrapper._missionHoverBound) return;
fixedWrapper._missionHoverBound = true;
const clickState = {
chatWantsOpen: false,
missionWantsOpen: false,
partyWantsOpen: false
};
fixedWrapper.addEventListener('click', (e) => {
if (!e.isTrusted) return;
const buttons = getButtons();
if (buttons.chatContainer && buttons.chatContainer.contains(e.target)) {
clickState.chatWantsOpen = !clickState.chatWantsOpen;
} else if (e.target.closest('.live_chatting_fixed_party_container__KVPg1')) {
clickState.partyWantsOpen = !clickState.partyWantsOpen;
} else {
clickState.missionWantsOpen = !clickState.missionWantsOpen;
}
});
fixedWrapper.addEventListener('pointerenter', () => {
openAll();
});
fixedWrapper.addEventListener('pointerleave', () => {
const buttons = getButtons();
if (!clickState.chatWantsOpen && !clickState.missionWantsOpen && !clickState.partyWantsOpen) {
closeAll();
} else {
toggleButton(buttons.chat, clickState.chatWantsOpen);
toggleButton(buttons.mission, clickState.missionWantsOpen);
toggleButton(buttons.party, clickState.partyWantsOpen);
}
});
// 개선: MutationObserver로 버튼 제거 감지
const buttonObserver = new MutationObserver((mutations) => {
let shouldUpdate = false;
let hasRemovals = false;
for (const mutation of mutations) {
// 추가된 노드 체크 (파티 버튼)
if (mutation.addedNodes.length > 0) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) {
if (node.classList?.contains('live_chatting_fixed_party_container__KVPg1') ||
node.querySelector?.('.live_chatting_fixed_party_container__KVPg1')) {
shouldUpdate = true;
break;
}
}
}
}
// 제거된 노드 체크 (버튼이 사라졌을 때)
if (mutation.removedNodes.length > 0) {
for (const node of mutation.removedNodes) {
if (node.nodeType === 1) {
// 캐시된 버튼이 제거되었는지 확인
if (node === cachedButtons.mission ||
node === cachedButtons.chat ||
node === cachedButtons.party ||
node === cachedButtons.chatContainer ||
node.contains?.(cachedButtons.mission) ||
node.contains?.(cachedButtons.chat) ||
node.contains?.(cachedButtons.party)) {
hasRemovals = true;
cachedButtons.isValid = false; // 캐시 무효화
break;
}
}
}
}
}
if (shouldUpdate || hasRemovals) {
if (shouldUpdate) {
updateButtonCache();
const buttons = getButtons();
if (buttons.party) {
toggleButton(buttons.party, false);
}
}
// hasRemovals만 있으면 다음 getButtons() 호출 시 자동 갱신
}
});
buttonObserver.observe(fixedWrapper, {
childList: true,
subtree: true // subtree로 변경: 내부 버튼 제거도 감지
});
}
// ========================================
// 4. 드롭스 토글 기능
// ========================================
function initDropsToggle() {
const container = document.getElementById('drops_info');
if (!container || container.classList.contains('drops-init')) return;
const header = container.querySelector('.live_information_drops_header__920BX');
if (!header) return;
const toggleIcon = document.createElement('span');
toggleIcon.classList.add('live_information_drops_toggle_icon');
toggleIcon.textContent = '▼';
header.appendChild(toggleIcon);
header.style.cursor = 'pointer';
container.classList.add('drops-collapsed');
container.classList.add('drops-init');
header.addEventListener('click', () => {
const collapsed = container.classList.toggle('drops-collapsed');
toggleIcon.textContent = collapsed ? '▼' : '▲';
});
}
function setupDropsToggleObserver() {
// 초기 실행
initDropsToggle();
const existingDrops = document.getElementById('drops_info');
if (existingDrops && existingDrops.classList.contains('drops-init')) {
return;
}
let debounceTimer = null;
const DEBOUNCE_DELAY = 100;
const handleMutation = () => {
initDropsToggle();
const drops = document.getElementById('drops_info');
if (drops && drops.classList.contains('drops-init')) {
if (obs) {
obs.disconnect();
obs = null;
}
}
};
const debouncedHandler = () => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(handleMutation, DEBOUNCE_DELAY);
};
let obs = null;
const targetContainer = document.querySelector('#live_player_layout') ||
document.querySelector('[class*="live_information"]') ||
document.body;
obs = new MutationObserver((mutations) => {
let hasDropsChange = false;
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) {
if (node.id === 'drops_info' ||
node.querySelector?.('#drops_info')) {
hasDropsChange = true;
break;
}
}
}
}
if (hasDropsChange) break;
}
if (hasDropsChange) {
debouncedHandler();
}
});
obs.observe(targetContainer, {
childList: true,
subtree: targetContainer === document.body ? true : false
});
if (!window._dropsObserverCleanup) {
window._dropsObserverCleanup = () => {
if (obs) obs.disconnect();
if (debounceTimer) clearTimeout(debounceTimer);
};
}
}
// ========================================
// 5. 키보드 단축키
// ========================================
const keyboardState = {
isChatOpen: true,
isChatHidden: false,
isInfoHidden: false,
styleElements: {
chat: null,
info: null
},
lockedPlayerObserver: null,
fixedPlayerClass: ""
};
const domCache = {
chatCloseBtn: null,
chatOpenBtn: null,
player: null,
isValid: true, // 추가: 캐시 유효성 플래그
observer: null, // 추가: MutationObserver
needsRefresh: new Set(), // 추가: 무효화된 키 추적
refresh(specificKey = null) {
if (specificKey) {
// 특정 키만 갱신
switch(specificKey) {
case 'chatCloseBtn':
this.chatCloseBtn = document.querySelector('.live_chatting_header_button__t2pa1');
break;
case 'chatOpenBtn':
this.chatOpenBtn = document.querySelector('svg[viewBox="0 0 38 34"]')?.closest('button');
break;
case 'player':
this.player = document.querySelector('.pzp-pc');
break;
}
this.needsRefresh.delete(specificKey);
} else {
// 전체 갱신
this.chatCloseBtn = document.querySelector('.live_chatting_header_button__t2pa1');
this.chatOpenBtn = document.querySelector('svg[viewBox="0 0 38 34"]')?.closest('button');
this.player = document.querySelector('.pzp-pc');
this.needsRefresh.clear();
}
this.isValid = true;
},
get(key) {
// 해당 키만 필요시 갱신
if (this.needsRefresh.has(key) || !this[key]) {
this.refresh(key);
}
return this[key];
},
invalidate(key) {
// 특정 키만 무효화
this.needsRefresh.add(key);
},
setupObserver() {
if (this.observer) return; // 이미 설정됨
// 채팅창 영역 감시
const chatArea = document.querySelector('[class*="live_chatting"]') || document.body;
this.observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.removedNodes.length > 0) {
for (const node of mutation.removedNodes) {
if (node.nodeType !== 1) continue;
// 캐시된 요소가 제거되었는지 확인
if (node === this.chatCloseBtn || node.contains?.(this.chatCloseBtn)) {
this.invalidate('chatCloseBtn');
}
if (node === this.chatOpenBtn || node.contains?.(this.chatOpenBtn)) {
this.invalidate('chatOpenBtn');
}
if (node === this.player || node.contains?.(this.player)) {
this.invalidate('player');
}
}
}
}
});
this.observer.observe(chatArea, {
childList: true,
subtree: true
});
},
destroy() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
}
};
// ] 키: 채팅창 접기/펼치기
function toggleChatWindow() {
const chatCloseBtn = domCache.get('chatCloseBtn'); // 필요한 것만 가져옴
if (keyboardState.isChatOpen) {
if (chatCloseBtn) {
chatCloseBtn.click();
keyboardState.isChatOpen = false;
}
} else {
const chatOpenBtn = domCache.get('chatOpenBtn'); // 필요할 때만 가져옴
if (chatOpenBtn) {
chatOpenBtn.click();
keyboardState.isChatOpen = true;
}
}
}
// [ 키: 채팅 댓글만 숨기기
function toggleChatMessages() {
if (keyboardState.isChatHidden) {
if (keyboardState.styleElements.chat) {
keyboardState.styleElements.chat.remove();
keyboardState.styleElements.chat = null;
}
keyboardState.isChatHidden = false;
} else {
if (!keyboardState.styleElements.chat) {
keyboardState.styleElements.chat = GM_addStyle(`
div.live_chatting_list_wrapper__a5XTV {
display: none !important;
}
button.live_chatting_scroll_button_chatting__kqgzN {
display: none !important;
}
button.live_chatting_scroll_button_arrow__tUviD {
display: none !important;
}
p.vod_player_header_title__yPsca {
display: none !important;
}
`);
}
keyboardState.isChatHidden = true;
}
}
// \ 키: 방송 정보 숨기기
function toggleBroadcastInfo() {
if (keyboardState.isInfoHidden) {
if (keyboardState.styleElements.info) {
keyboardState.styleElements.info.remove();
keyboardState.styleElements.info = null;
}
if (keyboardState.lockedPlayerObserver) {
keyboardState.lockedPlayerObserver.disconnect();
keyboardState.lockedPlayerObserver = null;
}
keyboardState.isInfoHidden = false;
} else {
if (!keyboardState.styleElements.info) {
keyboardState.styleElements.info = GM_addStyle(`
div.live_information_player_area__54uqN {
display: none !important;
}
div.pzp-pc__bottom-buttons {
display: none !important;
}
div.pzp-ui-progress__wrap.pzp-ui-slider__wrap-first-child.pzp-ui-slider--handler {
display: none !important;
}
.pzp-pc.pzp-pc--controls {
background: transparent !important;
backdrop-filter: none !important;
}
`);
}
const player = domCache.get('player'); // 필요할 때만 가져옴
if (player) {
keyboardState.fixedPlayerClass = player.className;
if (!keyboardState.lockedPlayerObserver) {
keyboardState.lockedPlayerObserver = new MutationObserver(() => {
// 개선: observer 일시 중단 후 변경 (무한 루프 방지)
keyboardState.lockedPlayerObserver.disconnect();
if (player.className !== keyboardState.fixedPlayerClass) {
player.className = keyboardState.fixedPlayerClass;
}
keyboardState.lockedPlayerObserver.observe(player, {
attributes: true,
attributeFilter: ['class']
});
});
keyboardState.lockedPlayerObserver.observe(player, {
attributes: true,
attributeFilter: ['class']
});
}
player.className = keyboardState.fixedPlayerClass;
}
keyboardState.isInfoHidden = true;
}
}
function handleKeyPress(e) {
const tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) {
return;
}
switch (e.key) {
case ']':
toggleChatWindow();
break;
case '[':
toggleChatMessages();
break;
case '\\':
toggleBroadcastInfo();
break;
}
}
function initKeyboardShortcuts() {
domCache.refresh(); // 초기 캐시
domCache.setupObserver(); // Observer 시작
window.addEventListener('keydown', handleKeyPress);
}
// ========================================
// 6. SPA 페이지 전환 감지
// ========================================
function setupSPADetection() {
let lastUrl = location.href;
const onUrlChange = () => {
if (location.href !== lastUrl) {
lastUrl = location.href;
console.log('[Chzzk Simple] 페이지 전환 감지, 재초기화 중...');
setTimeout(() => {
if (colorFixer) colorFixer.destroy();
colorFixer = new NicknameColorFixer();
colorFixer.setupObserver('[class*="live_chatting_list_wrapper__"]');
setupMissionAutoCollapse();
setupDropsToggleObserver();
}, 500);
}
};
['pushState', 'replaceState'].forEach(method => {
const orig = history[method];
history[method] = function(...args) {
orig.apply(this, args);
setTimeout(onUrlChange, 100);
};
});
window.addEventListener('popstate', onUrlChange);
}
// ========================================
// 7. 초기화
// ========================================
let colorFixer = null;
function init() {
console.log('[Chzzk Simple] 초기화 시작');
colorFixer = new NicknameColorFixer();
colorFixer.setupObserver('[class*="live_chatting_list_wrapper__"]');
setupMissionAutoCollapse();
setupDropsToggleObserver();
initKeyboardShortcuts();
setupSPADetection();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();