Greasy Fork

Greasy Fork is available in English.

Chzzk_L&V: Chatting Plus

파트너·지정 스트리머 채팅 강조 / 닉네임 각종 설정 / 드롭스 접고 펼치기 / 고정댓글, 미션 자동 제어 / 채팅창 접고 펼치기 단축키( ] ) / 채팅 새로고침 버튼

当前为 2025-10-15 提交的版本,查看 最新版本

// ==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
// ==/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 = `
    <h3>강조/제외 닉네임 설정</h3>
    <label>연두색으로 강조할 닉네임 (콤마로 구분 //파트너 기본 지원):</label>
    <textarea id="cp-streamer-input">${curStreamers}</textarea>
    <label>배경색 강조 제외할 닉네임 (콤마로 구분 //매니저 봇등):</label>
    <textarea id="cp-exception-input">${curExceptions}</textarea>

    <label><h4>유틸 기능 (온/오프)------------------------------------------------------</h4></label>
    <div class="cp-setting-row">
        <label class="cp-setting-label">
              <input type="checkbox" id="cp-fix-unread" ${ENABLE_FIX_UNREADABLE_COLOR ? 'checked' : ''}> 투명 닉네임 제거</label>
        <label class="cp-setting-label">
              <input type="checkbox" id="cp-remove-hl" ${ENABLE_REMOVE_BG_COLOR ? 'checked' : ''}> 형광펜 제거)</label>
        <label class="cp-setting-label">
              <input type="checkbox" id="cp-truncate" ${ENABLE_TRUNCATE_NICKNAME ? 'checked' : ''}> 길이 제한 (최대:10자)</label>
    </div>

    <div class="cp-setting-row">
        <label class="cp-setting-label">
              <input type="checkbox" id="cp-drops-toggle" ${ENABLE_DROPS_TOGGLE ? 'checked' : ''}> 드롭스 토글 기능</label>
        <label class="cp-setting-label">
              <input type="checkbox" id="cp-mission-hover" ${ENABLE_MISSION_HOVER ? 'checked' : ''}> 고정 댓글, 미션 자동 펼치고 접기 <br>(처음 펼침, 마우스 지나가면 접힘)</label>
    </div>
          <label><h4>-----------------------------------------------------------------------------</h4></label>
          <label><h5>추가기능: 키보드 " ] " 버튼을 눌러 채팅창을 접고 펼칠 수 있습니다.</h4></label>
          <label><h5>추가기능: 채팅 입력창 옆에 새로고침 버튼으로 채팅창만 새로고침 가능합니다.</h4></label>
    <div>
      <button id="cp-save-btn">저장</button>
      <button id="cp-cancel-btn">취소</button>
    </div>
    <div style="font-size:0.75rem; text-align:right; margin-top:0.5rem;">
       Enter ↵: 저장 Esc : 취소 (저장시 새로고침 및 적용)
    </div>
  `;
      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();
})();