Chzzk_L&V: Chatting Plus

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

目前為 2025-05-13 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Chzzk_L&V: Chatting Plus
// @namespace    Chzzk_Live&VOD: Chatting Plus
// @version      2.0.1.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
    };

    // 사용자 설정 불러오기(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; // 초기 상태: 열림

    function scheduleProcess() {
        if (processScheduled) return;
        processScheduled = true;
        window.requestAnimationFrame(() => {
            pendingNodes.forEach(processChatMessage);
            pendingNodes = [];
            processScheduled = false;
            });
    }

    const LIGHT_GREEN = "rgb(102, 200, 102)";
    const Background_SKYBLUE = 'rgba(173, 216, 230, 0.15)';
    const colorCache = new Map(); // key: CSS color string, value: 가시성(true=보임, 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;
  }
`);

    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>
      <div>
        <button id="cp-save-btn">저장</button>
        <button id="cp-cancel-btn">취소</button>
      </div>
      <div style="font-size:0.75rem; color:#666; 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);
    });
}

    // 유틸: 닉네임 색상이 너무 어두운 경우 스타일 제거
    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;
        }
        // 밝기 계산 로직
        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('.name_text__yQG50');
        if (!textSpan) return;
        const fullText = textSpan.textContent;
        if (fullText.length > maxLen) textSpan.textContent = fullText.slice(0, maxLen) + '...';
    }

    // 채팅 메시지 처리
    function processChatMessage(messageElem) {
        if (messageElem.getAttribute('data-partner-processed') === 'true') return;
        const isPartner = !!messageElem.querySelector('[class*="name_icon__zdbVH"]');
        const badgeImg = messageElem.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 nicknameElem = messageElem.querySelector('.live_chatting_username_nickname__dDbbj');
        const textElem = messageElem.querySelector('.live_chatting_message_text__DyleH');

        if (ENABLE_FIX_UNREADABLE_COLOR) fixUnreadableNicknameColor(nicknameElem);
        if (ENABLE_REMOVE_BG_COLOR)    removeBackgroundColor(nicknameElem);
        if (ENABLE_TRUNCATE_NICKNAME)  truncateNickname(nicknameElem);

        const nameText = nicknameElem?.querySelector('.name_text__yQG50')?.textContent.trim() || '';
        const isManualStreamer = streamer.includes(nameText);

        // 연두색 스타일
        if ((!isManager && !isStreamer) && (isPartner || isManualStreamer)) {
            nicknameElem && nicknameElem.classList.add('cp-highlight');
            textElem     && textElem.classList.add('cp-highlight');
            }
        // 배경 강조
        if ((isPartner || isStreamer || isManager || isManualStreamer) && !exception.includes(nameText)) {
            messageElem.classList.add('cp-bg');
            }
        messageElem.setAttribute('data-partner-processed', 'true');
    }

    // 채팅 옵저버 설정
    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) {
    // 1) 미션창 wrapper
    const fixedWrapper = document.querySelector('.live_chatting_list_fixed__Wy3TT');
    if (!fixedWrapper) {
        if (retry < 10) {
            return setTimeout(() => setupMissionHover(retry + 1), 500);
        }
        return;
    }

    // 2) 토글 버튼을 찾아주는 유틸
    const getButtons = () => {
        const missionBtn = fixedWrapper.querySelector('.live_chatting_fixed_mission_folded_button__bBWS2');
        const fixedChat   = document.querySelector('.live_chatting_fixed_container__2tQz6');
        const chatBtn     = fixedChat
            ?.querySelector('.live_chatting_fixed_control__FCHpN button:not([aria-haspopup])');
        return { missionBtn, chatBtn };
    };

    // 3) 모두 펼치기
    const openAll = () => {
        const { missionBtn, chatBtn } = getButtons();
        if (missionBtn && missionBtn.getAttribute('aria-expanded') === 'false') {
            missionBtn.click();
        }
        if (chatBtn && chatBtn.getAttribute('aria-expanded') === 'false') {
            chatBtn.click();
        }
    };

    // 4) 모두 접기
    const closeAll = () => {
        const { missionBtn, chatBtn } = getButtons();
        if (missionBtn && missionBtn.getAttribute('aria-expanded') === 'true') {
            missionBtn.click();
        }
        if (chatBtn && chatBtn.getAttribute('aria-expanded') === 'true') {
            chatBtn.click();
        }
    };

    // 5) 초기에는 무조건 펼친 상태로
    openAll();

    // 6) 한 번만 바인딩
    if (fixedWrapper._missionHoverBound) return;
    fixedWrapper._missionHoverBound = true;

    // 7) 마우스 들어오면 펼치기
    fixedWrapper.addEventListener('pointerenter', () => {
        openAll();
    });

    // 8) 마우스 나가면 접기
    fixedWrapper.addEventListener('pointerleave', () => {
        closeAll();
    });

    // 9) 사용자가 직접 클릭(수동 토글)이력 저장
    const { missionBtn } = getButtons();
    missionBtn?.addEventListener('click', () => {
        fixedWrapper.dataset.userExpanded =
            missionBtn.getAttribute('aria-expanded') === 'true' ? 'true' : '';
    });
}

        // ▽ 드롭스 토글용 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 closeChat() {
        const btn = document.querySelector('.live_chatting_header_button__t2pa1');
        if (btn) {
            btn.click();
        } else {
            console.warn('채팅 접기 버튼을 찾을 수 없습니다.');
        }
    }

    function openChat() {
        const btn = document
            .querySelector('svg[viewBox="0 0 38 34"]')
            ?.closest('button');
        if (btn) {
            btn.click();
        } else {
            console.warn('기본 채팅 토글 버튼을 찾을 수 없습니다.');
        }
    }

    function onKeydown(e) {
        const tag = e.target.tagName;
        if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return;
        if (e.key === ']') {
            if (isChatOpen) {
                closeChat();
                isChatOpen = false;
            } else {
                openChat();
                isChatOpen = true;
            }
        }
    }
        window.addEventListener('keydown', onKeydown);

  /**  드롭스 토글 기능 **/
  function initDropsToggle() {
    const container = document.getElementById('drops_info');
    if (!container) 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');

    header.addEventListener('click', () => {
      const collapsed = container.classList.toggle('drops-collapsed');
     toggleIcon.textContent = collapsed ? '▼' : '▲';
    });
  }

      // SPA
  function setupDropsToggleObserver() {
    initDropsToggle();
    new MutationObserver((muts, obs) => {
      if (document.getElementById('drops_info')) {
        initDropsToggle();
        obs.disconnect();
      }
    }).observe(document.body, { childList: true, subtree: true });
  }


    // SPA 탐지
    function setupSPADetection() {
        let lastUrl = location.href;
        const onUrlChange = () => {
            if (location.href !== lastUrl) {
                setTimeout(() => {
                    setupChatObserver();
                    if (ENABLE_MISSION_HOVER) setupMissionHover();
                }, 1000);
            }
        };
        ['pushState','replaceState'].forEach(m => {
            const orig = history[m];
            history[m] = function(...args) { orig.apply(this,args); onUrlChange(); };
        });
        window.addEventListener('popstate', onUrlChange);
    }

    // 설정 메뉴 추가
    GM_registerMenuCommand("⚙️ Chzzk: Chatting Plus 설정 변경", showCombinedPanel);

    // 초기화
    function init() {
        setupChatObserver();
        setupSPADetection();
        if (ENABLE_MISSION_HOVER) setupMissionHover();
        if (ENABLE_DROPS_TOGGLE) setupDropsToggleObserver();
    }
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
})();