Chzzk_L&V: Chatting Plus

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

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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