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