// ==UserScript==
// @name Chzzk_L&V: Chatting Plus
// @name:ko Chzzk_L&V: 채팅창 추가기능
// @namespace Chzzk_Live&VOD: Chatting Plus
// @version 3.1
// @description 파트너·지정 스트리머 채팅 강조 / 닉네임 각종 설정 / 드롭스 접고 펼치기 / 고정댓글, 미션 자동 제어 / 채팅창 접고 펼치기 단축키( ] ) / 채팅 새로고침 버튼
// @author DOGJIP
// @match https://chzzk.naver.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @run-at document-end
// @license MIT
// @icon https://www.google.com/s2/favicons?sz=64&domain=chzzk.naver.com
// @downloadURL https://update.greasyfork.icu/scripts/532068/Chzzk_LV%3A%20Chatting%20Plus.user.js
// @updateURL https://update.greasyfork.icu/scripts/532068/Chzzk_LV%3A%20Chatting%20Plus.meta.js
// ==/UserScript==
(function() {
'use strict';
// 기본 설정
const DEFAULTS = {
streamer: ['고수달','냐 미 Nyami','새 담','청 묘','침착맨','삼식123','레니아워 RenieHouR'],
exception: ['인챈트 봇','픽셀봇','스텔라이브 봇'],
fixUnreadable: true,
removeHighlight: true,
truncateName: true,
dropsToggle: true,
missionHover: true
};
// chzzk_knife_tracker용 설정 객체
const KNIFE_CONFIG = {
chatContainerSelector: '.live_chatting_list_container__vwsbZ',
chatListSelector: '.live_chatting_list_wrapper__a5XTV',
maxMessages: 100,
defaultStreamers: DEFAULTS.streamer,
defaultExceptions: DEFAULTS.exception,
};
// 사용자 설정 불러오기(GM_getValue)
let streamer = GM_getValue('streamer', DEFAULTS.streamer);
let exception = GM_getValue('exception', DEFAULTS.exception);
const ENABLE_FIX_UNREADABLE_COLOR = GM_getValue('fixUnreadable', DEFAULTS.fixUnreadable);
const ENABLE_REMOVE_BG_COLOR = GM_getValue('removeHighlight', DEFAULTS.removeHighlight);
const ENABLE_TRUNCATE_NICKNAME = GM_getValue('truncateName', DEFAULTS.truncateName);
const ENABLE_DROPS_TOGGLE = GM_getValue('dropsToggle', DEFAULTS.dropsToggle);
const ENABLE_MISSION_HOVER = GM_getValue('missionHover', DEFAULTS.missionHover);
let chatObserver = null;
let pendingNodes = [];
let processScheduled = false;
let isChatOpen = true; // 초기 상태: 열림
let refreshButton = null; // 채팅 리프레쉬 버튼
let refreshButtonObserver = null;
let buttonCheckInterval = null;
function scheduleProcess() {
if (processScheduled) return;
processScheduled = true;
window.requestAnimationFrame(() => {
pendingNodes.forEach(processChatMessage);
pendingNodes = [];
processScheduled = false;
});
}
GM_addStyle(`
/* 오버레이 */
#cp-settings-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.3);
display: flex; align-items: center; justify-content: center;
z-index: 9999;
overflow: auto;
pointer-events: none;
}
/* 패널: 연회색 배경 */
#cp-settings-panel {
background: #b0b0b0;
color: #111;
padding: 1rem;
border-radius: 8px;
width: 480px;
max-width: 90%;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
font-family: sans-serif;
pointer-events: auto;
}
#cp-settings-panel h3 {
margin-top: 0;
color: #111;
}
/* 입력창 */
#cp-settings-panel textarea {
width: 100%;
height: 80px;
margin-bottom: 0.75rem;
background: #fff;
color: #111;
border: 1px solid #ccc;
border-radius: 4px;
padding: 0.5rem;
resize: vertical;
}
/* 버튼 컨테이너: flex layout */
#cp-settings-panel > div {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
/* 버튼 공통 */
#cp-settings-panel button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
font-size: 0.9rem;
cursor: pointer;
}
/* 저장 버튼 */
#cp-settings-panel button#cp-save-btn,
#cp-settings-panel button#cp-exc-save-btn {
background: #007bff;
color: #fff;
}
/* 취소 버튼 */
#cp-settings-panel button#cp-cancel-btn,
#cp-settings-panel button#cp-exc-cancel-btn {
background: #ddd;
color: #111;
/* margin-left: auto; */
}
/* 버튼 호버 시 약간 어두워지기 */
#cp-settings-panel button:hover {
opacity: 0.9;
}
/* Highlight 클래스 */
.cp-highlight {
color: rgb(102, 200, 102) !important;
font-weight: bold !important;
text-transform: uppercase !important;
}
/* 설정 체크박스 레이아웃 */
.cp-setting-row {
//display: flex;
gap: 0.5rem;
margin: 0.5rem 0;
font-size: 0.8rem;
}
.cp-setting-label {
flex: 1;
display: flex;
align-items: center;
gap: 0.2rem;
}
/* 백그라운드 색설정 */
.cp-bg {
background-color: rgba(173, 216, 230, 0.15) !important;
}
/* 채팅 리프레쉬 버튼 스타일 */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`);
function showCombinedPanel() {
if (document.getElementById('cp-settings-overlay')) return;
// overlay & panel 기본 구조 재사용
const overlay = document.createElement('div'); overlay.id = 'cp-settings-overlay';
const panel = document.createElement('div'); panel.id = 'cp-settings-panel';
// 현재 저장된 값 불러오기
const curStreamers = GM_getValue('streamer', DEFAULTS.streamer).join(', ');
const curExceptions= GM_getValue('exception', DEFAULTS.exception).join(', ');
panel.innerHTML = `
강조/제외 닉네임 설정
Enter ↵: 저장 Esc : 취소 (저장시 새로고침 및 적용)
`;
overlay.appendChild(panel);
document.body.appendChild(overlay);
panel.setAttribute('tabindex', '0');
panel.focus();
panel.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
panel.querySelector('#cp-save-btn').click();
} else if (e.key === 'Escape') {
e.preventDefault();
panel.querySelector('#cp-cancel-btn').click();
}
});
panel.querySelector('#cp-save-btn').addEventListener('click', () => {
const s = panel.querySelector('#cp-streamer-input').value;
const e = panel.querySelector('#cp-exception-input').value;
const fixUnread = panel.querySelector('#cp-fix-unread').checked;
const removeHl = panel.querySelector('#cp-remove-hl').checked;
const truncateName = panel.querySelector('#cp-truncate').checked;
const dropsToggleVal = panel.querySelector('#cp-drops-toggle').checked;
GM_setValue('streamer',
Array.from(new Set(s.split(',').map(x=>x.trim()).filter(x=>x)))
);
GM_setValue('exception',
Array.from(new Set(e.split(',').map(x=>x.trim()).filter(x=>x)))
);
GM_setValue('fixUnreadable', fixUnread);
GM_setValue('removeHighlight', removeHl);
GM_setValue('truncateName', truncateName);
GM_setValue('dropsToggle', dropsToggleVal);
GM_setValue('missionHover', document.querySelector('#cp-mission-hover').checked);
document.body.removeChild(overlay);
location.reload();
});
panel.querySelector('#cp-cancel-btn').addEventListener('click', () => {
document.body.removeChild(overlay);
});
}
// ==== 채팅 메시지 처리 ==== //
// 🔧 최적화 1: 셀렉터를 상수로 추출 (재사용)
const SELECTORS = {
PARTNER_ICON: '[class*="name_icon__zdbVH"]',
BADGE_IMG: '.badge_container__a64XB img[src*="manager.png"], .badge_container__a64XB img[src*="streamer.png"]',
NICKNAME: '.live_chatting_username_nickname__dDbbj',
NAME_TEXT: '.name_text__yQG50',
MESSAGE_TEXT: '.live_chatting_message_text__DyleH'
};
// 🔧 최적화 2: 유저 타입 판별 함수 분리 (재사용 가능)
function getUserType(messageElem) {
// 이미 처리된 메시지는 건너뛰기
if (messageElem.getAttribute('data-partner-processed') === 'true') {
return null;
}
// DOM 쿼리를 한 번에 모아서 실행
const nicknameElem = messageElem.querySelector(SELECTORS.NICKNAME);
const badgeImg = messageElem.querySelector(SELECTORS.BADGE_IMG);
// 🔧 최적화 3: 조기 반환 (닉네임 없으면 중단)
if (!nicknameElem) return null;
const nameText = nicknameElem.querySelector(SELECTORS.NAME_TEXT)?.textContent.trim() || '';
// 유저 타입 판별
const isPartner = !!messageElem.querySelector(SELECTORS.PARTNER_ICON);
const isManager = badgeImg?.src.includes('manager.png') || false;
const isStreamer = badgeImg?.src.includes('streamer.png') || false;
const isManualStreamer = streamer.includes(nameText);
const isException = exception.includes(nameText);
return {
nicknameElem,
nameText,
textElem: messageElem.querySelector(SELECTORS.MESSAGE_TEXT),
isPartner,
isManager,
isStreamer,
isManualStreamer,
isException
};
}
// 🔧 최적화 4: 메인 처리 함수 단순화
function processChatMessage(messageElem) {
const userType = getUserType(messageElem);
// 이미 처리되었거나 유효하지 않은 메시지
if (!userType) return;
const { nicknameElem, nameText, textElem, isPartner, isManager,
isStreamer, isManualStreamer, isException } = userType;
// === 유틸 기능 적용 (설정된 경우만) === //
if (ENABLE_FIX_UNREADABLE_COLOR && nicknameElem) {
fixUnreadableNicknameColor(nicknameElem);
}
if (ENABLE_REMOVE_BG_COLOR && nicknameElem) {
removeBackgroundColor(nicknameElem);
}
if (ENABLE_TRUNCATE_NICKNAME && nicknameElem) {
truncateNickname(nicknameElem);
}
// === 스타일 적용 === //
// 🔧 최적화 5: 조건 순서 개선 (자주 false인 것부터)
// 연두색 하이라이트 (파트너 또는 수동 지정 스트리머)
const shouldHighlight = (!isManager && !isStreamer) && (isPartner || isManualStreamer);
if (shouldHighlight) {
nicknameElem?.classList.add('cp-highlight');
textElem?.classList.add('cp-highlight');
}
// 배경색 강조 (예외 제외)
const shouldHighlightBackground = (isPartner || isStreamer || isManager || isManualStreamer) && !isException;
if (shouldHighlightBackground) {
messageElem.classList.add('cp-bg');
}
// 처리 완료 표시
messageElem.setAttribute('data-partner-processed', 'true');
}
// ==== 유틸 함수들 (기존 코드 유지) ==== //
// 🔧 최적화 6: fixUnreadableNicknameColor 개선
const LIGHT_GREEN = "rgb(102, 200, 102)";
const colorCache = new Map();
function fixUnreadableNicknameColor(nicknameElem) {
if (!nicknameElem) return;
const cssColor = window.getComputedStyle(nicknameElem).color;
// 하이라이트 색상은 제외
if (cssColor === LIGHT_GREEN) return;
// 캐시 확인
if (colorCache.has(cssColor)) {
if (colorCache.get(cssColor) === false) {
nicknameElem.style.color = '';
}
return;
}
// 🔧 최적화 7: 정규식 사전 컴파일
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;
if (visibility < 50) {
nicknameElem.style.color = '';
}
colorCache.set(cssColor, visibility >= 50);
}
function removeBackgroundColor(nicknameElem) {
if (!nicknameElem) return;
const bgTarget = nicknameElem.querySelector('[style*="background-color"]');
if (bgTarget) bgTarget.style.removeProperty('background-color');
}
function truncateNickname(nicknameElem, maxLen = 10) {
if (!nicknameElem) return;
const textSpan = nicknameElem.querySelector(SELECTORS.NAME_TEXT);
if (!textSpan) return;
const fullText = textSpan.textContent;
if (fullText.length >= 13) {
textSpan.textContent = fullText.slice(0, maxLen) + '...';
}
}
// 채팅 옵저버 설정
function setupChatObserver() {
if (chatObserver) chatObserver.disconnect();
const chatContainer = document.querySelector('[class*="live_chatting_list_wrapper__"], [class*="vod_chatting_list__"]');
if (!chatContainer) return setTimeout(setupChatObserver, 500);
chatContainer.querySelectorAll('[class^="live_chatting_message_chatting_message__"]').forEach(processChatMessage);
chatObserver = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType !== 1) return;
if (node.className.includes('live_chatting_message_chatting_message__')) {
pendingNodes.push(node);
} else {
node.querySelectorAll('[class^="live_chatting_message_chatting_message__"]')
.forEach(n => pendingNodes.push(n));
}
});
});
scheduleProcess();
});
chatObserver.observe(chatContainer, { childList: true, subtree: false });
}
// 미션창 + 고정 채팅 자동 접고 펼치기 (영역 클릭하여 접고 펼치기 유지)
function setupMissionHover(retry = 0) {
const fixedWrapper = document.querySelector('.live_chatting_list_fixed__Wy3TT');
if (!fixedWrapper) {
if (retry < 10) {
return setTimeout(() => setupMissionHover(retry + 1), 500);
}
return;
}
// 🔧 최적화 1: 모든 토글 가능한 버튼들을 한 번에 관리
let cachedButtons = {
mission: null, // 미션 버튼
chat: null, // 고정 채팅 버튼
party: null, // 파티 후원 버튼
chatContainer: null
};
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');
};
const getButtons = () => {
// DOM에 존재하는지 빠르게 확인
const needsUpdate =
(cachedButtons.mission && !document.contains(cachedButtons.mission)) ||
(cachedButtons.chat && !document.contains(cachedButtons.chat)) ||
(cachedButtons.party && !document.contains(cachedButtons.party)) ||
!cachedButtons.mission || !cachedButtons.chat; // 기본 버튼들이 없으면 갱신
if (needsUpdate) {
updateButtonCache();
}
return cachedButtons;
};
// 🔧 최적화 2: 버튼 클릭 로직 통합
const toggleButton = (button, shouldOpen) => {
if (!button) return;
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;
// 🔧 최적화 3: 상태 관리 (3개 영역)
const clickState = {
chatWantsOpen: false,
missionWantsOpen: false,
partyWantsOpen: false // 🆕 파티 후원 상태
};
// 🔧 최적화 4: 클릭 영역 판별 (3개 영역)
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();
});
// 🔧 최적화 5: pointerleave 로직
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); // 🆕
}
});
// 🆕 최적화 6: 가벼운 MutationObserver로 동적 요소 감지
// fixedWrapper 내부에 새로운 버튼이 추가되면 캐시 갱신
const buttonObserver = new MutationObserver((mutations) => {
let shouldUpdate = false;
for (const mutation of mutations) {
// 새로운 노드가 추가되었는지만 확인
if (mutation.addedNodes.length > 0) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) { // Element 노드만
// 파티 후원 컨테이너가 추가되었는지 확인
if (node.classList?.contains('live_chatting_fixed_party_container__KVPg1') ||
node.querySelector?.('.live_chatting_fixed_party_container__KVPg1')) {
shouldUpdate = true;
break;
}
}
}
}
}
if (shouldUpdate) {
updateButtonCache();
// 새로 추가된 요소도 닫힌 상태로 초기화
const buttons = getButtons();
if (buttons.party) {
toggleButton(buttons.party, false);
}
}
});
// childList만 감시 (attributes, subtree는 감시 안함 = 가벼움)
buttonObserver.observe(fixedWrapper, {
childList: true, // 직접 자식만 감시
subtree: false // 하위 요소는 감시 안함 (성능 최적화)
});
}
// ==== ▽ 드롭스 토글용 CSS ==== //
GM_addStyle(`
#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;
}
`);
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 obs = new MutationObserver(() => {
initDropsToggle();
});
obs.observe(document.body, { childList: true, subtree: true });
}
// ==== ▽ 드롭스 토글용 CSS 끝 ==== //
// ==== 통합 키보드 핸들러 ==== //
const keyboardState = {
isChatOpen: true, // ] 키: 채팅창 열림/닫힘
isChatHidden: false, // [ 키: 채팅 댓글 숨김
isInfoHidden: false, // \ 키: 방송 정보 숨김
styleElements: {
chat: null,
info: null
},
lockedPlayerObserver: null,
fixedPlayerClass: ""
};
const domCache = {
chatCloseBtn: null,
chatOpenBtn: null,
player: null,
// 캐시 갱신 함수
refresh() {
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');
},
// 특정 요소가 유효한지 확인
isValid(key) {
return this[key] && document.contains(this[key]);
}
};
// ] 키: 채팅창 접고 펼치기
function toggleChatWindow() {
if (!domCache.isValid('chatCloseBtn')) domCache.refresh();
if (keyboardState.isChatOpen) {
// 채팅창 닫기
if (domCache.chatCloseBtn) {
domCache.chatCloseBtn.click();
keyboardState.isChatOpen = false;
} else {
//console.warn('채팅 접기 버튼을 찾을 수 없습니다.');
}
} else {
// 채팅창 열기
if (!domCache.isValid('chatOpenBtn')) domCache.refresh();
if (domCache.chatOpenBtn) {
domCache.chatOpenBtn.click();
keyboardState.isChatOpen = true;
} else {
//console.warn('채팅 열기 버튼을 찾을 수 없습니다.');
}
}
}
// [ 키: 채팅 댓글만 숨기기
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;
}
`);
}
// 플레이어 클래스 강제 고정
if (!domCache.isValid('player')) domCache.refresh();
const player = domCache.player;
if (player) {
keyboardState.fixedPlayerClass = player.className;
if (!keyboardState.lockedPlayerObserver) {
keyboardState.lockedPlayerObserver = new MutationObserver(() => {
if (player.className !== keyboardState.fixedPlayerClass) {
player.className = keyboardState.fixedPlayerClass;
}
});
keyboardState.lockedPlayerObserver.observe(player, {
attributes: true,
attributeFilter: ['class']
});
}
player.className = keyboardState.fixedPlayerClass;
}
keyboardState.isInfoHidden = true;
}
}
function handleKeyPress(e) {
//console.log('🔑 키 눌림:', e.key); // 🐛 디버깅
// 입력창에서는 무시
const tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) {
//console.log('⚠️ 입력창에서 무시됨');
return;
}
// 키별 처리
switch (e.key) {
case ']':
//console.log('✅ ] 키 감지 - 채팅 토글');
toggleChatWindow();
break;
case '[':
//console.log('✅ [ 키 감지 - 댓글 토글');
toggleChatMessages();
break;
case '\\':
//console.log('✅ \\ 키 감지 - 방송정보 토글');
toggleBroadcastInfo();
break;
default:
// console.log('❌ 등록되지 않은 키');
}
}
function initKeyboardShortcuts() {
// 초기 DOM 캐시
domCache.refresh();
// 통합 핸들러 등록
window.addEventListener('keydown', handleKeyPress);
//console.log('⌨️ 키보드 단축키 초기화 완료');
//console.log('⌨️ 등록된 키: ] (채팅 접기/펼치기), [ (댓글 숨기기), \\ (방송정보 숨기기)');
}
// ==== 통합 키보드 핸들러 끝 ==== //
//SPA 관리
function setupSPADetection() {
let lastUrl = location.href;
const onUrlChange = () => {
if (location.href !== lastUrl) {
console.log('페이지 변경 감지:', lastUrl, '->', location.href);
lastUrl = location.href;
// 버튼 초기화
refreshButton = null;
setTimeout(() => {
setupChatObserver();
if (ENABLE_MISSION_HOVER) setupMissionHover();
if (ENABLE_DROPS_TOGGLE) setupDropsToggleObserver();
initKnifeTracker(KNIFE_CONFIG);
clickMoreButton();
attachLiveObserver();
retryAddButton();
}, 500);
}
};
// History API 감지
['pushState', 'replaceState'].forEach(method => {
const orig = history[method];
history[method] = function(...args) {
orig.apply(this, args);
setTimeout(onUrlChange, 100); // 약간의 지연 추가
};
});
window.addEventListener('popstate', onUrlChange);
}
// ==== initKnifeTracker ==== //
function initKnifeTracker({
chatContainerSelector,
chatListSelector,
maxMessages = 100,
defaultStreamers = [],
defaultExceptions = [],
}) {
const styleId = 'knifeTracker';
const filteredMessages = [];
let knifeObserver = null;
let filteredBoxCache = null;
let chatListCache = null;
const MAX_COLLECTED_MESSAGES = 500;
const collectedMessages = new Map();
const manualStreamers = GM_getValue('streamer', defaultStreamers);
const exceptions = GM_getValue('exception', defaultExceptions);
const css = `
#filtered-chat-box {
display: flex;
flex-direction: column;
height: 70px;
max-height: 70px; /* 추가: 최대 높이도 제한 */
overflow-y: auto;
padding: 8px 8px 0 8px;
margin: 0;
border-bottom: 2px solid #444;
border-radius: 0 0 6px 6px;
background-color: rgba(30, 30, 30, 0.8);
scrollbar-width: none;
resize: vertical;
min-height: 38px;
max-height: 350px;
position: relative;
}
.live_chatting_list_wrapper__a5XTV,
.live_chatting_list_container__vwsbZ {
margin-top: 0 !important;
padding-top: 0 !important;
}
.live_chatting_list_fixed__Wy3TT {
top: 0 !important;
}
`;
function injectStyles() {
if (document.head.querySelector(`#${styleId}`)) return;
const s = document.createElement('style');
s.id = styleId;
s.textContent = css;
document.head.appendChild(s);
}
function shouldTrackUser(node) {
const nicknameElem = node.querySelector('.live_chatting_username_nickname__dDbbj');
const nameText = nicknameElem?.querySelector('.name_text__yQG50')?.textContent.trim() || '';
const isPartner = !!node.querySelector('[class*="name_icon__zdbVH"]');
const badgeImg = node.querySelector(
'.badge_container__a64XB img[src*="manager.png"], ' +
'.badge_container__a64XB img[src*="streamer.png"]'
);
const isManager = badgeImg?.src.includes('manager.png');
const isStreamer = badgeImg?.src.includes('streamer.png');
const isManualStreamer = manualStreamers.includes(nameText);
const isException = exceptions.includes(nameText);
return !isException && (isPartner || isStreamer || isManager || isManualStreamer);
}
function createFilteredBox() {
const container = document.querySelector(chatContainerSelector);
if (!container || document.getElementById('filtered-chat-box')) return;
const box = document.createElement('div');
box.id = 'filtered-chat-box';
container.parentElement.insertBefore(box, container);
injectStyles();
// 🔧 최적화 3: 캐시 저장
filteredBoxCache = box;
filteredMessages.forEach(m => {
const clone = m.cloneNode(true);
resizeVerificationMark(clone);
box.appendChild(clone);
});
box.scrollTop = 0;
}
function scrollToLatest(box, targetElement) {
// requestAnimationFrame으로 DOM 업데이트 후 스크롤 보장
requestAnimationFrame(() => {
box.scrollTop = box.scrollHeight; // 0 → scrollHeight로 변경 (기존: box.scrollTop = 0;)
});
}
function addToCollected(key) {
if (collectedMessages.size >= MAX_COLLECTED_MESSAGES) {
// 가장 오래된 항목 제거 (Map의 첫 번째 키)
const firstKey = collectedMessages.keys().next().value;
collectedMessages.delete(firstKey);
}
collectedMessages.set(key, Date.now());
}
let lastKnownMessageCount = 0;
function observeNewMessages() {
// 🔧 최적화 6: 리스트 캐싱
if (!chatListCache) {
chatListCache = document.querySelector(chatListSelector);
}
const list = chatListCache;
if (!list) return;
lastKnownMessageCount = list.children.length;
if (knifeObserver) knifeObserver.disconnect();
knifeObserver = new MutationObserver(mutations => {
// 🔧 최적화 7: 박스 캐시 사용
const box = filteredBoxCache || document.getElementById('filtered-chat-box');
if (!box) return;
mutations.forEach(m => {
for (const node of m.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
if (!node.matches('.live_chatting_list_item__0SGhw')) continue;
// 중복 체크
const nickname = node.querySelector('.name_text__yQG50')?.textContent?.trim() || '';
const message = node.querySelector('.live_chatting_message_chatting_message__7TKns')?.textContent?.trim() || '';
const key = `${nickname}:${message}`;
if (collectedMessages.has(key)) continue;
addToCollected(key); // 🔧 개선된 메모리 관리
if (node._knifeProcessed) continue;
node._knifeProcessed = true;
if (!node.querySelector('[class^="live_chatting_message_container__"]')) continue;
if (!shouldTrackUser(node)) continue;
const clone = node.cloneNode(true);
replaceBlockWithInline(clone);
resizeVerificationMark(clone);
const chatList = list;
const currentMessageCount = chatList.children.length;
// 🔧 수정: 메시지 개수가 증가했으면 실시간, 아니면 과거
const isRealTimeMessage = currentMessageCount > lastKnownMessageCount;
if (isRealTimeMessage) {
// 과거 메시지: 맨 위에 추가
box.insertBefore(clone, box.firstChild);
filteredMessages.unshift(clone);
if (filteredMessages.length > maxMessages) {
filteredMessages.pop();
const lastChild = box.lastChild;
if (lastChild) box.removeChild(lastChild);
}
//console.log('📩 과거 메시지 추가');
//console.log('Before - scrollTop:', box.scrollTop, 'scrollHeight:', box.scrollHeight, 'clientHeight:', box.clientHeight);
/*
// 🔧 메시지가 보이도록 스크롤
requestAnimationFrame(() => {
clone.scrollIntoView({ behavior: 'auto', block: 'nearest' });
console.log('After - scrollTop:', box.scrollTop, 'scrollHeight:', box.scrollHeight);
});
*/
} else {
// 실시간 메시지: 맨 아래 추가
box.appendChild(clone);
filteredMessages.push(clone);
if (filteredMessages.length > maxMessages) {
filteredMessages.shift();
const firstChild = box.firstChild;
if (firstChild) box.removeChild(firstChild);
}
//console.log('📜 실시간 메시지 추가');
//console.log('Before scrollTop:', box.scrollTop, 'scrollHeight:', box.scrollHeight);
// 🔧 실시간 메시지도 보이도록 스크롤
requestAnimationFrame(() => {
clone.scrollIntoView({ behavior: 'auto', block: 'nearest' });
//console.log('After scrollTop:', box.scrollTop, 'scrollHeight:', box.scrollHeight);
});
}
lastKnownMessageCount = currentMessageCount;
}
});
});
knifeObserver.observe(list, { childList: true, subtree: true });
}
function processExistingMessages() {
const list = document.querySelector(chatListSelector);
if (!list) return;
const existingMessages = Array.from(list.querySelectorAll('.live_chatting_list_item__0SGhw'));
existingMessages.reverse().forEach(node => {
if (node._knifeProcessed) return;
node._knifeProcessed = true;
if (!node.querySelector('[class^="live_chatting_message_container__"]')) return;
if (!shouldTrackUser(node)) return;
const clone = node.cloneNode(true);
replaceBlockWithInline(clone);
resizeVerificationMark(clone);
filteredMessages.unshift(clone);
if (filteredMessages.length > maxMessages) filteredMessages.pop();
});
}
function replaceBlockWithInline(node) {
const messageElement = node.querySelector('.live_chatting_message_chatting_message__7TKns');
if (!messageElement || messageElement.tagName !== 'DIV') return;
const span = document.createElement('span');
span.className = messageElement.className;
span.innerHTML = messageElement.innerHTML;
span.style.paddingLeft = '0px';
messageElement.replaceWith(span);
}
function resizeVerificationMark(node) {
const verified = node.querySelector('.live_chatting_username_nickname__dDbbj .blind');
if (verified) {
verified.style.fontSize = '10px';
verified.style.lineHeight = '1';
verified.style.verticalAlign = 'middle';
verified.style.marginLeft = '4px';
verified.style.opacity = '0.8';
}
const nameIcons = node.querySelectorAll('[class*="name_icon__zdbVH"]');
nameIcons.forEach(icon => {
icon.style.width = '14px';
icon.style.height = '14px';
icon.style.marginTop = '1px';
if (icon.style.backgroundImage) {
icon.style.backgroundSize = '14px 14px';
}
});
const badgeImages = node.querySelectorAll('.badge_container__a64XB img');
badgeImages.forEach(img => {
img.style.width = '14px';
img.style.height = '14px';
img.style.marginRight = '2px';
});
}
function waitForChatThenInit() {
const obs = new MutationObserver((_, o) => {
const c = document.querySelector(chatContainerSelector);
const l = document.querySelector(chatListSelector);
if (c && l) {
o.disconnect();
injectStyles();
processExistingMessages();
createFilteredBox();
observeNewMessages();
}
});
obs.observe(document.body, { childList: true, subtree: true });
}
waitForChatThenInit();
}
// ==== initKnifeTracker 끝 ==== //
// 설정 메뉴 추가
GM_registerMenuCommand("⚙️ Chzzk: Chatting Plus 설정 변경", showCombinedPanel);
// 초기화
function init() {
setupChatObserver();
setupSPADetection();
initKnifeTracker(KNIFE_CONFIG);
if (ENABLE_MISSION_HOVER) setupMissionHover();
if (ENABLE_DROPS_TOGGLE) setupDropsToggleObserver();
initKeyboardShortcuts();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();