// ==UserScript== // @name SOOP (숲) - 사이드바 UI 변경 // @name:ko SOOP (숲) - 사이드바 UI 변경 // @namespace https://greasyfork.org/ko/scripts/484713 // @version 20250644 // @description SOOP (숲)의 사이드바 UI를 변경합니다. // @description:ko SOOP (숲)의 사이드바 UI를 변경합니다. // @author askld // @match https://www.sooplive.co.kr/* // @match https://play.sooplive.co.kr/* // @match https://vod.sooplive.co.kr/player/* // @icon https://res.sooplive.co.kr/afreeca.ico // @grant unsafeWindow // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @connect sooplive.co.kr // @connect naver.com // @run-at document-end // @license // @downloadURL none // ==/UserScript== (function() { 'use strict'; //====================================== // 1. 전역 변수 및 설정 (Global Variables & Configuration) //====================================== const NEW_UPDATE_DATE = 20250613; const CURRENT_URL = window.location.href; const IS_DARK_MODE = document.documentElement.getAttribute('dark') === 'true'; const HIDDEN_BJ_LIST = []; let allFollowUserIds = GM_getValue('allFollowUserIds', []); let STATION_FEED_DATA; let menuIds = {}; let categoryMenuIds = {}; let wordMenuIds = {}; let displayFollow = GM_getValue("displayFollow", 6); let displayMyplus = GM_getValue("displayMyplus", 6); let displayMyplusvod = GM_getValue("displayMyplusvod", 4); let displayTop = GM_getValue("displayTop", 6); let myplusPosition = GM_getValue("myplusPosition", 1); let myplusOrder = GM_getValue("myplusOrder", 1); let blockedUsers = GM_getValue('blockedUsers', []); let blockedCategories = GM_getValue('blockedCategories', []); let blockedWords = GM_getValue('blockedWords', []); // 방송 목록 차단 단어 let registeredWords = GM_getValue("registeredWords",""); // 채팅창 차단 단어 let selectedUsers = GM_getValue("selectedUsers",""); // 유저 채팅 모아보기 아이디 let nicknameWidth = GM_getValue("nicknameWidth",126); let isOpenNewtabEnabled = GM_getValue("isOpenNewtabEnabled", 0); let isSidebarMinimized = GM_getValue("isSidebarMinimized", 0); let showSidebarOnScreenMode = GM_getValue("showSidebarOnScreenMode", 1); let showSidebarOnScreenModeAlways = GM_getValue("showSidebarOnScreenModeAlways", 0); let savedCategory = GM_getValue("szBroadCategory",0); let isAutoChangeMuteEnabled = GM_getValue("isAutoChangeMuteEnabled", 0); let isAutoChangeQualityEnabled = GM_getValue("isAutoChangeQualityEnabled", 0); let isNo1440pEnabled = GM_getValue("isNo1440pEnabled", 0); let isDuplicateRemovalEnabled = GM_getValue("isDuplicateRemovalEnabled", 1); let isRemainingBufferTimeEnabled = GM_getValue("isRemainingBufferTimeEnabled", 1); let isPinnedStreamWithNotificationEnabled = GM_getValue("isPinnedStreamWithNotificationEnabled", 0); let isPinnedStreamWithPinEnabled = GM_getValue("isPinnedStreamWithPinEnabled", 0); let isBottomChatEnabled = GM_getValue("isBottomChatEnabled", 0); let isMakePauseButtonEnabled = GM_getValue("isMakePauseButtonEnabled", 1); let isCaptureButtonEnabled = GM_getValue("isCaptureButtonEnabled", 1); let isMakeSharpModeShortcutEnabled = GM_getValue("isMakeSharpModeShortcutEnabled", 1); let isMakeLowLatencyShortcutEnabled = GM_getValue("isMakeLowLatencyShortcutEnabled", 1); let isMakeQualityChangeShortcutEnabled = GM_getValue("isMakeQualityChangeShortcutEnabled", 0); let isSendLoadBroadEnabled = GM_getValue("isSendLoadBroadEnabled", 1); let isSelectBestQualityEnabled = GM_getValue("isSelectBestQualityEnabled", 1); let isHideSupporterBadgeEnabled = GM_getValue("isHideSupporterBadgeEnabled",0); let isHideFanBadgeEnabled = GM_getValue("isHideFanBadgeEnabled",0); let isHideSubBadgeEnabled = GM_getValue("isHideSubBadgeEnabled",0); let isHideVIPBadgeEnabled = GM_getValue("isHideVIPBadgeEnabled",0); let isHideManagerBadgeEnabled = GM_getValue("isHideManagerBadgeEnabled",0); let isHideStreamerBadgeEnabled = GM_getValue("isHideStreamerBadgeEnabled",0); let isBlockWordsEnabled = GM_getValue("isBlockWordsEnabled",0); let isAutoClaimGemEnabled = GM_getValue("isAutoClaimGemEnabled",0); let isVideoSkipHandlerEnabled = GM_getValue("isVideoSkipHandlerEnabled",0); let isSmallUserLayoutEnabled = GM_getValue("isSmallUserLayoutEnabled",0); let isChannelFeedEnabled = GM_getValue("isChannelFeedEnabled",1); let isChangeFontEnabled = GM_getValue("isChangeFontEnabled", 0); let isCustomSidebarEnabled = GM_getValue("isCustomSidebarEnabled", 1); let isRemoveCarouselEnabled = GM_getValue("isRemoveCarouselEnabled", 0); let isDocumentTitleUpdateEnabled = GM_getValue("isDocumentTitleUpdateEnabled", 1); let isRemoveRedistributionTagEnabled = GM_getValue("isRemoveRedistributionTagEnabled", 1); let isRemoveWatchLaterButtonEnabled = GM_getValue("isRemoveWatchLaterButtonEnabled", 1); let isRemoveBroadStartTimeTagEnabled = GM_getValue("isRemoveBroadStartTimeTagEnabled", 0); let isBroadTitleTextEllipsisEnabled = GM_getValue("isBroadTitleTextEllipsisEnabled", 0); let isUnlockCopyPasteEnabled = GM_getValue("isUnlockCopyPasteEnabled", 0); let isAlignNicknameRightEnabled = GM_getValue("isAlignNicknameRightEnabled", 0); let isReplaceEmptyThumbnailEnabled = GM_getValue("isReplaceEmptyThumbnailEnabled", 1); let isAutoScreenModeEnabled = GM_getValue("isAutoScreenModeEnabled", 0); let isAdjustDelayNoGridEnabled = GM_getValue("isAdjustDelayNoGridEnabled", 0); let ishideButtonsAboveChatInputEnabled = GM_getValue("ishideButtonsAboveChatInputEnabled", 0); let isExpandVODChatAreaEnabled = GM_getValue("isExpandVODChatAreaEnabled", 1); let isExpandLiveChatAreaEnabled = GM_getValue("isExpandLiveChatAreaEnabled", 1); let isOpenExternalPlayerEnabled = GM_getValue("isOpenExternalPlayerEnabled", 0); let isOpenExternalPlayerFromSidebarEnabled = GM_getValue("isOpenExternalPlayerFromSidebarEnabled", 0); let isRemoveShadowsFromCatchEnabled = GM_getValue("isRemoveShadowsFromCatchEnabled", 0); let isChzzkTopChannelsEnabled = GM_getValue("isChzzkTopChannelsEnabled", 0); let isChzzkFollowChannelsEnabled = GM_getValue("isChzzkFollowChannelsEnabled", 0); let isAdaptiveSpeedControlEnabled = GM_getValue("isAdaptiveSpeedControlEnabled", 0); let isShowSelectedMessagesEnabled = GM_getValue("isShowSelectedMessagesEnabled", 0); let isShowDeletedMessagesEnabled = GM_getValue("isShowDeletedMessagesEnabled", 0); let isNoAutoVODEnabled = GM_getValue("isNoAutoVODEnabled", 1); let isHideEsportsInfoEnabled = GM_getValue("isHideEsportsInfoEnabled",0); let isBlockedCategorySortingEnabled = GM_getValue("isBlockedCategorySortingEnabled",0); let isChatCounterEnabled = GM_getValue("isChatCounterEnabled",1); let isRandomSortEnabled = GM_getValue("isRandomSortEnabled",0); let isPinnedOnlineOnlyEnabled = GM_getValue("isPinnedOnlineOnlyEnabled",0); let isMonthlyRecapEnabled = GM_getValue("isMonthlyRecapEnabled",1); let isClickToMuteEnabled = GM_getValue("isClickToMuteEnabled",0); let isVODChatScanEnabled = GM_getValue("isVODChatScanEnabled",0); const WEB_PLAYER_SCROLL_LEFT = isSidebarMinimized ? 52 : 240; const REG_WORDS = registeredWords ? registeredWords.split(',').map(word => word.trim()).filter(Boolean) : []; const qualityNameToInternalType = { sd: 'LOW', hd: 'NORMAL', hd4k: 'HIGH_4000', hd8k: 'HIGH_8000', original: 'ORIGINAL', auto: 'AUTO' }; const BUTTON_DATA = [ { href: 'https://www.sooplive.co.kr/live/all', text: 'LIVE', onClickTarget: '#live > a' }, { href: 'https://www.sooplive.co.kr/my/favorite', text: 'MY', onClickTarget: '#my > a' }, { href: 'https://www.sooplive.co.kr/directory/category', text: '탐색', onClickTarget: '#cate > a' }, { href: 'https://vod.sooplive.co.kr/player/catch', text: '캐치', onClickTarget: '#catch > a' } ]; let qualityChangeTimeout = null; let previousQualityBeforeDowngrade = null; let previousIsAutoMode = null; let didChangeToLowest = false; let previousViewers = 0; let previousTitle = ''; const selectedUsersArray = selectedUsers ? selectedUsers.split(',').map(user_id => user_id.trim()).filter(Boolean) : []; const targetUserIdSet = new Set([...allFollowUserIds, ...selectedUsersArray]); // --- 리캡 관련 전역 변수 및 상수 --- // let recapInitialized = false; let recapModalBackdrop = null; // 모달 요소 참조 let activeCharts = []; // 활성 차트 인스턴스 저장 let categoryImageMap = null; // 카테고리 이미지 URL 캐시 const STATS_API_URL = 'https://broadstatistic.sooplive.co.kr/api/watch_statistic.php'; const INFO_API_URL = 'https://afevent2.sooplive.co.kr/api/get_private_info.php'; const SEARCH_API_URL = 'https://sch.sooplive.co.kr/api.php'; const CATEGORY_API_URL = 'https://sch.sooplive.co.kr/api.php'; const screenshotGradientPalette = ['linear-gradient(135deg, #667eea 0%, #764ba2 100%)', 'linear-gradient(135deg, #2af598 0%, #009efd 100%)', 'linear-gradient(135deg, #ff8c42 0%, #ff3f3f 100%)', 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)']; const deviceTranslations = { desktop: '데스크톱', mobile: '모바일' }; const typeTranslations = { general: '일반', best: '베스트', partner: '파트너' }; const vodTypeTranslations = { review: '다시보기', highlight: '하이라이트', upload: '업로드VOD', uploadclip: '업로드클립', user: '유저VOD', userclip: '유저클립', livestarclip: '별풍선클립'}; const chartColors = ['#e74c3c', '#8e44ad', '#3498db', '#f1c40f', '#1abc9c', '#2ecc71', '#d35400']; //====================================== // 2. CSS 스타일 정의 (CSS Styles) //====================================== const CommonStyles = ` #blockWordsInput::placeholder, #selectedUsersInput::placeholder { font-size: 14px; } /* Expand 토글용 li 스타일 */ .expand-toggle-li { width: 32px; height: 32px; cursor: pointer; background-color: transparent; background-repeat: no-repeat; background-position: center; list-style: none; background-size: 20px; /* 채팅 확장 아이콘 */ background-image: url('data:image/svg+xml,%3Csvg%20fill%3D%22%23757B8A%22%20height%3D%2264%22%20width%3D%2264%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20512%20512%22%20xml%3Aspace%3D%22preserve%22%20stroke%3D%22%23757B8A%22%3E%3Cg%20stroke-width%3D%220%22%2F%3E%3Cg%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M335.085%20207.085%20469.333%2072.837V128c0%2011.782%209.551%2021.333%2021.333%2021.333S512%20139.782%20512%20128V21.335q-.001-1.055-.106-2.107c-.031-.316-.09-.622-.135-.933-.054-.377-.098-.755-.172-1.13-.071-.358-.169-.705-.258-1.056-.081-.323-.152-.648-.249-.968-.104-.345-.234-.678-.355-1.015-.115-.319-.22-.641-.35-.956s-.284-.616-.428-.923c-.153-.324-.297-.651-.467-.969-.158-.294-.337-.574-.508-.86-.186-.311-.362-.626-.565-.93-.211-.316-.447-.613-.674-.917-.19-.253-.366-.513-.568-.76a22%2022%200%200%200-1.402-1.551l-.011-.012-.011-.01a22%2022%200%200%200-1.552-1.403c-.247-.203-.507-.379-.761-.569-.303-.227-.6-.462-.916-.673-.304-.203-.619-.379-.931-.565-.286-.171-.565-.35-.859-.508-.318-.17-.644-.314-.969-.467-.307-.145-.609-.298-.923-.429-.315-.13-.637-.236-.957-.35-.337-.121-.669-.25-1.013-.354-.32-.097-.646-.168-.969-.249-.351-.089-.698-.187-1.055-.258-.375-.074-.753-.119-1.13-.173-.311-.044-.617-.104-.933-.135A22%2022%200%200%200%20490.667%200H384c-11.782%200-21.333%209.551-21.333%2021.333S372.218%2042.666%20384%2042.666h55.163L304.915%20176.915c-8.331%208.331-8.331%2021.839%200%2030.17s21.839%208.331%2030.17%200zm-158.17%2097.83L42.667%20439.163V384c0-11.782-9.551-21.333-21.333-21.333C9.551%20362.667%200%20372.218%200%20384v106.667q.001%201.055.106%202.105c.031.315.09.621.135.933.054.377.098.756.173%201.13.071.358.169.704.258%201.055.081.324.152.649.249.969.104.344.233.677.354%201.013.115.32.22.642.35.957s.284.616.429.923c.153.324.297.651.467.969.158.294.337.573.508.859.186.311.362.627.565.931.211.316.446.612.673.916.19.254.366.514.569.761q.664.811%201.403%201.552l.01.011.012.011q.741.738%201.551%201.402c.247.203.507.379.76.568.304.227.601.463.917.674.303.203.618.379.93.565.286.171.565.35.86.508.318.17.645.314.969.467.307.145.609.298.923.428s.636.235.956.35c.337.121.67.25%201.015.355.32.097.645.168.968.249.351.089.698.187%201.056.258.375.074.753.118%201.13.172.311.044.618.104.933.135q1.05.105%202.104.106H128c11.782%200%2021.333-9.551%2021.333-21.333s-9.551-21.333-21.333-21.333H72.837l134.248-134.248c8.331-8.331%208.331-21.839%200-30.17s-21.839-8.331-30.17%200zm330.821%20198.51c.226-.302.461-.598.671-.913.204-.304.38-.62.566-.932.17-.285.349-.564.506-.857.17-.318.315-.646.468-.971.145-.306.297-.607.428-.921.13-.315.236-.637.35-.957.121-.337.25-.669.354-1.013.097-.32.168-.646.249-.969.089-.351.187-.698.258-1.055.074-.375.118-.753.173-1.13.044-.311.104-.617.135-.933a22%2022%200%200%200%20.106-2.107V384c0-11.782-9.551-21.333-21.333-21.333s-21.333%209.551-21.333%2021.333v55.163L335.085%20304.915c-8.331-8.331-21.839-8.331-30.17%200s-8.331%2021.839%200%2030.17l134.248%20134.248H384c-11.782%200-21.333%209.551-21.333%2021.333S372.218%20512%20384%20512h106.667q1.055-.001%202.105-.106c.315-.031.621-.09.933-.135.377-.054.756-.098%201.13-.173.358-.071.704-.169%201.055-.258.324-.081.649-.152.969-.249.344-.104.677-.233%201.013-.354.32-.115.642-.22.957-.35s.615-.283.921-.428c.325-.153.653-.297.971-.468.293-.157.572-.336.857-.506.312-.186.628-.363.932-.566.315-.211.611-.445.913-.671.255-.191.516-.368.764-.571q.804-.659%201.54-1.392l.023-.021.021-.023q.732-.736%201.392-1.54c.205-.248.382-.509.573-.764zM72.837%2042.667H128c11.782%200%2021.333-9.551%2021.333-21.333C149.333%209.551%20139.782%200%20128%200H21.332q-1.054.001-2.104.106c-.316.031-.622.09-.933.135-.377.054-.755.098-1.13.172-.358.071-.705.169-1.056.258-.323.081-.648.152-.968.249-.345.104-.678.234-1.015.355-.319.115-.641.22-.956.35-.315.131-.618.284-.925.43-.323.152-.65.296-.967.466-.295.158-.575.338-.862.509-.31.185-.625.36-.928.563-.317.212-.615.448-.92.676-.252.189-.511.364-.756.566a21.5%2021.5%200%200%200-2.977%202.977c-.202.245-.377.504-.566.757-.228.305-.464.603-.676.92-.203.303-.378.617-.564.928-.171.286-.351.567-.509.862-.17.317-.313.643-.466.967-.145.307-.299.61-.43.925-.13.315-.235.636-.35.956-.121.337-.25.67-.355%201.015-.097.32-.168.645-.249.968-.089.351-.187.698-.258%201.056-.074.375-.118.753-.172%201.13-.044.311-.104.618-.135.933A22%2022%200%200%200%200%2021.333V128c0%2011.782%209.551%2021.333%2021.333%2021.333S42.666%20139.782%2042.666%20128V72.837l134.248%20134.248c8.331%208.331%2021.839%208.331%2030.17%200s8.331-21.839%200-30.17z%22%2F%3E%3C%2Fsvg%3E'); } .expandVODChat .expand-toggle-li, .expandLiveChat .expand-toggle-li { background-image: url('data:image/svg+xml,%3Csvg%20fill%3D%22%23757B8A%22%20height%3D%2264%22%20width%3D%2264%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20512%20512%22%20xml%3Aspace%3D%22preserve%22%20stroke%3D%22%23757B8A%22%3E%3Cg%20stroke-width%3D%220%22%2F%3E%3Cg%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M320.106%20172.772c.031.316.09.622.135.933.054.377.098.755.172%201.13.071.358.169.705.258%201.056.081.323.152.648.249.968.104.345.234.678.355%201.015.115.319.22.641.35.956.131.315.284.618.43.925.152.323.296.65.466.967.158.294.337.574.508.86.186.311.362.626.565.93.211.316.447.613.674.917.19.253.365.513.568.759a21.4%2021.4%200%200%200%202.977%202.977c.246.202.506.378.759.567.304.228.601.463.918.675.303.203.618.379.929.565.286.171.566.351.861.509.317.17.644.314.968.466.307.145.609.298.924.429.315.13.637.236.957.35.337.121.669.25%201.013.354.32.097.646.168.969.249.351.089.698.187%201.055.258.375.074.753.119%201.13.173.311.044.617.104.932.135q1.051.105%202.105.106H448c11.782%200%2021.333-9.551%2021.333-21.333s-9.551-21.333-21.333-21.333h-55.163L505.752%2036.418c8.331-8.331%208.331-21.839%200-30.17s-21.839-8.331-30.17%200L362.667%20119.163V64c0-11.782-9.551-21.333-21.333-21.333C329.551%2042.667%20320%2052.218%20320%2064v106.668q.001%201.053.106%202.104zM170.667%2042.667c-11.782%200-21.333%209.551-21.333%2021.333v55.163L36.418%206.248c-8.331-8.331-21.839-8.331-30.17%200s-8.331%2021.839%200%2030.17l112.915%20112.915H64c-11.782%200-21.333%209.551-21.333%2021.333C42.667%20182.449%2052.218%20192%2064%20192h106.667q1.055-.001%202.105-.106c.316-.031.622-.09.933-.135.377-.054.755-.098%201.13-.172.358-.071.705-.169%201.056-.258.323-.081.648-.152.968-.249.345-.104.678-.234%201.015-.355.319-.115.641-.22.956-.35.315-.131.618-.284.925-.43.323-.152.65-.296.967-.466.295-.158.575-.338.862-.509.311-.185.625-.361.928-.564.317-.212.615-.448.92-.676.252-.189.511-.364.757-.566a21.5%2021.5%200%200%200%202.977-2.977c.202-.246.377-.505.566-.757.228-.305.464-.603.676-.92.203-.303.378-.617.564-.928.171-.286.351-.567.509-.862.17-.317.313-.643.466-.967.145-.307.299-.61.43-.925.13-.315.235-.636.35-.956.121-.337.25-.67.355-1.015.097-.32.168-.645.249-.968.089-.351.187-.698.258-1.056.074-.375.118-.753.172-1.13.044-.311.104-.618.135-.933q.105-1.05.106-2.104V64c-.002-11.782-9.553-21.333-21.335-21.333zm21.227%20296.561c-.031-.316-.09-.622-.135-.933-.054-.377-.098-.755-.172-1.13-.071-.358-.169-.705-.258-1.056-.081-.323-.152-.648-.249-.968-.104-.345-.234-.678-.355-1.015-.115-.319-.22-.641-.35-.956-.131-.315-.284-.618-.43-.925-.152-.323-.296-.65-.466-.967-.158-.295-.338-.575-.509-.862-.185-.311-.361-.625-.564-.928-.212-.317-.448-.615-.676-.92-.189-.252-.364-.511-.566-.757a21.5%2021.5%200%200%200-2.977-2.977c-.246-.202-.505-.377-.757-.566-.305-.228-.603-.464-.92-.676-.303-.203-.617-.378-.928-.564-.286-.171-.567-.351-.862-.509-.317-.17-.643-.313-.967-.466-.307-.145-.61-.299-.925-.43-.315-.13-.636-.235-.956-.35-.337-.121-.67-.25-1.015-.355-.32-.097-.645-.168-.968-.249-.351-.089-.698-.187-1.056-.258-.375-.074-.753-.118-1.13-.172-.311-.044-.618-.104-.933-.135q-1.051-.105-2.105-.106H64c-11.782%200-21.333%209.551-21.333%2021.333S52.218%20362.664%2064%20362.664h55.163L6.248%20475.582c-8.331%208.331-8.331%2021.839%200%2030.17s21.839%208.331%2030.17%200l112.915-112.915V448c0%2011.782%209.551%2021.333%2021.333%2021.333s21.333-9.551%2021.333-21.333V341.332a21%2021%200%200%200-.105-2.104zm200.943%2023.439H448c11.782%200%2021.333-9.551%2021.333-21.333s-9.551-21.333-21.333-21.333H341.333q-1.055.001-2.105.106c-.315.031-.621.09-.932.135-.378.054-.756.098-1.13.173-.358.071-.704.169-1.055.258-.324.081-.649.152-.969.249-.344.104-.677.233-1.013.354-.32.115-.642.22-.957.35-.315.131-.617.284-.924.429-.324.153-.65.296-.968.466-.295.158-.575.338-.861.509-.311.186-.626.362-.929.565-.316.212-.614.447-.918.675-.253.19-.512.365-.759.567a21.4%2021.4%200%200%200-2.977%202.977c-.202.246-.378.506-.568.759-.227.304-.463.601-.674.917-.203.304-.379.619-.565.93-.171.286-.351.566-.508.86-.17.317-.313.643-.466.967-.145.307-.299.61-.43.925-.13.315-.235.636-.35.956-.121.337-.25.67-.355%201.015-.097.32-.168.645-.249.968-.089.351-.187.698-.258%201.056-.074.374-.118.753-.172%201.13-.044.311-.104.618-.135.933q-.105%201.05-.106%202.104V448c0%2011.782%209.551%2021.333%2021.333%2021.333s21.333-9.551%2021.333-21.333v-55.163l112.915%20112.915c8.331%208.331%2021.839%208.331%2030.17%200s8.331-21.839%200-30.17z%22%2F%3E%3C%2Fsvg%3E'); } html[dark="true"] .expand-toggle-li { background-image: url('data:image/svg+xml,%3Csvg%20fill%3D%22%23ACB0B9%22%20height%3D%2264%22%20width%3D%2264%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20512%20512%22%20xml%3Aspace%3D%22preserve%22%3E%3Cg%20stroke-width%3D%220%22%2F%3E%3Cg%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M335.085%20207.085%20469.333%2072.837V128c0%2011.782%209.551%2021.333%2021.333%2021.333S512%20139.782%20512%20128V21.335q-.001-1.055-.106-2.107c-.031-.316-.09-.622-.135-.933-.054-.377-.098-.755-.172-1.13-.071-.358-.169-.705-.258-1.056-.081-.323-.152-.648-.249-.968-.104-.345-.234-.678-.355-1.015-.115-.319-.22-.641-.35-.956s-.284-.616-.428-.923c-.153-.324-.297-.651-.467-.969-.158-.294-.337-.574-.508-.86-.186-.311-.362-.626-.565-.93-.211-.316-.447-.613-.674-.917-.19-.253-.366-.513-.568-.76a22%2022%200%200%200-1.402-1.551l-.011-.012-.011-.01a22%2022%200%200%200-1.552-1.403c-.247-.203-.507-.379-.761-.569-.303-.227-.6-.462-.916-.673-.304-.203-.619-.379-.931-.565-.286-.171-.565-.35-.859-.508-.318-.17-.644-.314-.969-.467-.307-.145-.609-.298-.923-.429-.315-.13-.637-.236-.957-.35-.337-.121-.669-.25-1.013-.354-.32-.097-.646-.168-.969-.249-.351-.089-.698-.187-1.055-.258-.375-.074-.753-.119-1.13-.173-.311-.044-.617-.104-.933-.135A22%2022%200%200%200%20490.667%200H384c-11.782%200-21.333%209.551-21.333%2021.333S372.218%2042.666%20384%2042.666h55.163L304.915%20176.915c-8.331%208.331-8.331%2021.839%200%2030.17s21.839%208.331%2030.17%200m-158.17%2097.83L42.667%20439.163V384c0-11.782-9.551-21.333-21.333-21.333C9.551%20362.667%200%20372.218%200%20384v106.667q.001%201.055.106%202.105c.031.315.09.621.135.933.054.377.098.756.173%201.13.071.358.169.704.258%201.055.081.324.152.649.249.969.104.344.233.677.354%201.013.115.32.22.642.35.957s.284.616.429.923c.153.324.297.651.467.969.158.294.337.573.508.859.186.311.362.627.565.931.211.316.446.612.673.916.19.254.366.514.569.761q.664.811%201.403%201.552l.01.011.012.011q.741.738%201.551%201.402c.247.203.507.379.76.568.304.227.601.463.917.674.303.203.618.379.93.565.286.171.565.35.86.508.318.17.645.314.969.467.307.145.609.298.923.428s.636.235.956.35c.337.121.67.25%201.015.355.32.097.645.168.968.249.351.089.698.187%201.056.258.375.074.753.118%201.13.172.311.044.618.104.933.135q1.05.105%202.104.106H128c11.782%200%2021.333-9.551%2021.333-21.333s-9.551-21.333-21.333-21.333H72.837l134.248-134.248c8.331-8.331%208.331-21.839%200-30.17s-21.839-8.331-30.17%200m330.821%20198.51c.226-.302.461-.598.671-.913.204-.304.38-.62.566-.932.17-.285.349-.564.506-.857.17-.318.315-.646.468-.971.145-.306.297-.607.428-.921.13-.315.236-.637.35-.957.121-.337.25-.669.354-1.013.097-.32.168-.646.249-.969.089-.351.187-.698.258-1.055.074-.375.118-.753.173-1.13.044-.311.104-.617.135-.933a22%2022%200%200%200%20.106-2.107V384c0-11.782-9.551-21.333-21.333-21.333s-21.333%209.551-21.333%2021.333v55.163L335.085%20304.915c-8.331-8.331-21.839-8.331-30.17%200s-8.331%2021.839%200%2030.17l134.248%20134.248H384c-11.782%200-21.333%209.551-21.333%2021.333S372.218%20512%20384%20512h106.667q1.055-.001%202.105-.106c.315-.031.621-.09.933-.135.377-.054.756-.098%201.13-.173.358-.071.704-.169%201.055-.258.324-.081.649-.152.969-.249.344-.104.677-.233%201.013-.354.32-.115.642-.22.957-.35s.615-.283.921-.428c.325-.153.653-.297.971-.468.293-.157.572-.336.857-.506.312-.186.628-.363.932-.566.315-.211.611-.445.913-.671.255-.191.516-.368.764-.571q.804-.659%201.54-1.392l.023-.021.021-.023q.732-.736%201.392-1.54c.205-.248.382-.509.573-.764M72.837%2042.667H128c11.782%200%2021.333-9.551%2021.333-21.333C149.333%209.551%20139.782%200%20128%200H21.332q-1.054.001-2.104.106c-.316.031-.622.09-.933.135-.377.054-.755.098-1.13.172-.358.071-.705.169-1.056.258-.323.081-.648.152-.968.249-.345.104-.678.234-1.015.355-.319.115-.641.22-.956.35-.315.131-.618.284-.925.43-.323.152-.65.296-.967.466-.295.158-.575.338-.862.509-.31.185-.625.36-.928.563-.317.212-.615.448-.92.676-.252.189-.511.364-.756.566a21.5%2021.5%200%200%200-2.977%202.977c-.202.245-.377.504-.566.757-.228.305-.464.603-.676.92-.203.303-.378.617-.564.928-.171.286-.351.567-.509.862-.17.317-.313.643-.466.967-.145.307-.299.61-.43.925-.13.315-.235.636-.35.956-.121.337-.25.67-.355%201.015-.097.32-.168.645-.249.968-.089.351-.187.698-.258%201.056-.074.375-.118.753-.172%201.13-.044.311-.104.618-.135.933A22%2022%200%200%200%200%2021.333V128c0%2011.782%209.551%2021.333%2021.333%2021.333S42.666%20139.782%2042.666%20128V72.837l134.248%20134.248c8.331%208.331%2021.839%208.331%2030.17%200s8.331-21.839%200-30.17z%22%2F%3E%3C%2Fsvg%3E') !important; } html[dark="true"] .expandVODChat .expand-toggle-li, html[dark="true"] .expandLiveChat .expand-toggle-li { background-image: url('data:image/svg+xml,%3Csvg%20fill%3D%22%23ACB0B9%22%20height%3D%2264%22%20width%3D%2264%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20512%20512%22%20xml%3Aspace%3D%22preserve%22%20stroke%3D%22%23ACB0B9%22%3E%3Cg%20stroke-width%3D%220%22%2F%3E%3Cg%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke%3D%22%23CCC%22%20stroke-width%3D%222.048%22%2F%3E%3Cpath%20d%3D%22M320.106%20172.772c.031.316.09.622.135.933.054.377.098.755.172%201.13.071.358.169.705.258%201.056.081.323.152.648.249.968.104.345.234.678.355%201.015.115.319.22.641.35.956.131.315.284.618.43.925.152.323.296.65.466.967.158.294.337.574.508.86.186.311.362.626.565.93.211.316.447.613.674.917.19.253.365.513.568.759a21.4%2021.4%200%200%200%202.977%202.977c.246.202.506.378.759.567.304.228.601.463.918.675.303.203.618.379.929.565.286.171.566.351.861.509.317.17.644.314.968.466.307.145.609.298.924.429.315.13.637.236.957.35.337.121.669.25%201.013.354.32.097.646.168.969.249.351.089.698.187%201.055.258.375.074.753.119%201.13.173.311.044.617.104.932.135q1.051.105%202.105.106H448c11.782%200%2021.333-9.551%2021.333-21.333s-9.551-21.333-21.333-21.333h-55.163L505.752%2036.418c8.331-8.331%208.331-21.839%200-30.17s-21.839-8.331-30.17%200L362.667%20119.163V64c0-11.782-9.551-21.333-21.333-21.333C329.551%2042.667%20320%2052.218%20320%2064v106.668q.001%201.053.106%202.104zM170.667%2042.667c-11.782%200-21.333%209.551-21.333%2021.333v55.163L36.418%206.248c-8.331-8.331-21.839-8.331-30.17%200s-8.331%2021.839%200%2030.17l112.915%20112.915H64c-11.782%200-21.333%209.551-21.333%2021.333C42.667%20182.449%2052.218%20192%2064%20192h106.667q1.055-.001%202.105-.106c.316-.031.622-.09.933-.135.377-.054.755-.098%201.13-.172.358-.071.705-.169%201.056-.258.323-.081.648-.152.968-.249.345-.104.678-.234%201.015-.355.319-.115.641-.22.956-.35.315-.131.618-.284.925-.43.323-.152.65-.296.967-.466.295-.158.575-.338.862-.509.311-.185.625-.361.928-.564.317-.212.615-.448.92-.676.252-.189.511-.364.757-.566a21.5%2021.5%200%200%200%202.977-2.977c.202-.246.377-.505.566-.757.228-.305.464-.603.676-.92.203-.303.378-.617.564-.928.171-.286.351-.567.509-.862.17-.317.313-.643.466-.967.145-.307.299-.61.43-.925.13-.315.235-.636.35-.956.121-.337.25-.67.355-1.015.097-.32.168-.645.249-.968.089-.351.187-.698.258-1.056.074-.375.118-.753.172-1.13.044-.311.104-.618.135-.933q.105-1.05.106-2.104V64c-.002-11.782-9.553-21.333-21.335-21.333zm21.227%20296.561c-.031-.316-.09-.622-.135-.933-.054-.377-.098-.755-.172-1.13-.071-.358-.169-.705-.258-1.056-.081-.323-.152-.648-.249-.968-.104-.345-.234-.678-.355-1.015-.115-.319-.22-.641-.35-.956-.131-.315-.284-.618-.43-.925-.152-.323-.296-.65-.466-.967-.158-.295-.338-.575-.509-.862-.185-.311-.361-.625-.564-.928-.212-.317-.448-.615-.676-.92-.189-.252-.364-.511-.566-.757a21.5%2021.5%200%200%200-2.977-2.977c-.246-.202-.505-.377-.757-.566-.305-.228-.603-.464-.92-.676-.303-.203-.617-.378-.928-.564-.286-.171-.567-.351-.862-.509-.317-.17-.643-.313-.967-.466-.307-.145-.61-.299-.925-.43-.315-.13-.636-.235-.956-.35-.337-.121-.67-.25-1.015-.355-.32-.097-.645-.168-.968-.249-.351-.089-.698-.187-1.056-.258-.375-.074-.753-.118-1.13-.172-.311-.044-.618-.104-.933-.135q-1.051-.105-2.105-.106H64c-11.782%200-21.333%209.551-21.333%2021.333S52.218%20362.664%2064%20362.664h55.163L6.248%20475.582c-8.331%208.331-8.331%2021.839%200%2030.17s21.839%208.331%2030.17%200l112.915-112.915V448c0%2011.782%209.551%2021.333%2021.333%2021.333s21.333-9.551%2021.333-21.333V341.332a21%2021%200%200%200-.105-2.104zm200.943%2023.439H448c11.782%200%2021.333-9.551%2021.333-21.333s-9.551-21.333-21.333-21.333H341.333q-1.055.001-2.105.106c-.315.031-.621.09-.932.135-.378.054-.756.098-1.13.173-.358.071-.704.169-1.055.258-.324.081-.649.152-.969.249-.344.104-.677.233-1.013.354-.32.115-.642.22-.957.35-.315.131-.617.284-.924.429-.324.153-.65.296-.968.466-.295.158-.575.338-.861.509-.311.186-.626.362-.929.565-.316.212-.614.447-.918.675-.253.19-.512.365-.759.567a21.4%2021.4%200%200%200-2.977%202.977c-.202.246-.378.506-.568.759-.227.304-.463.601-.674.917-.203.304-.379.619-.565.93-.171.286-.351.566-.508.86-.17.317-.313.643-.466.967-.145.307-.299.61-.43.925-.13.315-.235.636-.35.956-.121.337-.25.67-.355%201.015-.097.32-.168.645-.249.968-.089.351-.187.698-.258%201.056-.074.374-.118.753-.172%201.13-.044.311-.104.618-.135.933q-.105%201.05-.106%202.104V448c0%2011.782%209.551%2021.333%2021.333%2021.333s21.333-9.551%2021.333-21.333v-55.163l112.915%20112.915c8.331%208.331%2021.839%208.331%2030.17%200s8.331-21.839%200-30.17z%22%2F%3E%3C%2Fsvg%3E') !important; } .screen_mode .expand-toggle-li, .fullScreen_mode .expand-toggle-li { display: none !important; } .customSidebar #serviceLnb { display: none !important; } .left_navbar { display: flex; align-items: center; justify-content: flex-end; position: fixed; flex-direction: row-reverse; top: 0px; left: 128px; z-index: 9999; background-color: white; } html[dark="true"] .left_navbar { background-color: #0c0d0e; } html[dark="true"] .left_nav_button { color: #e5e5e5; } html:not([dark="true"]) .left_nav_button { color: #1F1F23; } html[dark="true"] .left_nav_button { color: #e5e5e5; } html:not([dark="true"]) .left_nav_button { color: #1F1F23; } .left_navbar button.left_nav_button { position: relative; width: 68px; height: 64px; padding: 0; border: 0; cursor: pointer; z-index: 3001; font-size: 1.25em !important; font-weight: 600; } @media (max-width: 1280px) { #serviceHeader .left_navbar { left: 124px !important; } #serviceHeader .left_nav_button { width: 58px !important; font-size: 1.2em !important; } } @media (max-width: 1100px) { #serviceHeader .left_navbar { left: 120px !important; } #serviceHeader .left_nav_button { width: 46px !important; font-size: 1.1em !important; } } #sidebar { top: 64px; display: flex !important; flex-direction: column !important; } #sidebar .top-section.follow { order: 1; } #sidebar .users-section.follow { order: 2; } #sidebar .top-section.myplus { order: 3; } #sidebar .users-section.myplus { order: 4; } #sidebar .top-section.myplusvod { order: 5; } #sidebar .users-section.myplusvod { order: 6; } #sidebar .top-section.top { order: 7; } #sidebar .users-section.top { order: 8; } .starting-line .chatting-list-item .message-container .username { width: ${nicknameWidth}px !important; } .duration-overlay { position: absolute; top: 235px; right: 4px; background-color: rgba(0, 0, 0, 0.7); color: white; padding: 2px 5px; font-size: 15px; border-radius: 3px; z-index:9999; line-height: 17px; } #studioPlayKorPlayer, #studioPlayKor, #studioPlay, .btn-broadcast { display: none; } #myModal.modal { display: none; position: fixed; z-index: 9999; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.4); color: black; } #myModal .modal-content { background-color: #fefefe; margin: 15% auto; padding: 20px; border: 1px solid #888; border-radius: 10px; width: clamp(400px, 80%, 550px); } #myModal .modal-content .myModalClose { color: #aaa; float: right; font-size: 36px; font-weight: bold; margin-top: -12px; } #myModal .modal-content .myModalClose:hover, #myModal .modal-content .myModalClose:focus { color: black; text-decoration: none; cursor: pointer; } #myModal .modal-content .option { margin-bottom: 10px; display: flex; align-items: center; } #myModal .modal-content .option label { margin-right: 10px; font-size: 15px; } #myModal .modal-content .switch { position: relative; display: inline-block; width: 60px; height: 34px; transform: scale(0.9); /* 축소 */ } #myModal .modal-content .switch input { display: none; } #myModal .modal-content .slider_95642 { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 34px; } #myModal .modal-content .slider_95642:before { position: absolute; content: ""; height: 26px; width: 26px; left: 4px; bottom: 4px; background-color: white; transition: .4s; border-radius: 50%; } #myModal .modal-content .slider_95642.round { border-radius: 34px; min-width: 60px; } #myModal .modal-content .slider_95642.round:before { border-radius: 50%; } #myModal .modal-content input:checked + .slider_95642 { background-color: #2196F3; } #myModal .modal-content input:focus + .slider_95642 { box-shadow: 0 0 1px #2196F3; } #myModal .modal-content input:checked + .slider_95642:before { transform: translateX(26px); } #myModal .modal-content #range { width: 100%; } #myModal .modal-content #rangeValue { display: inline-block; margin-left: 10px; } #myModal .modal-content .divider { width: 100%; /* 가로 폭 설정 */ height: 1px; /* 세로 높이 설정 */ background-color: #000; /* 배경색 설정 */ margin: 20px 0; /* 위아래 여백 설정 */ } #openModalBtn { box-sizing: border-box; font-size: 12px; line-height: 1.2 !important; font-family: "NG"; list-style: none; position: relative; margin-left: 12px; width: 40px; height: 40px; } #topInnerHeader #openModalBtn { margin-right: 12px; } #openModalBtn > button.btn-settings-ui { background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' fill='none'%3e%3cpath stroke='%23757B8A' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.4' d='M8.269 2.061c.44-1.815 3.022-1.815 3.462 0a1.782 1.782 0 0 0 2.658 1.101c1.595-.971 3.42.854 2.449 2.449a1.781 1.781 0 0 0 1.1 2.658c1.816.44 1.816 3.022 0 3.462a1.781 1.781 0 0 0-1.1 2.659c.971 1.595-.854 3.42-2.449 2.448a1.781 1.781 0 0 0-2.658 1.101c-.44 1.815-3.022 1.815-3.462 0a1.781 1.781 0 0 0-2.658-1.101c-1.595.972-3.42-.854-2.449-2.448a1.782 1.782 0 0 0-1.1-2.659c-1.816-.44-1.816-3.021 0-3.462a1.782 1.782 0 0 0 1.1-2.658c-.972-1.595.854-3.42 2.449-2.449a1.781 1.781 0 0 0 2.658-1.1Z'/%3e%3cpath stroke='%23757B8A' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.4' d='M13.1 10a3.1 3.1 0 1 1-6.2 0 3.1 3.1 0 0 1 6.2 0Z'/%3e%3c/svg%3e") 50% 50% no-repeat !important; background-size: 18px !important; } html[dark="true"] #openModalBtn > button.btn-settings-ui { background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' fill='none'%3e%3cpath stroke='%23ACB0B9' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.4' d='M8.269 2.061c.44-1.815 3.022-1.815 3.462 0a1.782 1.782 0 0 0 2.658 1.101c1.595-.971 3.42.854 2.449 2.449a1.781 1.781 0 0 0 1.1 2.658c1.816.44 1.816 3.022 0 3.462a1.781 1.781 0 0 0-1.1 2.659c.971 1.595-.854 3.42-2.449 2.448a1.781 1.781 0 0 0-2.658 1.101c-.44 1.815-3.022 1.815-3.462 0a1.781 1.781 0 0 0-2.658-1.101c-1.595.972-3.42-.854-2.449-2.448a1.782 1.782 0 0 0-1.1-2.659c-1.816-.44-1.816-3.021 0-3.462a1.782 1.782 0 0 0 1.1-2.658c-.972-1.595.854-3.42 2.449-2.449a1.781 1.781 0 0 0 2.658-1.1Z'/%3e%3cpath stroke='%23ACB0B9' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.4' d='M13.1 10a3.1 3.1 0 1 1-6.2 0 3.1 3.1 0 0 1 6.2 0Z'/%3e%3c/svg%3e") 50% 50% no-repeat !important; background-size: 18px !important; } @keyframes rotate { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* .red-dot이 있을 때만 회전 */ #openModalBtn:has(.red-dot) .btn-settings-ui { animation: rotate 4s linear infinite; animation-duration: 4s; /* 4초에 한 번 회전 */ animation-iteration-count: 10; /* 10번 반복 */ } #sidebar.max { width: 240px; } #sidebar.min { width: 52px; } #sidebar.min .users-section a.user span { display: none; } #sidebar.min .users-section button { font-size:12px; padding: 4px; } #sidebar.max .button-fold-sidebar { background-size: 7px 11px; background-repeat: no-repeat; width: 26px; height: 26px; background-position: center; position: absolute; top: 13px; left: 200px; } #sidebar.max .button-unfold-sidebar { display:none; } #sidebar.min .button-fold-sidebar { display:none; } #sidebar.min .button-unfold-sidebar { background-size: 7px 11px; background-repeat: no-repeat; width: 26px; height: 26px; background-position: center; position: relative; top: 8px; left: 12px; padding-top:16px; padding-bottom:12px; } #sidebar.min .top-section span.max{ display:none; } #sidebar.max .top-section span.min{ display:none; } #toggleButton, #toggleButton2, #toggleButton3, #toggleButton4 { padding: 7px 0px; width: 100%; text-align: center; font-size: 14px; } html[dark="true"] #toggleButton, html[dark="true"] #toggleButton2, html[dark="true"] #toggleButton3, html[dark="true"] #toggleButton4 { color:#A1A1A1; } html:not([dark="true"]) #toggleButton, html:not([dark="true"]) #toggleButton2, html:not([dark="true"]) #toggleButton3, html:not([dark="true"]) #toggleButton4 { color: #53535F; } #sidebar { grid-area: sidebar; padding-bottom: 360px; height: 100vh; overflow-y: auto; position: fixed; scrollbar-width: none; /* 파이어폭스 */ transition: all 0.1s ease-in-out; /* 부드러운 전환 효과 */ } #sidebar::-webkit-scrollbar { display: none; /* Chrome, Safari, Edge */ } #sidebar .top-section { display: flex; align-items: center; justify-content: space-around; margin: 12px 0px 6px 0px; line-height: 17px; } #sidebar .top-section > span { text-transform: uppercase; font-weight: 550; font-size: 14px; margin-top: 6px; margin-bottom: 2px; } .users-section .user.show-more { max-height: 0; opacity: 0; padding-top: 0; padding-bottom: 0; pointer-events: none; } .users-section .user { display: grid; grid-template-areas: "profile-picture username watchers" "profile-picture description blank"; grid-template-columns: 40px auto auto; padding: 5px 10px; max-height: 50px; opacity: 1; overflow: hidden; transition: opacity 0.7s ease; } .users-section .user:hover { cursor: pointer; } .users-section .user .profile-picture { grid-area: profile-picture; width: 30px; height: 30px; border-radius: 50%; line-height: 20px; } .users-section .user .username { grid-area: username; font-size: 14px; font-weight: 600; letter-spacing: 0.6px; margin-left:1px; line-height: 17px; } .users-section .user .description { grid-area: description; font-size: 13px; font-weight: 400; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-left:1px; line-height: 16px; } .users-section .user .watchers { grid-area: watchers; display: flex; align-items: center; justify-content: flex-end; font-weight: 400; font-size: 14px; margin-right: 2px; line-height: 17px; } .users-section .user .watchers .dot { font-size: 10px; margin-right: 5px; color: #ff2424; } .users-section .user .watchers .dot.greendot { color: #34c76b !important; } .tooltip-container { z-index: 999; width: 460px; height: auto; position: fixed; display: flex; flex-direction: column; align-items: center; border-radius: 10px; box-shadow: 5px 5px 10px 0px rgba(0, 0, 0, 0.5); opacity: 0; transition: opacity 0.1s ease-in-out; pointer-events: none; } .tooltip-container.visible { opacity: 1; pointer-events: auto; } .tooltip-container img { z-index: 999; width: 100%; /* 컨테이너의 너비에 맞게 확장 */ height: 260px; /* 고정 높이 */ object-fit: cover; /* 비율 유지하며 공간에 맞게 잘리기 */ border-top-left-radius: 10px; border-top-right-radius: 10px; border-bottom-left-radius: 0px; border-bottom-right-radius: 0px; } .tooltiptext { position: relative; z-index: 999; width: 100%; max-width: 460px; height: auto; text-align: center; box-sizing: border-box; padding: 14px 20px; font-size: 17px; border-top-left-radius: 0; border-top-right-radius: 0; border-bottom-left-radius: 10px; border-bottom-right-radius: 10px; line-height: 22px; overflow-wrap: break-word; } .tooltiptext .dot { font-size: 11px; margin-right: 2px; vertical-align: middle; line-height: 22px; display: inline-block; } .profile-grayscale { filter: grayscale(100%) contrast(85%); opacity: .8; } #sidebar.max .small-user-layout.show-more { max-height: 0; opacity: 0; padding: 0 !important; pointer-events: none; } #sidebar.max .small-user-layout { grid-template-areas: "profile-picture username description watchers" !important; grid-template-columns: 24px auto 1fr auto !important; padding: 4px 10px !important; gap: 8px !important; max-height: 32px; opacity: 1; overflow: hidden; transition: opacity 0.4s ease; } #sidebar.max .small-user-layout .profile-picture { width: 24px !important; height: 24px !important; border-radius: 20% !important; } #sidebar.max .small-user-layout .username { max-width: 80px !important; font-size: 14px !important; line-height: 24px !important; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } #sidebar.max .small-user-layout .description { font-size: 12px !important; line-height: 24px !important; } #sidebar.max .small-user-layout .watchers { font-size: 14px !important; line-height: 24px !important; } #sidebar.max .small-user-layout .watchers .dot { font-size: 8px !important; margin-right: 4px !important; } .customSidebar #serviceHeader .a_d_banner { display: none !important; } .customSidebar #serviceHeader .btn_flexible+.logo_wrap { left: 24px !important; } .customSidebar #serviceHeader .logo_wrap { left: 24px !important; } html[dark="true"] .users-section .user.user-offline span { filter: grayscale(1) brightness(0.8); /* 다크모드: 완전 흑백과 약간 어둡게 */ } html:not([dark="true"]) .users-section .user.user-offline span { opacity: 0.7; /* 밝은 모드: 투명하게 */ } /* darkMode Sidebar Styles */ html[dark="true"] #sidebar.max .button-fold-sidebar { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='none slice' viewBox='0 0 7 11'%3e%3cpath fill='%23f9f9f9' d='M5.87 11.01L.01 5.51 5.87.01l1.08 1.01-4.74 4.45L7 9.96 5.87 11z'/%3e%3c/svg%3e"); } html[dark="true"] #sidebar.min .button-unfold-sidebar { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='none slice' viewBox='0 0 7 11'%3e%3cpath fill='%23f9f9f9' d='M1.13 11.01l5.86-5.5L1.13.01.05 1.02l4.74 4.45L0 9.96 1.13 11z'/%3e%3c/svg%3e"); } html[dark="true"] #sidebar { color: white; background-color: #1F1F23; } html[dark="true"] #sidebar .top-section > span { color:#DEDEE3; } html[dark="true"] #sidebar .top-section > span > a { color:#DEDEE3; } html[dark="true"] .users-section .user:hover { background-color: #26262c; } html[dark="true"] .users-section .user .username { color:#DEDEE3; } html[dark="true"] .users-section .user .description { color: #a1a1a1; } html[dark="true"] .users-section .user .watchers { color: #c0c0c0; } html[dark="true"] .tooltip-container { background-color: #26262C; } html[dark="true"] .tooltiptext { color: #fff; background-color: #26262C; } /* whiteMode Sidebar Styles */ html:not([dark="true"]) #sidebar.max .button-fold-sidebar { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='none slice' viewBox='0 0 7 11'%3e%3cpath fill='%23888' d='M5.87 11.01L.01 5.51 5.87.01l1.08 1.01-4.74 4.45L7 9.96 5.87 11z'/%3e%3c/svg%3e"); } html:not([dark="true"]) #sidebar.min .button-unfold-sidebar { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='none slice' viewBox='0 0 7 11'%3e%3cpath fill='%23888' d='M1.13 11.01l5.86-5.5L1.13.01.05 1.02l4.74 4.45L0 9.96 1.13 11z'/%3e%3c/svg%3e"); } html:not([dark="true"]) #sidebar { color: white; background-color: #EFEFF1; } html:not([dark="true"]) #sidebar .top-section > span { color:#0E0E10; } html:not([dark="true"]) #sidebar .top-section > span > a { color:#0E0E10; } html:not([dark="true"]) .users-section .user:hover { background-color: #E6E6EA; } html:not([dark="true"]) .users-section .user .username { color:#1F1F23; } html:not([dark="true"]) .users-section .user .description { color: #53535F; } html:not([dark="true"]) .users-section .user .watchers { color: black; } html:not([dark="true"]) .tooltip-container { background-color: #E6E6EA; } html:not([dark="true"]) .tooltiptext { color: black; background-color: #E6E6EA; } `; const mainPageCommonStyles = ` ._moreDot_layer button { text-align: left; } .customSidebar .btn_flexible { display: none; } #sidebar { z-index: 1401; } body.customSidebar main { padding-left: 238px !important; } body.customSidebar .catch_webplayer_wrap { margin-left: 24px !important; } `; const playerCommonStyles = ` .screen_mode .left_navbar, .fullScreen_mode .left_navbar { display: none; } .customSidebar .btn_flexible { display: none; } /* 스크롤바 스타일링 */ html { overflow: auto; /* 스크롤 기능 유지 */ } /* Firefox 전용 스크롤바 감추기 */ html::-webkit-scrollbar { display: none; /* 크롬 및 사파리에서 */ } /* Firefox에서는 아래와 같이 처리 */ html { scrollbar-width: none; /* Firefox에서 스크롤바 감추기 */ -ms-overflow-style: none; /* Internet Explorer 및 Edge */ } .customSidebar #player, .customSidebar #webplayer #webplayer_contents #player_area .float_box, .customSidebar #webplayer #webplayer_contents #player_area { min-width: 180px !important; } .customSidebar.screen_mode #webplayer, .customSidebar.screen_mode #sidebar { transition: all 0.25s ease-in-out !important; } @media screen and (max-width: 892px) { .screen_mode.bottomChat #webplayer #player .view_ctrl, .screen_mode.bottomChat #webplayer .wrapping.side { display: block !important; } } .customSidebar #webplayer_contents { width: calc(100vw - ${WEB_PLAYER_SCROLL_LEFT}px) !important; gap:0 !important; padding: 0 !important; margin: 64px 0 0 !important; left: ${WEB_PLAYER_SCROLL_LEFT}px !important; } .customSidebar.top_hide #webplayer_contents, .customSidebar.top_hide #sidebar { top: 0 !important; margin-top: 0 !important; min-height: 100vh !important; } /* sidebar가 .max 클래스를 가질 때, body에 .screen_mode가 없을 경우 */ body:not(.screen_mode):not(.fullScreen_mode):has(#sidebar.max) #webplayer_contents { width: calc(100vw - 240px) !important; left: 240px !important; } /* sidebar가 .min 클래스를 가질 때, body에 .screen_mode가 없을 경우 */ body:not(.screen_mode):not(.fullScreen_mode):has(#sidebar.min) #webplayer_contents { width: calc(100vw - 52px) !important; left: 52px !important; } .customSidebar.screen_mode #webplayer #webplayer_contents, .customSidebar.fullScreen_mode #webplayer #webplayer_contents { top: 0 !important; left: 0 !important; width: 100vw; height: 100vh !important; margin: 0 !important; } .customSidebar.screen_mode #sidebar{ top: 0 !important; } .customSidebar.screen_mode #sidebar .button-fold-sidebar, .customSidebar.screen_mode #sidebar .button-unfold-sidebar { display: none !important; } .customSidebar.screen_mode.showSidebar #sidebar{ display: flex !important; } .customSidebar.screen_mode #webplayer_contents, .customSidebar.fullScreen_mode #webplayer_contents{ width: 100vw !important } .customSidebar.screen_mode.showSidebar:has(#sidebar.min) #webplayer_contents { width: calc(100vw - 52px) !important } .customSidebar.screen_mode.showSidebar:has(#sidebar.max) #webplayer_contents { width: calc(100vw - 240px) !important } .screen_mode.bottomChat #webplayer #webplayer_contents { top: 0 !important; margin: 0 !important; } .screen_mode.bottomChat #player { min-height: auto !important; } .screen_mode.bottomChat #webplayer #webplayer_contents { position: relative; box-sizing: border-box; flex: auto; display: flex; flex-direction: column !important; justify-content:flex-start !important; } .screen_mode.bottomChat #webplayer #webplayer_contents .wrapping.side { width: 100% !important; max-height: calc(100vh - (100vw * 9 / 16)) !important; } .screen_mode.bottomChat.showSidebar:has(#sidebar.min) #webplayer #webplayer_contents .wrapping.side { width: 100% !important; max-height: calc(100vh - ((100vw - 52px) * 9 / 16)) !important; } .screen_mode.bottomChat.showSidebar:has(#sidebar.max) #webplayer #webplayer_contents .wrapping.side { width: 100% !important; max-height: calc(100vh - ((100vw - 240px) * 9 / 16)) !important; } .screen_mode.bottomChat #webplayer #webplayer_contents .wrapping.side section.box.chatting_box { height: 100% !important; } .screen_mode.bottomChat #webplayer #webplayer_contents .wrapping.side section.box.chatting_box #chatting_area { height: 100% !important; min-height: 10vh !important; } .screen_mode.bottomChat #webplayer #webplayer_contents #player_area .htmlplayer_wrap, .screen_mode.bottomChat #webplayer #webplayer_contents #player_area .htmlplayer_content, .screen_mode.bottomChat #webplayer #webplayer_contents #player_area .float_box, .screen_mode.bottomChat #webplayer #webplayer_contents #player_area #player { height: auto !important; max-height: max-content; } .customSidebar #player { max-height: 100vh !important; } `; //====================================== // 3. 함수 정의 (Function Definitions) //====================================== // 3.1. API 및 데이터 호출 함수 (API & Data Fetching) const getHiddenbjList = async () => { const url = "https://live.sooplive.co.kr/api/hiddenbj/hiddenbjController.php"; const response = await fetchBroadList(url, 3000); if (response?.RESULT === 1) { return response.DATA || []; } else { return []; } }; const getStationFeed = async () => { // 채널 피드가 비활성화된 경우 빈 배열을 반환합니다. if (!isChannelFeedEnabled) { return []; } const feedUrl = "https://myapi.sooplive.co.kr/api/feed?index_reg_date=0&user_id=&is_bj_write=1&feed_type=&page=1"; const response = await fetchBroadList(feedUrl, 5000); return response?.data || []; }; const loadCategoryData = () => { // 현재 시간 기록 const currentTime = new Date().getTime(); // 이전 실행 시간 불러오기 const lastExecutionTime = GM_getValue("lastExecutionTime", 0); // 마지막 실행 시간으로부터 15분 이상 경과했는지 확인 if (currentTime - lastExecutionTime >= 900000) { // URL에 현재 시간을 쿼리 스트링으로 추가해서 캐시 방지 const url = "https://live.sooplive.co.kr/script/locale/ko_KR/broad_category.js?" + currentTime; GM_xmlhttpRequest({ method: "GET", url: url, headers: { "Content-Type": "text/plain; charset=utf-8" }, onload: function(response) { if (response.status === 200) { // 성공적으로 데이터를 받았을 때 처리할 코드 작성 let szBroadCategory = response.responseText; console.log(szBroadCategory); // 이후 처리할 작업 추가 szBroadCategory = JSON.parse(szBroadCategory.split('var szBroadCategory = ')[1].slice(0, -1)); if (szBroadCategory.CHANNEL.RESULT === "1") { // 데이터 저장 GM_setValue("szBroadCategory", szBroadCategory); // 현재 시간을 마지막 실행 시간으로 업데이트 GM_setValue("lastExecutionTime", currentTime); } } else { console.error("Failed to load data:", response.statusText); } }, onerror: function(error) { console.error("Error occurred while loading data:", error); } }); } }; const fetchBroadList = async (url, timeout) => { const CACHE_EXPIRY_MS = 45 * 1000; // 45초 const cacheKey = `fetchCache_${encodeURIComponent(url)}`; // 1. LocalStorage 확인 const cached = localStorage.getItem(cacheKey); if (cached) { try { const { timestamp, data } = JSON.parse(cached); if (Date.now() - timestamp < CACHE_EXPIRY_MS) { return data; } } catch (e) { console.warn(url, 'Cache parse error in LocalStorage, ignoring.'); } } // 2. GM 저장소 확인 const gmCached = await GM_getValue(cacheKey, null); if (gmCached) { try { const { timestamp, data } = JSON.parse(gmCached); if (Date.now() - timestamp < CACHE_EXPIRY_MS) { // LocalStorage에도 저장해두기 (빠른 재사용을 위해) localStorage.setItem(cacheKey, gmCached); return data; } } catch (e) { console.warn(url, 'Cache parse error in GM storage, ignoring.'); } } // 3. 요청 수행 return new Promise((resolve) => { let timeoutId; if (timeout) { timeoutId = setTimeout(() => { console.error(url, `Request timed out after ${timeout} ms`); resolve([]); }, timeout); } GM_xmlhttpRequest({ method: 'GET', url: url, headers: { 'Content-Type': 'application/json' }, onload: async (response) => { if (timeoutId) clearTimeout(timeoutId); try { if (response.status >= 200 && response.status < 300) { const jsonResponse = JSON.parse(response.responseText); // 에러 응답 처리 if (jsonResponse?.RESULT === -1 || (jsonResponse?.code && jsonResponse.code < 0)) { console.error(url, `API Error (Login Required or other): ${jsonResponse.MSG || jsonResponse.message}`); localStorage.removeItem(cacheKey); await GM_setValue(cacheKey, ""); // GM 저장소도 삭제 (빈 문자열) resolve([]); } else { const cacheData = JSON.stringify({ timestamp: Date.now(), data: jsonResponse }); // LocalStorage + GM 저장소에 저장 localStorage.setItem(cacheKey, cacheData); await GM_setValue(cacheKey, cacheData); resolve(jsonResponse); } } else if (response.status === 401) { console.error(url, "Unauthorized: 401 error - possibly invalid credentials"); resolve([]); } else { console.error(url, `Error: ${response.status}`); resolve([]); } } catch (error) { console.error(url, "Parsing error: ", error); resolve([]); } }, onerror: (error) => { if (timeoutId) clearTimeout(timeoutId); console.error(url, "Request error: " + error.message); resolve([]); } }); }); }; const getBroadAid2 = async (id, broadNumber, quality = 'original') => { const basePayload = { bid: id, bno: broadNumber, from_api: '0', mode: 'landing', player_type: 'html5', stream_type: 'common', quality: quality }; // AID 요청 함수 const requestAid = async (password = '') => { const payload = { ...basePayload, type: 'aid', pwd: password }; const options = { method: 'POST', body: new URLSearchParams(payload), credentials: 'include', cache: 'no-store' }; const res = await fetch('https://live.sooplive.co.kr/afreeca/player_live_api.php', options); return await res.json(); }; // LIVE 요청 함수 const requestLive = async () => { const payload = { ...basePayload, type: 'live', pwd: '' }; const options = { method: 'POST', body: new URLSearchParams(payload), credentials: 'include', cache: 'no-store' }; const res = await fetch('https://live.sooplive.co.kr/afreeca/player_live_api.php', options); return await res.json(); }; try { // 1차: 비밀번호 없이 AID 요청 const result1 = await requestAid(''); if (result1?.CHANNEL?.AID) { console.log(result1.CHANNEL.AID); return result1.CHANNEL.AID; } // 2차: LIVE 요청으로 BPWD 확인 const result2 = await requestLive(); if (result2?.CHANNEL?.BPWD === 'Y') { const password = prompt('비밀번호를 입력하세요:'); if (password === null) return null; // 3차: 입력된 비밀번호로 다시 AID 요청 const retryResult = await requestAid(password); if (retryResult?.CHANNEL?.AID) { console.log(result1.CHANNEL.AID); return retryResult.CHANNEL.AID; } else { alert('비밀번호가 틀렸거나 종료된 방송입니다.'); } } return null; } catch (error) { console.log('오류 발생:', error); return null; } }; unsafeWindow.getBroadAid2 = getBroadAid2; const getLatestFrameData = async (id, broadNumber) => { const videoElement = document.createElement('video'); videoElement.playbackRate = 16; // 빠른 재생 속도 설정 const aid = await getBroadAid2(id, broadNumber, 'sd'); const m3u8url = `https://live-global-cdn-v02.sooplive.co.kr/live-stm-12/auth_playlist.m3u8?aid=${aid}` if (unsafeWindow.Hls.isSupported()) { const hls = new unsafeWindow.Hls(); hls.loadSource(m3u8url); hls.attachMedia(videoElement); return new Promise((resolve) => { videoElement.addEventListener('canplay', async () => { const frameData = await captureLatestFrame(videoElement); resolve(frameData); videoElement.pause(); videoElement.src = ''; }); }); } else { console.error('HLS.js를 지원하지 않는 브라우저입니다.'); return null; } }; // 3.2. 핵심 유틸리티 함수 (Core Utility Functions) function waitForVariable(varName, timeout = 20000) { return new Promise((resolve, reject) => { let e = 0; const t = setInterval(() => { unsafeWindow[varName] ? (clearInterval(t), resolve(unsafeWindow[varName])) : (e += 200, e >= timeout && (clearInterval(t), reject(new Error(`'${varName}' 변수를 찾지 못했습니다.`)))) }, 200) }) } const loadHlsScript = () => { // hls.js 동적 로드 const hlsScript = document.createElement('script'); hlsScript.src = 'https://cdn.jsdelivr.net/npm/hls.js@latest'; hlsScript.onload = function() { console.log('hls.js가 성공적으로 로드되었습니다.'); }; hlsScript.onerror = function() { console.error('hls.js 로드 중 오류가 발생했습니다.'); }; document.head.appendChild(hlsScript); }; const applyFontStyles = () => { const style = document.createElement('style'); style.textContent = ` @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap'); * { font-family: 'Inter' !important; } `; document.head.appendChild(style); }; const checkIfTimeover = (timestamp) => { const now = Date.now(); const inputTime = timestamp * 1000; // 초 단위 타임스탬프를 밀리초로 변환 // 24시간(1일) = 86400000 밀리초 return (now - inputTime) > 86400000; }; const timeSince = (serverTimeStr) => { // 입력 문자열 → ISO 8601 + KST 오프셋으로 변환 const toKSTDate = (str) => { const iso = str.replace(' ', 'T') + '+09:00'; return new Date(iso); }; const postTime = toKSTDate(serverTimeStr).getTime(); // 게시물 작성 시각 (KST) const now = Date.now(); // 현재 시각 (밀리초 기준, UTC) const seconds = Math.floor((now - postTime) / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 365) return `${Math.floor(days / 365)}년 전`; if (days > 30) return `${Math.floor(days / 30)}개월 전`; if (days > 0) return `${days}일 전`; if (hours > 0) return `${hours}시간 전`; if (minutes > 0) return `${minutes}분 전`; return `${seconds}초 전`; }; const waitForElement = (selector, callback, timeout = 10000) => { let observer = null; const timeoutId = setTimeout(() => { if (observer) { observer.disconnect(); console.warn(`[waitForElement] Timeout: '${selector}' 요소를 ${timeout}ms 내에 찾지 못했습니다.`); } }, timeout); const element = document.querySelector(selector); if (element) { clearTimeout(timeoutId); callback(selector, element); return; } observer = new MutationObserver((mutations, obs) => { const targetElement = document.querySelector(selector); if (targetElement) { obs.disconnect(); clearTimeout(timeoutId); callback(selector, targetElement); } }); observer.observe(document.body, { childList: true, subtree: true }); }; const waitForElementAsync = (selector, timeout = 10000) => { return new Promise((resolve, reject) => { // 1. 요소가 이미 존재하는지 즉시 확인 const element = document.querySelector(selector); if (element) { resolve(element); return; } let observer = null; // 2. 타임아웃 설정: 지정된 시간이 지나면 reject 실행 const timeoutId = setTimeout(() => { if (observer) { observer.disconnect(); // new Error 객체를 사용해 더 명확한 에러 스택 추적 가능 reject(new Error(`Timeout: '${selector}' 요소를 ${timeout}ms 내에 찾지 못했습니다.`)); } }, timeout); // 3. MutationObserver 설정: DOM 변경 감지 observer = new MutationObserver((mutations) => { // 변경이 감지되면 요소를 다시 찾아봄 const targetElement = document.querySelector(selector); if (targetElement) { observer.disconnect(); // 관찰 중단 clearTimeout(timeoutId); // 타임아웃 타이머 제거 resolve(targetElement); // Promise 성공 처리 } }); // 4. 관찰 시작 observer.observe(document.body, { childList: true, subtree: true }); }); }; const waitForLivePlayer = (timeout = 10000) => { return new Promise((resolve, reject) => { const interval = 1500; let elapsed = 0; const check = () => { if (unsafeWindow.livePlayer) { resolve(unsafeWindow.livePlayer); } else { elapsed += interval; if (elapsed >= timeout) { reject(new Error('livePlayer 객체를 찾지 못했습니다.')); } else { setTimeout(check, interval); } } }; check(); }); }; const waitForNonEmptyArray = async () => { const timeout = new Promise((resolve) => setTimeout(() => resolve([]), 3000) // 3초 후 빈 배열 반환 ); const checkArray = (async () => { while (allFollowUserIds.length === 0) { await new Promise((resolve) => setTimeout(resolve, 100)); // 100ms 대기 } return allFollowUserIds; })(); return Promise.race([timeout, checkArray]); }; const updateElementWithContent = (targetElement, newContent) => { // DocumentFragment 생성 const createFragment = (content) => { const fragment = document.createDocumentFragment(); const tempDiv = document.createElement('div'); tempDiv.innerHTML = content; // tempDiv의 자식 요소를 fragment에 추가 while (tempDiv.firstChild) { fragment.appendChild(tempDiv.firstChild); } return fragment; }; // 기존 내용을 지우고 DocumentFragment를 적용 const applyFragment = (fragment) => { targetElement.innerHTML = ''; // 기존 내용을 모두 지움 targetElement.appendChild(fragment); // 새로운 내용 추가 }; // DocumentFragment 생성 후 적용 applyFragment(createFragment(newContent)); }; const manageRedDot = (targetDiv) => { const RED_DOT_CLASS = 'red-dot'; const style = document.createElement('style'); style.textContent = ` .${RED_DOT_CLASS} { position: absolute; top: 8px; right: 8px; width: 4px; height: 4px; background-color: red; border-radius: 50%; } `; document.head.appendChild(style); const lastUpdateDate = GM_getValue('lastUpdateDate', 0); const btn = targetDiv; // 빨간 점 추가 함수 const showRedDot = () => { if (!btn || document.querySelector(`#openModalBtn .${RED_DOT_CLASS}`)) return; const redDot = document.createElement('div'); redDot.classList.add(RED_DOT_CLASS); btn.parentElement.appendChild(redDot); }; // 빨간 점 제거 함수 const hideRedDot = () => { const redDot = document.querySelector(`#openModalBtn .${RED_DOT_CLASS}`); if (redDot) redDot.remove(); }; // 날짜를 비교하여 빨간 점 표시 if (NEW_UPDATE_DATE > lastUpdateDate) { showRedDot(); } else { hideRedDot(); } // 버튼 클릭 시 이벤트 핸들러 추가 btn?.addEventListener('click', () => { GM_setValue('lastUpdateDate', NEW_UPDATE_DATE); hideRedDot(); }); }; const addNumberSeparator = (number) => { number = Number(number); // 숫자가 10,000 이상일 때 if (number >= 10000) { const displayNumber = (number / 10000).toFixed(1); return displayNumber.endsWith('.0') ? displayNumber.slice(0, -2) + '만' : displayNumber + '만'; } return number.toLocaleString(); }; const addNumberSeparatorAll = (number) => { number = Number(number); // 숫자가 10,000 이상일 때 if (number >= 10000) { const displayNumber = (number / 10000).toFixed(1); return displayNumber.endsWith('.0') ? displayNumber.slice(0, -2) + '만' : displayNumber + '만'; } // 숫자가 1,000 이상일 때 else if (number >= 1000) { const displayNumber = (number / 1000).toFixed(1); return displayNumber.endsWith('.0') ? displayNumber.slice(0, -2) + '천' : displayNumber + '천'; } // 기본적으로 쉼표 추가 return number.toLocaleString(); }; const getCategoryName = (targetCateNo) => { const searchCategory = (categories) => { for (const category of categories) { if (category.cate_no === targetCateNo) { return category.cate_name; } if (category.child?.length) { const result = searchCategory(category.child); if (result) return result; } } return targetCateNo === "ADULT_BROAD_CATE" ? "연령제한" : null; }; return searchCategory(savedCategory.CHANNEL.BROAD_CATEGORY); }; const getCategoryNo = (targetCateName) => { const searchCategory = (categories) => { for (const category of categories) { if (category.cate_name === targetCateName) { return category.cate_no; } if (category.child?.length) { const result = searchCategory(category.child); if (result) return result; } } return targetCateName === "연령제한" ? "ADULT_BROAD_CATE" : null; }; return searchCategory(savedCategory.CHANNEL.BROAD_CATEGORY); }; const desc_order = (selector) => { const container = document.body.querySelector(selector); const userElements = container.children; const categories = [[], [], [], [], [], []]; for (let i = 0; i < userElements.length; i++) { const user = userElements[i]; const isPin = user.getAttribute('is_pin') === 'Y'; const hasBroadThumbnail = user.hasAttribute('broad_thumbnail'); const isMobilePush = user.getAttribute('is_mobile_push') === 'Y'; const isOffline = user.hasAttribute('is_offline'); const broad_cate_no = user.getAttribute('broad_cate_no'); const isBlocked = blockedCategories.some(b => b.categoryId === broad_cate_no); if (isPin && hasBroadThumbnail) { categories[0].push(user); // 1. 고정 + 생방 } else if (isPin) { categories[1].push(user); // 2. 고정 + 오프라인 } else if (isMobilePush && !isOffline) { categories[2].push(user); // 3. 알림 켜짐 + 생방 } else if (isBlocked && isBlockedCategorySortingEnabled) { categories[4].push(user); // 5. 차단된 카테고리 (고정 제외, 알림 켜짐 제외) } else if (!isMobilePush && !isOffline) { categories[3].push(user); // 4. 알림 꺼짐 + 생방 } else { categories[5].push(user); // 6. 그 외 } } categories.forEach((category, index) => { if (index === 5 || selector !== '.users-section.follow') { // 방송국 글이거나 즐찾 채널이 아닌 경우 시청자 많은 순 category.sort(compareWatchers); } else { category.sort(isRandomSortEnabled ? stableRandomOrder : compareWatchers) } }); container.innerHTML = ''; const fragment = document.createDocumentFragment(); categories.forEach(category => { category.forEach(user => fragment.appendChild(user)); }); container.appendChild(fragment); }; const compareWatchers = (a, b) => { // Get watchers data only once for each element const watchersA = a.dataset.watchers ? +a.dataset.watchers : 0; // Use dataset for better performance const watchersB = b.dataset.watchers ? +b.dataset.watchers : 0; // Use dataset for better performance return watchersB - watchersA; // Sort by watchers }; const stableRandomOrder = (() => { // 한 번에 여러 개를 정렬할 때 일관된 랜덤성을 유지하려면, 미리 섞어주는 방식이 좋습니다. // 이 함수는 내부적으로 shuffle된 index 맵을 사용해서 안정적인 무작위 정렬을 구현합니다. let randomMap = new WeakMap(); return (a, b) => { if (!randomMap.has(a)) randomMap.set(a, Math.random()); if (!randomMap.has(b)) randomMap.set(b, Math.random()); return randomMap.get(a) - randomMap.get(b); }; })(); const debounce = (func, wait) => { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; }; const isElementVisible = (selector) => { const el = document.querySelector(selector); if (!el) return false; // 요소가 없음 const style = window.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { return false; // CSS로 숨겨진 경우 } const rect = el.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) { return false; // 크기가 0인 경우 } // 화면 안에 일부라도 보이는 경우 return ( rect.bottom > 0 && rect.right > 0 && rect.top < (window.innerHeight || document.documentElement.clientHeight) && rect.left < (window.innerWidth || document.documentElement.clientWidth) ); }; const updateBodyClass = (targetClass) => { if (!window.matchMedia("(orientation: portrait)").matches) { document.body.classList.remove(targetClass); document.querySelector('.expand-toggle-li').style.display = 'none'; } else { document.querySelector('.expand-toggle-li').style.display = 'block'; } }; const extractDateTime = (text) => { const [dateStr, timeStr] = text.split(' '); // split 한 번으로 날짜와 시간을 동시에 얻기 const dateTimeStr = `${dateStr}T${timeStr}Z`; // 문자열 템플릿 사용 return new Date(dateTimeStr); }; const getElapsedTime = (broadcastStartTimeText, type) => { const broadcastStartTime = extractDateTime(broadcastStartTimeText); broadcastStartTime.setHours(broadcastStartTime.getHours() - 9); const currentTime = new Date(); const timeDiff = currentTime - broadcastStartTime; const secondsElapsed = Math.floor(timeDiff / 1000); const hoursElapsed = Math.floor(secondsElapsed / 3600); const minutesElapsed = Math.floor((secondsElapsed % 3600) / 60); const remainingSeconds = secondsElapsed % 60; let formattedTime = ''; if (type === "HH:MM:SS") { formattedTime = `${String(hoursElapsed).padStart(2, '0')}:${String(minutesElapsed).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`; } else if (type === "HH:MM") { if (hoursElapsed > 0) { formattedTime = `${String(hoursElapsed)}시간 `; } formattedTime += `${String(minutesElapsed)}분`; } return formattedTime; }; const isUserTyping = () => { const active = document.activeElement; const tag = active?.tagName?.toUpperCase(); return ( tag === 'INPUT' || tag === 'TEXTAREA' || active?.isContentEditable || active?.id === 'write_area' ); }; const observeElementChanges = (targetSelector, callback, options = {}) => { /** * 지정된 요소의 DOM 변경을 감지하고, 변경 시 콜백 함수를 실행하는 범용 유틸리티 함수입니다. * * @param {string} targetSelector - 감시할 요소의 CSS 선택자입니다. * @param {function(MutationRecord[], MutationObserver): void} callback - DOM 변경이 감지되었을 때 실행할 콜백 함수입니다. * @param {Object} [options] - 관찰에 대한 설정 객체입니다. (선택 사항) * @param {boolean} [options.once=false] - true로 설정하면 콜백을 한 번만 실행하고 관찰을 자동 중단합니다. * @param {MutationObserverInit} [options] - MutationObserver의 표준 설정도 포함합니다. (childList, subtree, attributes 등) * @returns {MutationObserver|null} 생성된 MutationObserver 인스턴스를 반환합니다. */ // 1. 감시할 대상 요소 선택 const targetElement = document.querySelector(targetSelector); if (!targetElement) { console.error(`[observeElementChanges] 오류: 선택자 '${targetSelector}'에 해당하는 요소를 찾을 수 없습니다.`); return null; } // 2. 콜백 함수 유효성 검사 if (typeof callback !== 'function') { console.error(`[observeElementChanges] 오류: 두 번째 인자로 전달된 콜백이 함수가 아닙니다.`); return null; } // 3. 옵션 분리 및 설정 // options 객체에서 'once' 속성을 분리하고, 나머지는 observer 설정으로 사용합니다. const { once = false, ...observerOptions } = options; const defaultConfig = { childList: true, // 기본값: 자식 요소 변경 감지 subtree: true // 기본값: 하위 트리까지 감지 }; // 기본 설정, 사용자 지정 observer 설정을 병합 const config = { ...defaultConfig, ...observerOptions }; // 4. MutationObserver 인스턴스 생성 및 콜백 연결 const observer = new MutationObserver((mutationsList, observer) => { // 사용자 콜백 실행 callback(mutationsList, observer); // 5. 'once' 옵션이 true이면, 콜백 실행 후 즉시 관찰 중단 if (once) { observer.disconnect(); console.log(`[observeElementChanges] '${targetSelector}' 요소에 대한 관찰이 1회 실행 후 중단되었습니다.`); } }); // 6. 관찰 시작 observer.observe(targetElement, config); console.log(`[observeElementChanges] '${targetSelector}' 요소에 대한 관찰을 시작합니다. (once: ${once})`); // 7. 생성된 observer 인스턴스 반환 return observer; }; const observeUrlChanges = (() => { let lastUrl = window.location.pathname; const callbacks = new Set(); let isObserving = false; const triggerCallbacks = (newUrl) => { if (newUrl !== lastUrl) { lastUrl = newUrl; callbacks.forEach(cb => cb(newUrl)); } }; const startObserving = () => { if (isObserving) return; isObserving = true; window.addEventListener('popstate', () => { triggerCallbacks(window.location.pathname); }); const originalPushState = history.pushState; const originalReplaceState = history.replaceState; history.pushState = function(...args) { originalPushState.apply(this, args); triggerCallbacks(args[2]?.toString() || window.location.pathname); }; history.replaceState = function(...args) { originalReplaceState.apply(this, args); triggerCallbacks(args[2]?.toString() || window.location.pathname); }; }; return function registerCallback(callback) { startObserving(); callbacks.add(callback); // 개별 콜백 제거 가능 return function disconnect() { callbacks.delete(callback); }; }; })(); const waitForConditionAsync = (conditionFn, timeout = 10000) => { /** * 주어진 조건 함수(conditionFn)가 true를 반환할 때까지 기다리는 Promise를 반환합니다. * @param {() => boolean} conditionFn - true 또는 false를 반환하는 조건 함수. * @param {number} [timeout=10000] - 기다릴 최대 시간 (밀리초). * @returns {Promise} 조건이 충족되면 resolve되는 Promise. */ return new Promise((resolve, reject) => { // 1. 즉시 조건 확인 if (conditionFn()) { resolve(); return; } let observer = null; // 2. 타임아웃 설정 const timeoutId = setTimeout(() => { if (observer) { observer.disconnect(); reject(new Error("Timeout: 조건이 지정된 시간 내에 충족되지 않았습니다.")); } }, timeout); // 3. MutationObserver로 body의 모든 변화를 감지 observer = new MutationObserver(() => { if (conditionFn()) { observer.disconnect(); clearTimeout(timeoutId); resolve(); } }); observer.observe(document.body, { childList: true, subtree: true, attributes: true }); }); } const observeClassChanges = (targetSelector, callback) => { /** * 지정된 요소의 'class' 속성 변경만을 감지하고, 변경 시 콜백 함수를 실행하는 유틸리티 함수입니다. * 이 함수는 MutationObserver를 사용하여 불필요한 DOM 변경 감지를 최소화합니다. * * @param {string} targetSelector - 감시할 요소의 CSS 선택자입니다. * @param {function(MutationRecord[], MutationObserver): void} callback - 'class' 속성 변경이 감지되었을 때 실행할 콜백 함수입니다. * @returns {MutationObserver|null} 생성된 MutationObserver 인스턴스를 반환합니다. */ // 1. 감시할 대상 요소 선택 const targetElement = document.querySelector(targetSelector); if (!targetElement) { // console.error(`[observeClassChanges] 오류: 선택자 '${targetSelector}'에 해당하는 요소를 찾을 수 없습니다.`); return null; } // 2. 콜백 함수 유효성 검사 if (typeof callback !== 'function') { // console.error(`[observeClassChanges] 오류: 두 번째 인자로 전달된 콜백이 함수가 아닙니다.`); return null; } // 3. MutationObserver 설정 (class 변화에만 집중) const config = { attributes: true, // 속성 변경 감지 활성화 attributeFilter: ['class'], // 'class' 속성만 필터링하여 감지 childList: false, // 자식 요소 변경 감지 비활성화 (기본값 재정의) subtree: false // 하위 트리 변경 감지 비활성화 (기본값 재정의) }; // 4. MutationObserver 인스턴스 생성 및 콜백 연결 const observer = new MutationObserver((mutationsList, observerInstance) => { // 'class' 속성 변경에 대한 모든 변경 레코드를 순회하며 콜백 실행 // 실제 콜백 함수는 모든 mutationList를 받을 수 있지만, // 이 옵션으로 인해 class attribute 변경만 여기에 전달됩니다. callback(mutationsList, observerInstance); }); // 5. 관찰 시작 observer.observe(targetElement, config); // console.log(`[observeClassChanges] '${targetSelector}' 요소의 클래스 변경 감시를 시작합니다.`); // 6. 생성된 observer 인스턴스 반환 (필요시 중단 등을 위해) return observer; }; const loadScript = (url) => { return new Promise((resolve, reject) => { // 동일한 스크립트가 이미 로드되었는지 확인 if (document.querySelector(`script[src="${url}"]`)) { console.log(`스크립트가 이미 로드됨: ${url}`); resolve(); return; } const script = document.createElement('script'); script.src = url; script.onload = () => { console.log(`스크립트 로드 성공: ${url}`); resolve(); }; script.onerror = () => { console.error(`스크립트 로드 실패: ${url}`); reject(new Error(`${url} 로드 실패`)); }; document.head.appendChild(script); }); } // 3.3. 차단 기능 관련 함수 (Blocking Features) function saveBlockedUsers() { GM_setValue('blockedUsers', blockedUsers); }; function blockUser(userName, userId) { // 이미 차단된 사용자인지 확인 if (!isUserBlocked(userId)) { blockedUsers.push({ userName, userId }); saveBlockedUsers(); alert(`사용자 ${userName}(${userId})를 차단했습니다.\n차단 해제 메뉴는 템퍼몽키 아이콘을 누르면 있습니다.`); registerUnblockMenu({ userName, userId }); } else { alert(`사용자 ${userName}(${userId})는 이미 차단되어 있습니다.`); } }; function unblockUser(userId) { // 차단된 사용자 목록에서 해당 사용자 찾기 let unblockedUser = blockedUsers.find(user => user.userId === userId); // 사용자를 찾았을 때만 차단 해제 및 메뉴 삭제 수행 if (unblockedUser) { // 차단된 사용자 목록에서 해당 사용자 제거 blockedUsers = blockedUsers.filter(user => user.userId !== userId); // 변경된 목록을 저장 GM_setValue('blockedUsers', blockedUsers); alert(`사용자 ${userId}의 차단이 해제되었습니다.`); unregisterUnblockMenu(unblockedUser.userName); } }; function isUserBlocked(userId) { return blockedUsers.some(user => user.userId === userId); }; function registerUnblockMenu(user) { // GM_registerMenuCommand로 메뉴를 등록하고 메뉴 ID를 기록 let menuId = GM_registerMenuCommand(`💔 차단 해제 - ${user.userName}`, function() { unblockUser(user.userId); }); // 메뉴 ID를 기록 menuIds[user.userName] = menuId; }; function unregisterUnblockMenu(userName) { // userName을 기반으로 저장된 메뉴 ID를 가져와서 삭제 let menuId = menuIds[userName]; if (menuId) { GM_unregisterMenuCommand(menuId); delete menuIds[userName]; // 삭제된 메뉴 ID를 객체에서도 제거 } }; function saveBlockedCategories() { GM_setValue('blockedCategories', blockedCategories); }; function blockCategory(categoryName, categoryId) { // 이미 차단된 카테고리인지 확인 if (!isCategoryBlocked(categoryId)) { blockedCategories.push({ categoryName, categoryId }); saveBlockedCategories(); alert(`카테고리 ${categoryName}(${categoryId})를 차단했습니다.\n차단 해제 메뉴는 템퍼몽키 아이콘을 누르면 있습니다.`); registerCategoryUnblockMenu({ categoryName, categoryId }); } else { alert(`카테고리 ${categoryName}(${categoryId})는 이미 차단되어 있습니다.`); } }; function unblockCategory(categoryId) { // 차단된 카테고리 목록에서 해당 카테고리 찾기 let unblockedCategory = blockedCategories.find(category => category.categoryId === categoryId); // 카테고리를 찾았을 때만 차단 해제 및 메뉴 삭제 수행 if (unblockedCategory) { // 차단된 카테고리 목록에서 해당 카테고리 제거 blockedCategories = blockedCategories.filter(category => category.categoryId !== categoryId); // 변경된 목록을 저장 GM_setValue('blockedCategories', blockedCategories); alert(`카테고리 ${categoryId}의 차단이 해제되었습니다.`); unregisterCategoryUnblockMenu(unblockedCategory.categoryName); } }; function isCategoryBlocked(categoryId) { return blockedCategories.some(category => category.categoryId === categoryId); }; function registerCategoryUnblockMenu(category) { // GM_registerMenuCommand로 카테고리 메뉴를 등록하고 메뉴 ID를 기록 let menuId = GM_registerMenuCommand(`💔 카테고리 차단 해제 - ${category.categoryName}`, function() { unblockCategory(category.categoryId); }); // 메뉴 ID를 기록 categoryMenuIds[category.categoryName] = menuId; }; function unregisterCategoryUnblockMenu(categoryName) { // categoryName을 기반으로 저장된 메뉴 ID를 가져와서 삭제 let menuId = categoryMenuIds[categoryName]; if (menuId) { GM_unregisterMenuCommand(menuId); delete categoryMenuIds[categoryName]; // 삭제된 메뉴 ID를 객체에서도 제거 } }; function saveBlockedWords() { GM_setValue('blockedWords', blockedWords); }; function blockWord(word) { // 단어의 양쪽 공백 제거 word = word.trim(); // 단어가 두 글자 이상인지 확인 if (word.length < 2) { alert("단어는 두 글자 이상이어야 합니다."); return; } // 이미 차단된 단어인지 확인 if (!isWordBlocked(word)) { blockedWords.push(word); saveBlockedWords(); alert(`단어 "${word}"를 차단했습니다.`); registerWordUnblockMenu(word); } else { alert(`단어 "${word}"는 이미 차단되어 있습니다.`); } }; function unblockWord(word) { // 차단된 단어 목록에서 해당 단어 찾기 let unblockedWord = blockedWords.find(blockedWord => blockedWord === word); // 단어를 찾았을 때만 차단 해제 및 메뉴 삭제 수행 if (unblockedWord) { // 차단된 단어 목록에서 해당 단어 제거 blockedWords = blockedWords.filter(blockedWord => blockedWord !== word); // 변경된 목록을 저장 saveBlockedWords(); alert(`단어 "${word}"의 차단이 해제되었습니다.`); unregisterWordUnblockMenu(word); } }; function isWordBlocked(word) { const lowerCaseWord = word.toLowerCase(); return blockedWords.map(word => word.toLowerCase()).includes(lowerCaseWord); }; function registerWordUnblockMenu(word) { // GM_registerMenuCommand로 단어 차단 해제 메뉴를 등록하고 메뉴 ID를 기록 let menuId = GM_registerMenuCommand(`💔 단어 차단 해제 - ${word}`, function() { unblockWord(word); }); // 메뉴 ID를 기록 wordMenuIds[word] = menuId; }; function unregisterWordUnblockMenu(word) { // word를 기반으로 저장된 메뉴 ID를 가져와서 삭제 let menuId = wordMenuIds[word]; if (menuId) { GM_unregisterMenuCommand(menuId); delete wordMenuIds[word]; // 삭제된 메뉴 ID를 객체에서도 제거 } }; function registerMenuBlockingWord() { // GM 메뉴에 단어 차단 등록 메뉴를 추가합니다. GM_registerMenuCommand('단어 등록 | 방제에 포함시 차단', function() { // 사용자에게 차단할 단어 입력을 요청 let word = prompt('차단할 단어 (2자 이상): '); // 입력한 단어가 있을 때만 처리 if (word) { blockWord(word); } }); }; // 3.4. UI 생성 및 조작 함수 (UI Generation & Manipulation) const makeTopNavbarAndSidebar = (page) => { // .left_navbar를 찾거나 생성 let leftNavbar = document.body.querySelector('.left_navbar'); if (!leftNavbar) { leftNavbar = document.createElement('div'); leftNavbar.className = 'left_navbar'; (async () => { const serviceHeaderDiv = await waitForElementAsync('#serviceHeader'); serviceHeaderDiv.prepend(leftNavbar); })() } // 버튼을 미리 만들어 DocumentFragment에 추가 const buttonFragment = document.createDocumentFragment(); BUTTON_DATA.reverse().forEach(data => { const newButton = document.createElement('a'); newButton.innerHTML = ``; const isTargetUrl = CURRENT_URL.startsWith("https://www.sooplive.co.kr"); // 이벤트 리스너 함수 정의 const triggerClick = (event) => { event.preventDefault(); const targetElement = isTargetUrl && data.onClickTarget ? document.querySelector(data.onClickTarget) : null; if (targetElement) { targetElement.click(); // 타겟 요소 클릭 } else { console.warn("타겟 요소를 찾을 수 없음:", data.onClickTarget); } }; // MutationObserver 설정: 타겟 요소가 로드될 때까지 기다림 if (isTargetUrl && data.onClickTarget) { const observer = new MutationObserver((mutations, observer) => { const targetElement = document.querySelector(data.onClickTarget); if (targetElement) { observer.disconnect(); // 요소가 확인되면 Observer 중지 newButton.addEventListener('click', triggerClick); } }); observer.observe(document.body, { childList: true, subtree: true }); } else { // 기본 링크 설정 newButton.href = data.href; newButton.target = isOpenNewtabEnabled ? "_blank" : "_self"; } buttonFragment.appendChild(newButton); }); leftNavbar.appendChild(buttonFragment); // 한 번에 추가 const tooltipContainer = document.createElement('div'); tooltipContainer.classList.add('tooltip-container'); const sidebarClass = isSidebarMinimized ? "min" : "max"; if (page === "main") { const newHtml = ` `; const serviceLnbElement = document.getElementById('soop-gnb'); if (serviceLnbElement) { serviceLnbElement.insertAdjacentHTML('afterend', newHtml); } document.body.appendChild(tooltipContainer); } if (page === "player") { const sidebarHtml = ` `; document.body.insertAdjacentHTML('beforeend', sidebarHtml); document.body.appendChild(tooltipContainer); } }; const createUserElementChzzk = (channel, is_mobile_push) => { const { liveTitle: liveTitle, liveImageUrl: liveImageUrl, concurrentUserCount: concurrentUserCount, openDate: openDate, liveCategoryValue: liveCategoryValue, liveCategory: liveCategory, channel: channelInfo, liveInfo: liveInfo } = channel; const userId = channelInfo.channelId; const playerLink = `https://chzzk.naver.com/live/${channelInfo.channelId}`; const broadThumbnail = liveImageUrl ? liveImageUrl.split('{type}').join('360') : "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQIHWNgAAAAAgABz8g15QAAAABJRU5ErkJggg=="; const profileImg = channelInfo?.channelImageUrl; const channelPage = 'https://chzzk.naver.com/'+userId; const channelName = channelInfo?.channelName; const emptyimage = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQIHWNgAAAAAgABz8g15QAAAABJRU5ErkJggg=='; const userElement = document.createElement('a'); userElement.classList.add('user'); if (isSmallUserLayoutEnabled) userElement.classList.add('small-user-layout'); userElement.setAttribute('href', playerLink); if (isOpenNewtabEnabled) { userElement.setAttribute('target', '_blank'); } else { userElement.setAttribute('target', '_self'); } userElement.setAttribute('data-watchers', concurrentUserCount ?? liveInfo.concurrentUserCount); userElement.setAttribute('broad_thumbnail', broadThumbnail); userElement.setAttribute('tooltip', liveTitle ?? liveInfo.liveTitle); userElement.setAttribute('user_id', userId); userElement.setAttribute('broad_start', openDate ?? 'NotAvailable'); userElement.setAttribute('is_mobile_push', is_mobile_push === "Y" ? 'Y' : 'N'); userElement.setAttribute('is_pin', 'N'); const profilePicture = document.createElement('img'); profilePicture.src = profileImg || emptyimage; profilePicture.setAttribute('loading', 'lazy'); const profileClickHandler = ` event.preventDefault(); event.stopPropagation(); if (document.getElementById('sidebar').offsetWidth === 52) { if(event.ctrlKey) { window.open('${playerLink}', '_blank'); return; } location.href = '${playerLink}'; } else { window.open('${channelPage}', '_blank'); } `; // 프로필 클릭 & 새 탭 열기: 최소화 시 생방송, 최대화 시 방송국 const profileClickHandlerForNewtab = ` event.preventDefault(); event.stopPropagation(); if (document.getElementById('sidebar').offsetWidth === 52) { window.open('${playerLink}', '_blank'); } else { window.open('${channelPage}', '_blank'); } ` profilePicture.setAttribute('onclick', isOpenNewtabEnabled === 1 ? profileClickHandlerForNewtab : profileClickHandler ); profilePicture.setAttribute('onmousedown', ` if (event.button === 1) { event.preventDefault(); event.stopPropagation(); if (document.getElementById('sidebar').offsetWidth !== 52) { window.open('${channelPage}', '_blank'); } } `); profilePicture.classList.add('profile-picture'); const username = document.createElement('span'); username.classList.add('username'); username.textContent = (is_mobile_push === "Y") ? `🖈${channelName}` : channelName; username.setAttribute('title', is_mobile_push === "Y" ? '고정됨(알림 받기 켜짐)' : ''); username.title = username.textContent; const description = document.createElement('span'); description.classList.add('description'); description.textContent = liveCategoryValue ?? liveInfo.liveCategoryValue; description.title = description.textContent; userElement.setAttribute('broad_cate_no', liveCategory ?? ''); const watchers = document.createElement('span'); watchers.classList.add('watchers'); const dot = document.createElement('span'); dot.classList.add('dot', 'greendot'); dot.setAttribute('role', 'img'); dot.textContent = '●'; const userCount = addNumberSeparator(concurrentUserCount ?? liveInfo.concurrentUserCount); const countText = document.createTextNode(userCount); watchers.append(dot, countText); userElement.append(profilePicture, username, description, watchers); return userElement; }; const createUserElement = (channel, is_mobile_push, is_pin) => { const { user_id: userId, broad_no: broadNo, total_view_cnt: totalViewCnt, broad_title: broadTitle, user_nick: userNick, broad_start: broadStart, broad_cate_no: catNo, category_name: categoryName, subscription_only: subscriptionOnly } = channel; const isSubOnly = Number(subscriptionOnly || 0) > 0; const playerLink = `https://play.sooplive.co.kr/${userId}/${broadNo}`; const broadThumbnail = `https://liveimg.sooplive.co.kr/m/${broadNo}`; const userElement = document.createElement('a'); userElement.classList.add('user'); if (isSmallUserLayoutEnabled) userElement.classList.add('small-user-layout'); userElement.setAttribute('href', playerLink); if (isOpenNewtabEnabled) { userElement.setAttribute('target', '_blank'); } else if (isSendLoadBroadEnabled) { userElement.setAttribute('onclick', ` if(event.ctrlKey || (window.location.href.indexOf('play.sooplive.co.kr') === -1) ) return; event.preventDefault(); event.stopPropagation(); if (document.body.querySelector('div.loading') && getComputedStyle(document.body.querySelector('div.loading')).display === 'none') { liveView.playerController.sendLoadBroad('${userId}', ${broadNo}); } else { location.href = '${playerLink}'; } `); } else { userElement.setAttribute('target', '_self'); } userElement.setAttribute('data-watchers', totalViewCnt); userElement.setAttribute('broad_thumbnail', broadThumbnail); userElement.setAttribute('tooltip', broadTitle); userElement.setAttribute('user_id', userId); userElement.setAttribute('broad_start', broadStart); if (isOpenExternalPlayerFromSidebarEnabled) { userElement.setAttribute('oncontextmenu', ` event.preventDefault(); event.stopPropagation(); (async () => { const aid = await getBroadAid2('${userId}', ${broadNo}); if (aid) openHlsStream('${userNick}', 'https://live-global-cdn-v02.sooplive.co.kr/live-stm-12/auth_playlist.m3u8?aid=' + aid); })(); `); } if (is_mobile_push) { userElement.setAttribute('is_mobile_push', is_mobile_push); userElement.setAttribute('is_pin', is_pin ? 'Y' : 'N'); } const profilePicture = document.createElement('img'); const pp_webp = `https://stimg.sooplive.co.kr/LOGO/${userId.slice(0, 2)}/${userId}/m/${userId}.webp`; const pp_jpg = `https://profile.img.sooplive.co.kr/LOGO/${userId.slice(0, 2)}/${userId}/m/${userId}.jpg`; profilePicture.src = pp_webp; profilePicture.setAttribute('loading', 'lazy'); profilePicture.setAttribute('onerror', `this.onerror=null; this.src='${pp_jpg}'`); const profileClickHandler = isSendLoadBroadEnabled ? // 프로필 클릭 & 현재 탭 & 빠른 전환 ` event.preventDefault(); event.stopPropagation(); if (document.getElementById('sidebar').offsetWidth === 52) { if (event.ctrlKey) { window.open('${playerLink}', '_blank'); return; } if (document.body.querySelector('div.loading') && getComputedStyle(document.body.querySelector('div.loading')).display === 'none') { liveView.playerController.sendLoadBroad('${userId}', ${broadNo}); } else { location.href = '${playerLink}'; } } else { window.open('https://ch.sooplive.co.kr/${userId}', '_blank'); } ` : // 프로필 클릭 & 현재 탭 & 새로고침 ` event.preventDefault(); event.stopPropagation(); if (document.getElementById('sidebar').offsetWidth === 52) { if(event.ctrlKey) { window.open('${playerLink}', '_blank'); return; } location.href = '${playerLink}'; } else { window.open('https://ch.sooplive.co.kr/${userId}', '_blank'); } `; // 프로필 클릭 & 새 탭 열기: 최소화 시 생방송, 최대화 시 방송국 const profileClickHandlerForNewtab = ` event.preventDefault(); event.stopPropagation(); if (document.getElementById('sidebar').offsetWidth === 52) { window.open('${playerLink}', '_blank'); } else { window.open('https://ch.sooplive.co.kr/${userId}', '_blank'); } ` profilePicture.setAttribute('onclick', isOpenNewtabEnabled === 1 ? profileClickHandlerForNewtab : profileClickHandler ); profilePicture.setAttribute('onmousedown', ` if (event.button === 1) { event.preventDefault(); event.stopPropagation(); if (document.getElementById('sidebar').offsetWidth !== 52) { window.open('https://ch.sooplive.co.kr/${userId}', '_blank'); } } `); profilePicture.classList.add('profile-picture'); const username = document.createElement('span'); username.classList.add('username'); username.textContent = (is_pin || is_mobile_push === "Y") ? `🖈${userNick}` : userNick; username.setAttribute('title', is_pin ? '고정됨(상단 고정 켜짐)' : is_mobile_push === "Y" ? '고정됨(알림 받기 켜짐)' : ''); username.title = username.textContent; const description = document.createElement('span'); description.classList.add('description'); description.textContent = categoryName || getCategoryName(catNo); description.title = description.textContent; userElement.setAttribute('broad_cate_no', catNo); const watchers = document.createElement('span'); watchers.classList.add('watchers'); // const dot = document.createElement('span'); dot.classList.add('dot'); dot.setAttribute('role', 'img'); dot.textContent = isSubOnly ? '★' : '●'; dot.title = isSubOnly ? '구독자 전용' : ''; const viewCountText = document.createTextNode(addNumberSeparator(totalViewCnt)); watchers.append(dot, viewCountText); userElement.append(profilePicture, username, description, watchers); return userElement; }; const createUserElement_vod = (channel) => { const { user_id: userId, title_no: broadNo, view_cnt: totalViewCnt, title: broadTitle, user_nick: userNick, vod_duration: vodDuration, reg_date: regDate, thumbnail, } = channel; const playerLink = `https://vod.sooplive.co.kr/player/${broadNo}`; const broadThumbnail = thumbnail.replace("http://", "https://"); const userElement = document.createElement('a'); userElement.classList.add('user'); if (isSmallUserLayoutEnabled) userElement.classList.add('small-user-layout'); userElement.setAttribute('href', playerLink); if (isOpenNewtabEnabled) { userElement.setAttribute('target', '_blank'); } userElement.setAttribute('data-watchers', totalViewCnt); userElement.setAttribute('broad_thumbnail', broadThumbnail); userElement.setAttribute('tooltip', broadTitle); userElement.setAttribute('user_id', userId); userElement.setAttribute('vod_duration', vodDuration); const profilePicture = document.createElement('img'); const pp_webp = `https://stimg.sooplive.co.kr/LOGO/${userId.slice(0, 2)}/${userId}/m/${userId}.webp`; const pp_jpg = `https://profile.img.sooplive.co.kr/LOGO/${userId.slice(0, 2)}/${userId}/m/${userId}.jpg`; profilePicture.src = pp_webp; profilePicture.setAttribute('loading', 'lazy'); profilePicture.setAttribute('onerror', `this.onerror=null; this.src='${pp_jpg}'`); const profileClickHandler = ` event.preventDefault(); event.stopPropagation(); const sidebarWidth = document.getElementById('sidebar').offsetWidth; if (sidebarWidth === 52) { if (event.ctrlKey) { window.open('${playerLink}', '_blank'); return; } location.href = '${playerLink}'; } else { window.open('https://ch.sooplive.co.kr/${userId}', '_blank'); } `; profilePicture.setAttribute('onclick', isOpenNewtabEnabled ? `event.preventDefault(); event.stopPropagation(); window.open('${playerLink}', '_blank');` : profileClickHandler ); profilePicture.setAttribute('onmousedown', ` if (event.button === 1) { if (document.getElementById('sidebar').offsetWidth !== 52) { event.preventDefault(); event.stopPropagation(); window.open('https://ch.sooplive.co.kr/${userId}', '_blank'); } } `); profilePicture.classList.add('profile-picture', 'profile-grayscale'); const username = document.createElement('span'); username.classList.add('username'); username.textContent = userNick; username.title = username.textContent; const description = document.createElement('span'); description.classList.add('description'); description.textContent = vodDuration; description.title = vodDuration; const watchers = document.createElement('span'); watchers.classList.add('watchers'); watchers.textContent = timeSince(regDate); userElement.append(profilePicture, username, description, watchers); return userElement; }; const createUserElement_offline = (channel, isFeeditem) => { const { user_id: userId, total_view_cnt: totalViewCnt, user_nick: userNick, is_mobile_push: isMobilePush, is_pin: isPin, reg_date_human: reg_date_human, } = channel; const playerLink = isFeeditem ? isFeeditem.url : `https://ch.sooplive.co.kr/${userId}`; const isOffline = "Y"; const feedTimestamp = isFeeditem ? isFeeditem.reg_timestamp : false; const feedRegDate = isFeeditem ? isFeeditem.reg_date : false; const userElement = document.createElement('a'); userElement.classList.add('user'); userElement.classList.add('user-offline'); if (isSmallUserLayoutEnabled) userElement.classList.add('small-user-layout'); userElement.setAttribute('href', playerLink); userElement.setAttribute('target', '_blank'); userElement.setAttribute('broad_start', feedRegDate || ''); userElement.setAttribute('data-watchers', isFeeditem ? feedTimestamp : totalViewCnt); userElement.setAttribute('user_id', userId); if (isFeeditem) { if (isFeeditem.photo_cnt) { userElement.setAttribute('broad_thumbnail', `https:${isFeeditem.photos[0].url}`); } else { userElement.setAttribute('data-tooltip-listener', 'false'); } userElement.setAttribute('tooltip', isFeeditem.title_name); } else { userElement.setAttribute('data-tooltip-listener', 'false'); } if (isMobilePush) { userElement.setAttribute('is_mobile_push', isMobilePush); userElement.setAttribute('is_pin', isPin ? 'Y' : 'N'); } userElement.setAttribute('is_offline', isOffline); const profilePicture = document.createElement('img'); const pp_webp = `https://stimg.sooplive.co.kr/LOGO/${userId.slice(0, 2)}/${userId}/m/${userId}.webp`; const pp_jpg = `https://profile.img.sooplive.co.kr/LOGO/${userId.slice(0, 2)}/${userId}/m/${userId}.jpg`; profilePicture.src = pp_webp; profilePicture.setAttribute('loading', 'lazy'); profilePicture.setAttribute('onerror', `this.onerror=null; this.src='${pp_jpg}'`); const profileClickHandler = ` event.preventDefault(); event.stopPropagation(); const sidebarWidth = document.getElementById('sidebar').offsetWidth; if (sidebarWidth === 52) { window.open('${playerLink}', '_blank'); } else { window.open('https://ch.sooplive.co.kr/${userId}', '_blank'); } `; profilePicture.setAttribute('onclick', profileClickHandler); profilePicture.classList.add('profile-picture', 'profile-grayscale'); const username = document.createElement('span'); username.classList.add('username'); username.textContent = isPin ? `🖈${userNick}` : userNick; username.title = username.textContent; if (isPin) username.setAttribute('title', '고정됨(상단 고정 켜짐)'); const description = document.createElement('span'); description.classList.add('description'); description.textContent = isFeeditem ? isFeeditem.title_name : ''; description.title = isFeeditem ? isFeeditem.title_name : ''; const watchers = document.createElement('span'); watchers.classList.add('watchers'); if (isFeeditem) { // 피드 아이템이 있으면 시간 표시 watchers.textContent = isFeeditem.reg_date_human; } else { // 오프라인 상태일 경우 ● + "오프라인" const dot = document.createElement('span'); dot.classList.add('dot', 'profile-grayscale'); dot.setAttribute('role', 'img'); dot.textContent = '●'; const offlineText = document.createTextNode('오프라인'); watchers.append(dot, offlineText); } userElement.append(profilePicture, username, description, watchers); return userElement; }; const isUserInFollowSection = (userid) => { const followUsers = document.body.querySelectorAll('.users-section.follow .user'); // 유저가 포함되어 있는지 확인 return Array.from(followUsers).some(user => user.getAttribute('user_id') === userid); }; const insertFoldButton = () => { const foldButton = `
`; const webplayer_scroll = document.getElementById('webplayer_scroll') || document.getElementById('list-container'); const serviceLnbElement = document.getElementById('sidebar'); if (serviceLnbElement) { serviceLnbElement.insertAdjacentHTML('beforeend', foldButton); // 클릭 이벤트 리스너를 정의 const toggleSidebar = () => { isSidebarMinimized = !isSidebarMinimized; // max 클래스가 있으면 제거하고 min 클래스 추가 if (serviceLnbElement.classList.toggle('max')) { serviceLnbElement.classList.remove('min'); webplayer_scroll.style.left = '240px'; } else { serviceLnbElement.classList.remove('max'); serviceLnbElement.classList.add('min'); webplayer_scroll.style.left = '52px'; } // isSidebarMinimized 값을 저장 GM_setValue("isSidebarMinimized", isSidebarMinimized ? 1 : 0); }; // 버튼에 클릭 이벤트 리스너 추가 const buttons = serviceLnbElement.querySelectorAll('.button-fold-sidebar, .button-unfold-sidebar'); for (const button of buttons) { button.addEventListener('click', toggleSidebar); } } }; const insertTopChannels = async (update) => { const topIcon = IS_DARK_MODE ? `` : ``; const newHtml = `
인기 채널 ${topIcon}
`; const serviceLnbElement = document.getElementById('sidebar'); if (serviceLnbElement && !update) { serviceLnbElement.insertAdjacentHTML('beforeend', newHtml); } const openList = document.body.querySelectorAll('.users-section.top .user:not(.show-more)').length; try { const [hiddenBjList, broadListResponse] = await Promise.all([getHiddenbjList(), fetchBroadList('https://live.sooplive.co.kr/api/main_broad_list_api.php?selectType=action&orderType=view_cnt&pageNo=1&lang=ko_KR')]); HIDDEN_BJ_LIST.length = 0; HIDDEN_BJ_LIST.push(...hiddenBjList); const channels = broadListResponse.broad; const usersSection = document.querySelector('.users-section.top'); let temp_html = ''; channels.forEach(channel => { const isBlocked = blockedWords.some(word => channel.broad_title.toLowerCase().includes(word.toLowerCase())) || HIDDEN_BJ_LIST.includes(channel.user_id) || isCategoryBlocked(channel.broad_cate_no) || isUserBlocked(channel.user_id); if (!isBlocked) { const userElement = createUserElement(channel, 0, 0); temp_html += userElement.outerHTML; } }); if (isChzzkTopChannelsEnabled) { const chzzkTopChannelsData = await fetchBroadList('https://api.chzzk.naver.com/service/v1/lives?size=50&sortType=POPULAR'); const chzzkChannels = chzzkTopChannelsData.content.data; chzzkChannels.forEach(channel => { const userElement = createUserElementChzzk(channel, 0); temp_html += userElement.outerHTML; }); } if (update) { updateElementWithContent(usersSection, temp_html); } else { usersSection.insertAdjacentHTML('beforeend', temp_html); } desc_order('.users-section.top'); showMore('.users-section.top', 'toggleButton3', update ? openList : displayTop, displayTop); makeThumbnailTooltip(); } catch (error) { console.error("Error:", error); } }; const extractFollowUserIds = (response) => { allFollowUserIds = response.data.map(item => item.user_id); // 모든 user_id를 추출하여 전역 배열에 저장 GM_setValue("allFollowUserIds", allFollowUserIds); }; const insertFavoriteChannels = async (update) => { let followingListSoop; let followingListChzzk; if (isChzzkFollowChannelsEnabled) { [followingListSoop, followingListChzzk] = await Promise.all([fetchBroadList('https://myapi.sooplive.co.kr/api/favorite'), fetchBroadList('https://api.chzzk.naver.com/service/v1/channels/followings/live',3000)]); } else { followingListSoop = await fetchBroadList('https://myapi.sooplive.co.kr/api/favorite'); } const isSooploggedIn = followingListSoop?.data; const isChzzkloggedIn = followingListChzzk?.code === 200; if (!isSooploggedIn && !isChzzkloggedIn) { return; } if (isSooploggedIn){ extractFollowUserIds(followingListSoop); } const followIcon = IS_DARK_MODE ? `` : ``; if (!update) { const newHtml = `
즐겨찾기 채널 ${followIcon}
`; const serviceLnbElement = document.getElementById('sidebar'); serviceLnbElement?.insertAdjacentHTML('beforeend', newHtml); } const openList = document.body.querySelectorAll('.users-section.follow .user:not(.show-more)').length; try { let tempHtmlArray = []; const usersSection = document.querySelector('.users-section.follow'); if (isSooploggedIn){ const feedData = await getStationFeed(); // 피드 데이터를 비동기적으로 가져옴 const feedUserIdSet = new Set(feedData.map(feedItem => feedItem.station_user_id)); tempHtmlArray = followingListSoop.data.reduce((acc, item) => { const { is_live, user_id, broad_info } = item; const is_mobile_push = isPinnedStreamWithNotificationEnabled === 1 ? item.is_mobile_push : "N"; const is_pin = isPinnedStreamWithPinEnabled === 1 ? item.is_pin : false; if (is_live) { // 생방송 중 broad_info.forEach(channel => { const userElement = createUserElement(channel, is_mobile_push, is_pin); acc.push(userElement.outerHTML); }); } else if (feedUserIdSet.has(user_id)) { // 비방 + 방송국 새 글 const feedItems = feedData.filter(feedItem => feedItem.station_user_id === user_id); feedItems.forEach(feedItem => { if (feedItem?.reg_timestamp && checkIfTimeover(feedItem.reg_timestamp)) { return; // 타임오버된 경우 넘어감 } const userElement = createUserElement_offline(item, feedItem); acc.push(userElement.outerHTML); }); } else if (is_pin && !isPinnedOnlineOnlyEnabled ) { // 비방 + 상단 고정 + 설정값 const userElement = createUserElement_offline(item, null); acc.push(userElement.outerHTML); } return acc; }, []); } if (isChzzkloggedIn){ // 기존의 tempHtmlArray에 추가하도록 변경 followingListChzzk?.content?.followingList.forEach(item => { const is_mobile_push = isPinnedStreamWithNotificationEnabled === 1 ? (item?.channel?.personalData?.following?.notification === true ? "Y" : "N") : "N"; const userElement = createUserElementChzzk(item, is_mobile_push); tempHtmlArray.push(userElement.outerHTML); // 기존 배열에 추가 }); } if (update) { updateElementWithContent(usersSection, tempHtmlArray.join('')); } else { usersSection.insertAdjacentHTML('beforeend', tempHtmlArray.join('')); } desc_order('.users-section.follow'); showMore('.users-section.follow', 'toggleButton2', update ? openList : displayFollow, displayFollow); makeThumbnailTooltip(); } catch (error) { console.error("Error in insertFavoriteChannels:", error); } }; const insertMyplusChannels = async (update) => { try { const response = await fetchBroadList('https://live.sooplive.co.kr/api/myplus/preferbjLiveVodController.php?nInitCnt=6&szRelationType=C'); if (!response || typeof response !== 'object' || response.RESULT === -1 || !response.DATA) { return; } const { DATA } = response; const myplusIcon = IS_DARK_MODE ? `` : ``; if (!update) { const newHtml = `
추천 채널 ${myplusIcon}
추천 VOD ${myplusIcon}
`; document.getElementById('sidebar')?.insertAdjacentHTML('beforeend', newHtml); } const openList = document.querySelectorAll('.users-section.myplus .user:not(.show-more)').length; const openListvod = document.querySelectorAll('.users-section.myplusvod .user:not(.show-more)').length; const { live_list: channels, vod_list: vods } = DATA; const usersSection = document.querySelector('.users-section.myplus'); const usersSection_vod = document.querySelector('.users-section.myplusvod'); const tempHtmlArray = []; const tempHtmlVodArray = []; const addChannelElements = (channelList, isVod = false) => { for (const channel of channelList) { const isWordBlocked = channel.broad_title && blockedWords.some(word => channel.broad_title.toLowerCase().includes(word.toLowerCase())); // 조건 추가: allFollowUserIds와 channel.user_id 비교 if ( allFollowUserIds.includes(channel.user_id) && // allFollowUserIds에 user_id가 포함된 경우 !isVod && // isVod가 false일 때 isDuplicateRemovalEnabled // 중복 제거 기능이 활성화되어 있을 때 ) { continue; // 조건이 충족되면 다음 루프 반복 } if ( isCategoryBlocked(isVod ? channel.category : channel.broad_cate_no) || isUserBlocked(channel.user_id) || isWordBlocked || (update && isDuplicateRemovalEnabled && isUserInFollowSection(channel.user_id)) ) { continue; // 다른 조건에 따라 건너뛰기 } const userElement = isVod ? createUserElement_vod(channel) : createUserElement(channel, 0, 0); (isVod ? tempHtmlVodArray : tempHtmlArray).push(userElement.outerHTML); } }; if (isDuplicateRemovalEnabled && displayFollow) await waitForNonEmptyArray(); addChannelElements(channels); addChannelElements(vods, true); if (update) { updateElementWithContent(usersSection, tempHtmlArray.join('')); updateElementWithContent(usersSection_vod, tempHtmlVodArray.join('')); } else { usersSection.insertAdjacentHTML('beforeend', tempHtmlArray.join('')); usersSection_vod.insertAdjacentHTML('beforeend', tempHtmlVodArray.join('')); } makeThumbnailTooltip(); if (!myplusOrder) { desc_order('.users-section.myplus'); } const showMoreHandler = () => { showMore('.users-section.myplus', 'toggleButton', update ? openList : displayMyplus, displayMyplus); showMore('.users-section.myplusvod', 'toggleButton4', update ? openListvod : displayMyplusvod, displayMyplusvod); }; showMoreHandler(); } catch (error) { console.error("Error fetching or processing data:", error); } }; const makeThumbnailTooltip = () => { try { const elements = document.querySelectorAll('#sidebar a.user'); const tooltipContainer = document.querySelector('.tooltip-container'); const sidebar = document.getElementById('sidebar'); const hoverTimeouts = new Map(); elements.forEach(element => { const isOffline = element.getAttribute('data-tooltip-listener') === 'false'; if (isOffline) return; const hasEventListener = element.getAttribute('data-tooltip-listener') === 'true'; if (!hasEventListener) { element.addEventListener('mouseenter', (e) => { const uniqueId = `tooltip-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; element.setAttribute('data-hover-tooltip-id', uniqueId); const timeoutId = setTimeout(() => { if (element.matches(':hover') && element.getAttribute('data-hover-tooltip-id') === uniqueId) { showTooltip(element, uniqueId); } }, 20); hoverTimeouts.set(element, timeoutId); }); element.addEventListener('mouseleave', (e) => { element.removeAttribute('data-hover-tooltip-id'); const timeoutId = hoverTimeouts.get(element); if (timeoutId) { clearTimeout(timeoutId); hoverTimeouts.delete(element); } const to = e.relatedTarget; const isGoingToAnotherElement = Array.from(elements).some(el => { const isOffline = el.getAttribute('data-tooltip-listener') === 'false'; return el !== element && el.contains(to) && !isOffline; }); if (!isGoingToAnotherElement) { tooltipContainer.classList.remove('visible'); tooltipContainer.removeAttribute('data-tooltip-id'); tooltipContainer.innerHTML = ''; // 초기화 } }); window.addEventListener('mouseout', (e) => { if (!e.relatedTarget && !e.toElement) { tooltipContainer.classList.remove('visible'); tooltipContainer.innerHTML = ''; } }); element.setAttribute('data-tooltip-listener', 'true'); } }); async function showTooltip(element, uniqueId) { // hover 중인지 다시 검사 if (element.getAttribute('data-hover-tooltip-id') !== uniqueId) return; tooltipContainer.setAttribute('data-tooltip-id', uniqueId); const topBarHeight = document.getElementById('serviceHeader')?.offsetHeight ?? 0; const isScreenMode = document.body.classList.contains('screen_mode'); const { left: elementX, top: elementY } = element.getBoundingClientRect(); const offsetX = elementX + sidebar.offsetWidth; const offsetY = Math.max(elementY - 260, isScreenMode ? 0 : topBarHeight); let imgSrc = element.getAttribute('broad_thumbnail'); const broadTitle = element.getAttribute('tooltip'); let broadStart = element.getAttribute('broad_start'); const vodDuration = element.getAttribute('vod_duration'); const randomTimeCode = Date.now(); const userId = element.getAttribute('user_id'); if (broadStart === "NotAvailable") { try { const getThumbnailJson = await fetchBroadList(`https://api.chzzk.naver.com/service/v1/channels/${userId}/data?fields=topExposedVideos`); if (getThumbnailJson?.code === 200) { const topExposedVideos = getThumbnailJson.content?.topExposedVideos; if (topExposedVideos?.openLive?.liveImageUrl) { const newThumbnail = topExposedVideos.openLive.liveImageUrl.split('{type}').join('360'); const newBroadStart = topExposedVideos.openLive.openDate; if ( tooltipContainer.getAttribute('data-tooltip-id') === uniqueId && element.getAttribute('data-hover-tooltip-id') === uniqueId ) { element.setAttribute('broad_thumbnail', newThumbnail); element.setAttribute('broad_start', newBroadStart); imgSrc = newThumbnail; broadStart = newBroadStart; } } } } catch (error) { console.error("Error in fetching thumbnail:", error); } } if (element.getAttribute('data-hover-tooltip-id') !== uniqueId) return; // 방송 시간 && 이미지 && !게시판이미지 if (broadStart && imgSrc?.startsWith("http") && !imgSrc?.startsWith('https://stimg.')) { imgSrc += `?${Math.floor(randomTimeCode / 10000)}`; } let durationText = broadStart ? getElapsedTime(broadStart, "HH:MM") : vodDuration; let tooltipText = ''; if (sidebar.offsetWidth === 52) { const username = element.querySelector('span.username')?.textContent ?? ''; const description = element.querySelector('span.description')?.textContent ?? ''; let watchers = element.querySelector('span.watchers')?.textContent ?? ''; watchers = watchers.replace('●', '').trim(); tooltipText = `${username} · ${description} · ${watchers}
${broadTitle}`; } else { tooltipText = broadTitle; } const isTooltipVisible = tooltipContainer.classList.contains('visible'); const isSameTooltip = tooltipContainer.getAttribute('data-tooltip-id') === uniqueId; if (isTooltipVisible && isSameTooltip) { const imgEl = tooltipContainer.querySelector('img'); if (imgEl) imgEl.src = imgSrc; else { const newImg = document.createElement('img'); newImg.src = imgSrc; tooltipContainer.prepend(newImg); } const durationOverlay = tooltipContainer.querySelector('.duration-overlay'); if (durationOverlay) { durationOverlay.textContent = durationText; } else if (durationText) { const newOverlay = document.createElement('div'); newOverlay.className = 'duration-overlay'; newOverlay.textContent = durationText; tooltipContainer.appendChild(newOverlay); } const textEl = tooltipContainer.querySelector('.tooltiptext'); if (textEl) { textEl.innerHTML = tooltipText; } else { const newText = document.createElement('div'); newText.className = 'tooltiptext'; newText.innerHTML = tooltipText; tooltipContainer.appendChild(newText); } } else { let tooltipContent = ``; if (durationText) { tooltipContent += `
${durationText}
`; } tooltipContent += `
${tooltipText}
`; tooltipContainer.innerHTML = tooltipContent; } Object.assign(tooltipContainer.style, { left: `${offsetX}px`, top: `${offsetY}px` }); tooltipContainer.classList.add('visible'); } } catch (error) { console.error('makeThumbnailTooltip 함수에서 오류가 발생했습니다:', error); } }; const showMore = (containerSelector, buttonId, n, fixed_n) => { const userContainer = document.body.querySelector(containerSelector); const users = Array.from(userContainer?.querySelectorAll('.user') || []); const displayPerClick = 10; // n보다 목록이 적으면 함수를 끝낸다 if (users.length <= fixed_n) return false; // n개를 넘는 모든 요소를 숨긴다 users.slice(n).forEach(user => user.classList.add('show-more')); const toggleButton = document.createElement('button'); toggleButton.textContent = users.length > n ? `더 보기 (${users.length - n})` : '접기'; toggleButton.id = buttonId; toggleButton.title = "우클릭시 접기(초기화)"; userContainer.appendChild(toggleButton); toggleButton.addEventListener('click', () => { const hiddenUsers = users.filter(user => user.classList.contains('show-more')); const hiddenCount = hiddenUsers.length; if (hiddenCount > 0) { hiddenUsers.slice(0, displayPerClick).forEach(user => user.classList.remove('show-more')); const remainingHidden = hiddenUsers.length - displayPerClick; toggleButton.textContent = remainingHidden > 0 ? `더 보기 (${remainingHidden})` : '접기'; } else { users.slice(fixed_n).forEach(user => user.classList.add('show-more')); toggleButton.textContent = `더 보기 (${users.length - fixed_n})`; } }); toggleButton.addEventListener('contextmenu', event => { event.preventDefault(); users.slice(fixed_n).forEach(user => user.classList.add('show-more')); toggleButton.textContent = `더 보기 (${users.length - fixed_n})`; }); }; const generateBroadcastElements = async (update) => { console.log(`방송 목록 갱신: ${new Date().toLocaleString()}`); try { if (displayFollow) insertFavoriteChannels(update); if (displayTop) insertTopChannels(update); if (displayMyplus || displayMyplusvod) insertMyplusChannels(update); } catch (error) { console.error('Error:', error); } }; const addModalSettings = (serviceUtilDiv) => { const openModalBtn = document.createElement("div"); openModalBtn.setAttribute("id", "openModalBtn"); const link = document.createElement("button"); link.setAttribute("class", "btn-settings-ui"); openModalBtn.appendChild(link); serviceUtilDiv.prepend(openModalBtn); // 모달 컨텐츠를 담고 있는 HTML 문자열 const modalContentHTML = ` `; // 모달 컨텐츠를 body에 삽입 document.body.insertAdjacentHTML("beforeend", modalContentHTML); // 모달 열기 버튼에 이벤트 리스너 추가 let isFirstClick = true; // 첫 클릭 여부를 저장하는 변수 openModalBtn.addEventListener("click", () => { // 모달을 표시 document.getElementById("myModal").style.display = "block"; // 첫 클릭인 경우에만 updateSettingsData 호출 if (isFirstClick) { updateSettingsData(); isFirstClick = false; // 첫 클릭 후에는 false로 변경 } }); // 모달 닫기 버튼에 이벤트 리스너 추가 const closeModalBtn = document.body.querySelector(".myModalClose"); closeModalBtn.addEventListener("click", () => { // 모달을 숨김 const modal = document.getElementById("myModal"); if (modal) { modal.style.display = "none"; } }); // 모달 외부를 클릭했을 때 닫기 document.getElementById("myModal").addEventListener("click", (event) => { const modalContent = document.querySelector('div.modal-content'); const modal = document.getElementById("myModal"); // 모달 콘텐츠가 아닌 곳을 클릭한 경우에만 모달 닫기 if (modal && !modalContent.contains(event.target)) { modal.style.display = "none"; } }); }; const updateSettingsData = () => { const setCheckboxAndSaveValue = (elementId, storageVariable, storageKey) => { const checkbox = document.getElementById(elementId); // elementId가 유효한 경우에만 체크박스를 설정 if (checkbox) { checkbox.checked = (storageVariable === 1); checkbox.addEventListener("change", (event) => { GM_setValue(storageKey, event.target.checked ? 1 : 0); storageVariable = event.target.checked ? 1 : 0; }); } else { console.warn(`Checkbox with id "${elementId}" not found.`); } } // 함수를 사용하여 각 체크박스를 설정하고 값을 저장합니다. setCheckboxAndSaveValue("fixFixedChannel", isPinnedStreamWithPinEnabled, "isPinnedStreamWithPinEnabled"); setCheckboxAndSaveValue("fixNotificationChannel", isPinnedStreamWithNotificationEnabled, "isPinnedStreamWithNotificationEnabled"); setCheckboxAndSaveValue("showBufferTime", isRemainingBufferTimeEnabled, "isRemainingBufferTimeEnabled"); setCheckboxAndSaveValue("mutedInactiveTabs", isAutoChangeMuteEnabled, "isAutoChangeMuteEnabled"); setCheckboxAndSaveValue("switchAutoChangeQuality", isAutoChangeQualityEnabled, "isAutoChangeQualityEnabled"); setCheckboxAndSaveValue("switchNo1440p", isNo1440pEnabled, "isNo1440pEnabled"); setCheckboxAndSaveValue("popularChannelsFirst", myplusPosition, "myplusPosition"); setCheckboxAndSaveValue("mpSortByViewers", myplusOrder, "myplusOrder"); setCheckboxAndSaveValue("removeDuplicates", isDuplicateRemovalEnabled, "isDuplicateRemovalEnabled"); setCheckboxAndSaveValue("openInNewTab", isOpenNewtabEnabled, "isOpenNewtabEnabled"); setCheckboxAndSaveValue("mouseOverSideBar", showSidebarOnScreenMode, "showSidebarOnScreenMode"); setCheckboxAndSaveValue("switchShowSidebarOnScreenModeAlways", showSidebarOnScreenModeAlways, "showSidebarOnScreenModeAlways"); setCheckboxAndSaveValue("chatPosition", isBottomChatEnabled, "isBottomChatEnabled"); setCheckboxAndSaveValue("showPauseButton", isMakePauseButtonEnabled, "isMakePauseButtonEnabled"); setCheckboxAndSaveValue("switchCaptureButton", isCaptureButtonEnabled, "isCaptureButtonEnabled"); setCheckboxAndSaveValue("switchSharpmodeShortcut", isMakeSharpModeShortcutEnabled, "isMakeSharpModeShortcutEnabled"); setCheckboxAndSaveValue("switchLLShortcut", isMakeLowLatencyShortcutEnabled, "isMakeLowLatencyShortcutEnabled"); setCheckboxAndSaveValue("switchQualityChangeShortcut", isMakeQualityChangeShortcutEnabled, "isMakeQualityChangeShortcutEnabled"); setCheckboxAndSaveValue("sendLoadBroadCheck", isSendLoadBroadEnabled, "isSendLoadBroadEnabled"); setCheckboxAndSaveValue("selectBestQuality", isSelectBestQualityEnabled, "isSelectBestQualityEnabled"); setCheckboxAndSaveValue("selectHideSupporterBadge", isHideSupporterBadgeEnabled, "isHideSupporterBadgeEnabled"); setCheckboxAndSaveValue("selectHideFanBadge", isHideFanBadgeEnabled, "isHideFanBadgeEnabled"); setCheckboxAndSaveValue("selectHideSubBadge", isHideSubBadgeEnabled, "isHideSubBadgeEnabled"); setCheckboxAndSaveValue("selectHideVIPBadge", isHideVIPBadgeEnabled, "isHideVIPBadgeEnabled"); setCheckboxAndSaveValue("selectHideMngrBadge", isHideManagerBadgeEnabled, "isHideManagerBadgeEnabled"); setCheckboxAndSaveValue("selectHideStreamerBadge", isHideStreamerBadgeEnabled, "isHideStreamerBadgeEnabled"); setCheckboxAndSaveValue("selectBlockWords", isBlockWordsEnabled, "isBlockWordsEnabled"); setCheckboxAndSaveValue("useInterFont", isChangeFontEnabled, "isChangeFontEnabled"); setCheckboxAndSaveValue("autoClaimGem", isAutoClaimGemEnabled, "isAutoClaimGemEnabled"); setCheckboxAndSaveValue("switchVideoSkipHandler", isVideoSkipHandlerEnabled, "isVideoSkipHandlerEnabled"); setCheckboxAndSaveValue("switchSmallUserLayout", isSmallUserLayoutEnabled, "isSmallUserLayoutEnabled"); setCheckboxAndSaveValue("switchChannelFeed", isChannelFeedEnabled, "isChannelFeedEnabled"); setCheckboxAndSaveValue("switchCustomSidebar", isCustomSidebarEnabled, "isCustomSidebarEnabled"); setCheckboxAndSaveValue("switchRemoveCarousel", isRemoveCarouselEnabled, "isRemoveCarouselEnabled"); setCheckboxAndSaveValue("switchDocumentTitleUpdate", isDocumentTitleUpdateEnabled, "isDocumentTitleUpdateEnabled"); setCheckboxAndSaveValue("switchRemoveRedistributionTag", isRemoveRedistributionTagEnabled, "isRemoveRedistributionTagEnabled"); setCheckboxAndSaveValue("switchRemoveWatchLaterButton", isRemoveWatchLaterButtonEnabled, "isRemoveWatchLaterButtonEnabled"); setCheckboxAndSaveValue("switchBroadTitleTextEllipsis", isBroadTitleTextEllipsisEnabled, "isBroadTitleTextEllipsisEnabled"); setCheckboxAndSaveValue("switchRemoveBroadStartTimeTag", isRemoveBroadStartTimeTagEnabled, "isRemoveBroadStartTimeTagEnabled"); setCheckboxAndSaveValue("switchUnlockCopyPaste", isUnlockCopyPasteEnabled, "isUnlockCopyPasteEnabled"); setCheckboxAndSaveValue("switchAlignNicknameRight", isAlignNicknameRightEnabled, "isAlignNicknameRightEnabled"); setCheckboxAndSaveValue("switchReplaceEmptyThumbnail", isReplaceEmptyThumbnailEnabled, "isReplaceEmptyThumbnailEnabled"); setCheckboxAndSaveValue("switchAutoScreenMode", isAutoScreenModeEnabled, "isAutoScreenModeEnabled"); setCheckboxAndSaveValue("switchAdjustDelayNoGrid", isAdjustDelayNoGridEnabled, "isAdjustDelayNoGridEnabled"); setCheckboxAndSaveValue("switchHideButtonsAboveChatInput", ishideButtonsAboveChatInputEnabled, "ishideButtonsAboveChatInputEnabled"); setCheckboxAndSaveValue("switchExpandVODChatArea", isExpandVODChatAreaEnabled, "isExpandVODChatAreaEnabled"); setCheckboxAndSaveValue("switchExpandLiveChatArea", isExpandLiveChatAreaEnabled, "isExpandLiveChatAreaEnabled"); setCheckboxAndSaveValue("switchOpenExternalPlayer", isOpenExternalPlayerEnabled, "isOpenExternalPlayerEnabled"); setCheckboxAndSaveValue("switchOpenExternalPlayerFromSidebar", isOpenExternalPlayerFromSidebarEnabled, "isOpenExternalPlayerFromSidebarEnabled"); setCheckboxAndSaveValue("switchRemoveShadowsFromCatch", isRemoveShadowsFromCatchEnabled, "isRemoveShadowsFromCatchEnabled"); setCheckboxAndSaveValue("switchChzzkFollowChannels", isChzzkFollowChannelsEnabled, "isChzzkFollowChannelsEnabled"); setCheckboxAndSaveValue("switchChzzkTopChannels", isChzzkTopChannelsEnabled, "isChzzkTopChannelsEnabled"); setCheckboxAndSaveValue("switchShowSelectedMessages", isShowSelectedMessagesEnabled, "isShowSelectedMessagesEnabled"); setCheckboxAndSaveValue("switchShowDeletedMessages", isShowDeletedMessagesEnabled, "isShowDeletedMessagesEnabled"); setCheckboxAndSaveValue("switchNoAutoVOD", isNoAutoVODEnabled, "isNoAutoVODEnabled"); setCheckboxAndSaveValue("switchHideEsportsInfo", isHideEsportsInfoEnabled, "isHideEsportsInfoEnabled"); setCheckboxAndSaveValue("switchBlockedCategorySorting", isBlockedCategorySortingEnabled, "isBlockedCategorySortingEnabled"); setCheckboxAndSaveValue("switchChatCounter", isChatCounterEnabled, "isChatCounterEnabled"); setCheckboxAndSaveValue("switchRandomSort", isRandomSortEnabled, "isRandomSortEnabled"); setCheckboxAndSaveValue("switchPinnedOnlineOnly", isPinnedOnlineOnlyEnabled, "isPinnedOnlineOnlyEnabled"); setCheckboxAndSaveValue("switchMonthlyRecap", isMonthlyRecapEnabled, "isMonthlyRecapEnabled"); setCheckboxAndSaveValue("switchClickToMute", isClickToMuteEnabled, "isClickToMuteEnabled"); setCheckboxAndSaveValue("switchVODChatScan", isVODChatScanEnabled, "isVODChatScanEnabled"); const handleRangeInput = (inputId, displayId, currentValue, storageKey) => { const input = document.getElementById(inputId); input.value = currentValue; input.addEventListener("input", (event) => { const newValue = parseInt(event.target.value); // event.target.value로 변경 if (newValue !== currentValue) { GM_setValue(storageKey, newValue); currentValue = newValue; document.getElementById(displayId).textContent = newValue; if (inputId === "nicknameWidthDisplay") setWidthNickname(newValue); } }); } handleRangeInput("favoriteChannelsDisplay", "favoriteChannelsDisplayValue", displayFollow, "displayFollow"); handleRangeInput("myPlusChannelsDisplay", "myPlusChannelsDisplayValue", displayMyplus, "displayMyplus"); handleRangeInput("myPlusVODDisplay", "myPlusVODDisplayValue", displayMyplusvod, "displayMyplusvod"); handleRangeInput("popularChannelsDisplay", "popularChannelsDisplayValue", displayTop, "displayTop"); handleRangeInput("nicknameWidthDisplay", "nicknameWidthDisplayValue", nicknameWidth, "nicknameWidth"); // 채팅 단어 차단 입력 상자 설정 const blockWordsInputBox = document.getElementById('blockWordsInput'); blockWordsInputBox.addEventListener('input', () => { const inputValue = blockWordsInputBox.value.trim(); registeredWords = inputValue; GM_setValue("registeredWords", inputValue); }); // 유저 채팅 모아보기 입력 상자 설정 const selectedUsersinputBox = document.getElementById('selectedUsersInput'); selectedUsersinputBox.addEventListener('input', () => { const inputValue = selectedUsersinputBox.value.trim(); selectedUsers = inputValue; GM_setValue("selectedUsers", inputValue); }); }; const openHlsStream = (nickname, m3u8Url) => { // HTML과 JavaScript 코드 생성 const htmlContent = ` ${nickname}
`; // Blob 생성 const blob = new Blob([htmlContent], { type: 'text/html' }); const blobUrl = URL.createObjectURL(blob); // 새로운 창으로 Blob URL 열기 window.open(blobUrl, "_blank"); }; unsafeWindow.openHlsStream = openHlsStream; const openStreamDownloader = async (id, broadNumber) => { const aid = await getBroadAid2(id, broadNumber); if (!aid) return; const m3u8Url = `https://live-global-cdn-v02.sooplive.co.kr/live-stm-12/auth_playlist.m3u8?aid=${aid}`; const baseUrl = m3u8Url.split('/').slice(0, -1).join('/') + '/'; const html = ` 다운로드 준비 중... ${id}

📡 실시간 저장 중...

⏱️ 00:00:00

📦 0 / 0

🍰 최신 조각: 0

🍰 마지막 저장: 0

`; const blob = new Blob([html], { type: 'text/html' }); const url = URL.createObjectURL(blob); window.open(url, '_blank'); }; unsafeWindow.openStreamDownloader = openStreamDownloader; const makeExternalLinks = (thumbsBoxLinks) => { for (const thumbsBoxLink of thumbsBoxLinks) { if (!thumbsBoxLink.classList.contains("externalPlayer-checked")) { thumbsBoxLink.classList.add("externalPlayer-checked"); const hrefValue = thumbsBoxLink.getAttribute('href'); if (hrefValue?.includes("play.sooplive.co.kr")) { const [ , , , id, broadNumber] = hrefValue.split('/'); thumbsBoxLink.addEventListener('contextmenu', async (event) => { event.preventDefault(); event.stopPropagation(); const nickname = thumbsBoxLink.parentNode.parentNode.querySelector('.nick').innerText; const aid = await getBroadAid2(id, broadNumber); if (aid){ openHlsStream(nickname, `https://live-global-cdn-v02.sooplive.co.kr/live-stm-12/auth_playlist.m3u8?aid=${aid}`); } }); } } } }; const captureLatestFrame = (videoElement) => { return new Promise((resolve) => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); // 캔버스 크기 설정 (480x270) const canvasWidth = 480; const canvasHeight = 270; canvas.width = canvasWidth; canvas.height = canvasHeight; // 원본 비디오의 비율을 유지하면서 크기 계산 const videoRatio = videoElement.videoWidth / videoElement.videoHeight; const canvasRatio = canvasWidth / canvasHeight; let drawWidth, drawHeight; let offsetX = 0, offsetY = 0; if (videoRatio > canvasRatio) { drawWidth = canvasWidth; drawHeight = canvasWidth / videoRatio; offsetY = (canvasHeight - drawHeight) / 2; } else { drawHeight = canvasHeight; drawWidth = canvasHeight * videoRatio; offsetX = (canvasWidth - drawWidth) / 2; } // 배경을 검은색으로 채우기 ctx.fillStyle = 'black'; ctx.fillRect(0, 0, canvasWidth, canvasHeight); // 비디오의 현재 프레임을 캔버스에 그림 ctx.drawImage(videoElement, offsetX, offsetY, drawWidth, drawHeight); // webp 형식으로 변환 후 반환 const dataURL = canvas.toDataURL('image/webp'); resolve(dataURL); // 데이터 URL 반환 }); }; const replaceThumbnails = (thumbsBoxLinks) => { for (const thumbsBoxLink of thumbsBoxLinks) { if (!thumbsBoxLink.classList.contains("thumbnail-checked")) { thumbsBoxLink.classList.add("thumbnail-checked"); const hrefValue = thumbsBoxLink.getAttribute('href'); if (hrefValue && hrefValue.includes("sooplive.co.kr")) { const [ , , , id, broadNumber] = hrefValue.split('/'); thumbsBoxLink.dataset.lastMouseEnterTime = 0; thumbsBoxLink.addEventListener('mouseenter', async function(event) { event.preventDefault(); event.stopPropagation(); const currentTime = Date.now(); const lastMouseEnterTime = Number(thumbsBoxLink.dataset.lastMouseEnterTime); if (currentTime - lastMouseEnterTime >= 30000) { thumbsBoxLink.dataset.lastMouseEnterTime = currentTime; const frameData = await getLatestFrameData(id, broadNumber); let imgElement = thumbsBoxLink.querySelector('img'); if (!imgElement) { imgElement = document.createElement('img'); thumbsBoxLink.appendChild(imgElement); } imgElement.src = frameData; } }); } } } }; const removeUnwantedTags = () =>{ if (isRemoveCarouselEnabled) { GM_addStyle(` div[class^="player_player_wrap"] { display: none !important; } `); } if (isRemoveRedistributionTagEnabled) { GM_addStyle(` [data-type=cBox] .thumbs-box .allow { display: none !important; } `); } if (isRemoveWatchLaterButtonEnabled) { GM_addStyle(` [data-type=cBox] .thumbs-box .later { display: none !important; } `); } if (isRemoveBroadStartTimeTagEnabled) { GM_addStyle(` [data-type=cBox] .thumbs-box .time { display: none !important; } `); } if (isBroadTitleTextEllipsisEnabled) { GM_addStyle(` [data-type=cBox] .cBox-info .title a { white-space: nowrap; text-overflow: ellipsis; display: inline-block; } `); } }; const appendPauseButton = async () => { try { // 기존 버튼이 있다면 제거 const existingButton = document.body.querySelector("#closeStream"); if (existingButton) { existingButton.remove(); } // time_shift_play 버튼이 숨겨져 있을 때만 버튼 생성 const timeShiftButton = await waitForElementAsync('button#time_shift_play'); if (window.getComputedStyle(timeShiftButton).display !== 'none') return; const ctrlDiv = document.body.querySelector('div.ctrl'); if (!ctrlDiv) return; const newCloseStreamButton = document.createElement("button"); newCloseStreamButton.type = "button"; newCloseStreamButton.id = "closeStream"; newCloseStreamButton.className = "pause on"; const tooltipDiv = document.createElement("div"); tooltipDiv.className = "tooltip"; const spanElement = document.createElement("span"); spanElement.textContent = "일시정지"; tooltipDiv.appendChild(spanElement); newCloseStreamButton.appendChild(tooltipDiv); ctrlDiv.insertBefore(newCloseStreamButton, ctrlDiv.firstChild); newCloseStreamButton.addEventListener("click", (e) => { e.preventDefault(); toggleStream(newCloseStreamButton, spanElement); }); } catch (error) { console.error("스트리밍 종료 버튼 생성 실패:", error); } }; const toggleStream = (button, spanElement) => { try { if (button.classList.contains("on")) { unsafeWindow.livePlayer.closeStreamConnector(); button.classList.remove("on", "pause"); button.classList.add("off", "play"); spanElement.textContent = "재생"; } else { unsafeWindow.livePlayer._startBroad(); button.classList.remove("off", "play"); button.classList.add("on", "pause"); spanElement.textContent = "일시정지"; } } catch (error) { console.log(error); } }; const updateBjIdIfMismatch = () => { const currentUrl = window.location.href; const urlBjId = currentUrl.split('/')[3]; const infoNickName = document.querySelector('#infoNickName'); const dataBjId = infoNickName.getAttribute('data-bj_id'); const streamerNick = document.querySelector('#streamerNick'); if (dataBjId !== urlBjId) { infoNickName.setAttribute('data-bj_id', urlBjId); streamerNick.setAttribute('data-bj_id', urlBjId); } }; const emptyViewStreamer = () => { const viewStreamer = document.getElementById('view_streamer'); if (viewStreamer) { viewStreamer.innerHTML = ''; } }; const setWidthNickname = (wpx) => { if (typeof wpx === 'number' && wpx > 0) { // wpx가 유효한 값인지 확인 GM_addStyle(` .starting-line .chatting-list-item .message-container .username { width: ${wpx}px !important; } `); } else { console.warn('Invalid width value provided for setWidthNickname.'); // 유효하지 않은 값 경고 } }; const hideBadges = () => { const badgeSettings = [ { key: 'isHideSupporterBadgeEnabled', className: 'support' }, { key: 'isHideFanBadgeEnabled', className: 'fan' }, { key: 'isHideSubBadgeEnabled', className: 'sub' }, { key: 'isHideVIPBadgeEnabled', className: 'vip' }, { key: 'isHideManagerBadgeEnabled', className: 'manager' }, { key: 'isHideStreamerBadgeEnabled', className: 'streamer' } ]; // 각 배지 숨김 설정 값 가져오기 const settings = badgeSettings.map(setting => ({ key: setting.key, enabled: GM_getValue(setting.key), className: setting.className })); // 모든 배지 숨김 설정이 비활성화된 경우 종료 if (!settings.some(setting => setting.enabled)) { return; } // 활성화된 설정에 대한 CSS 규칙 생성 let cssRules = settings .filter(setting => setting.enabled) .map(setting => `[class^="grade-badge-${setting.className}"] { display: none !important; }`) .join('\n'); // 서브 배지용 CSS 규칙 추가 if (settings.find(s => s.className === 'sub' && s.enabled)) { const thumbSpanSelector = CURRENT_URL.startsWith("https://play.sooplive.co.kr/") ? '#chat_area div.username > button > span.thumb' : '#chatMemo div.username > button > span.thumb'; cssRules += `\n${thumbSpanSelector} { display: none !important; }`; } // CSS 규칙 한 번만 적용 GM_addStyle(cssRules); }; const unlockCopyPaste = (targetDiv) => { const writeArea = document.getElementById('write_area'); // 복사 기능 const handleCopy = (event) => { event.preventDefault(); // 기본 복사 동작 막기 const selectedText = window.getSelection().toString(); // 선택된 텍스트 가져오기 if (selectedText) { event.clipboardData.setData('text/plain', selectedText); // 클립보드에 텍스트 쓰기 } }; // 잘라내기 기능 const handleCut = (event) => { event.preventDefault(); // 기본 잘라내기 동작 막기 const selectedText = window.getSelection().toString(); // 선택된 텍스트 가져오기 if (selectedText) { event.clipboardData.setData('text/plain', selectedText); // 클립보드에 텍스트 쓰기 document.execCommand("delete"); // 선택된 텍스트 삭제 } }; // 붙여넣기 기능 const handlePaste = (event) => { event.preventDefault(); // 기본 붙여넣기 동작 막기 const text = (event.clipboardData || window.clipboardData).getData('text'); // 클립보드에서 텍스트 가져오기 document.execCommand("insertText", false, text); // 텍스트를 수동으로 삽입 }; // 이벤트 리스너 등록 writeArea.addEventListener('copy', handleCopy); writeArea.addEventListener('cut', handleCut); writeArea.addEventListener('paste', handlePaste); }; const alignNicknameRight = () => { GM_addStyle(` .starting-line .chatting-list-item .message-container .username > button { float: right !important; white-space: nowrap; } `); }; const hideButtonsAboveChatInput = () => { const style = ` .chatbox .actionbox .chat_item_list { display: none !important; } .chatbox .actionbox { height: auto !important; } `; GM_addStyle(style); }; const addStyleExpandLiveChat = () => { const style = ` body.expandLiveChat:not(.screen_mode,.fullScreen_mode) #serviceHeader, body.expandLiveChat:not(.screen_mode,.fullScreen_mode) .broadcast_information, body.expandLiveChat:not(.screen_mode,.fullScreen_mode) .section_selectTab, body.expandLiveChat:not(.screen_mode,.fullScreen_mode) .wrapping.player_bottom{ display: none !important; } body.expandLiveChat:not(.screen_mode,.fullScreen_mode) #webplayer_contents, body.expandLiveChat:not(.screen_mode,.fullScreen_mode) #sidebar { top: 0 !important; margin-top: 0 !important; min-height: 100vh !important; } body.expandLiveChat:not(.screen_mode,.fullScreen_mode) #webplayer #webplayer_contents .wrapping.side { padding: 0 !important; } `; GM_addStyle(style); }; const makeExpandChatButton = (el, css_class) => { if (!el) return; // li 요소 생성 const li = document.createElement('li'); li.className = 'expand-toggle-li'; // a 요소 생성 const a = document.createElement('a'); a.href = 'javascript:;'; a.setAttribute('tip', '확장/축소(x)'); a.textContent = '확장/축소(x)'; // 클릭 이벤트 등록 (a에 등록해도 되고 li에 등록해도 됨) a.addEventListener('click', () => { document.body.classList.toggle(css_class); }); // li에 a 추가, 그리고 el에 li 추가 li.appendChild(a); el.appendChild(li); }; const makeCaptureButton = () => { const svgDataUrl = 'data:image/svg+xml,%3Csvg%20width%3D%2264%22%20height%3D%2264%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20stroke%3D%22%23fff%22%3E%3Cg%20stroke-width%3D%220%22%2F%3E%3Cg%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke%3D%22%23CCC%22%20stroke-width%3D%22.048%22%2F%3E%3Cg%20stroke-width%3D%221.488%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M21%2013c0-2.667-.5-5-1-5.333-.32-.214-1.873-.428-4-.553C14.808%207.043%2017%205%2012%205S9.192%207.043%208%207.114c-2.127.125-3.68.339-4%20.553C3.5%208%203%2010.333%203%2013s.5%205%201%205.333S8%2019%2012%2019s7.5-.333%208-.667c.5-.333%201-2.666%201-5.333%22%2F%3E%3Cpath%20d%3D%22M12%2016a3%203%200%201%200%200-6%203%203%200%200%200%200%206%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E'; // 1. CSS 삽입 const style = document.createElement('style'); style.textContent = ` #player .imageCapture { overflow: visible; color: rgba(0, 0, 0, 0); width: 32px; height: 32px; margin: 0; font-size: 0; opacity: 0.9; background: url("${svgDataUrl}") 50% 50% no-repeat; background-size: 82%; border: none; padding: 0; cursor: pointer; position: relative; } #player .imageCapture:hover { opacity: 1; } `; document.head.appendChild(style); const captureVideoFrame = (shouldDownloadImmediately = false) => { const video = document.getElementById('livePlayer') || document.getElementById('video'); if (!video) { console.error('비디오 요소를 찾을 수 없습니다.'); return; } // 캔버스 생성 및 비디오의 현재 프레임 그리기 const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); // 파일명을 위한 타임스탬프 생성 const now = new Date(); const pad = n => String(n).padStart(2, '0'); const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`; const filename = `capture_${timestamp}.jpg`; // 캔버스 이미지를 JPEG Blob 객체로 변환 canvas.toBlob(blob => { if (!blob) { console.error('Blob 데이터를 생성하는데 실패했습니다.'); return; } // --- 여기서 인자에 따라 동작이 분기됩니다 --- if (shouldDownloadImmediately) { // [분기 1] 즉시 다운로드 로직 const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); // 다운로드 후 즉시 URL 해제 } else { // [분기 2] 새 탭에서 열기 로직 const imgURL = URL.createObjectURL(blob); const html = ` ScreenShot (${video.videoWidth}x${video.videoHeight}) 영상 캡쳐 이미지 `; const blobURL = URL.createObjectURL(new Blob([html], { type: 'text/html;charset=UTF-8' })); window.open(blobURL, '_blank'); // 여기서 imgURL을 해제하면 새 탭에서 이미지가 보이지 않으므로, 새 탭 내부에서 해제합니다. } }, 'image/jpeg', 0.92); }; // 2. 버튼 생성 const createButton = () => { const btn = document.createElement('button'); btn.className = 'imageCapture'; btn.type = 'button'; btn.title = '클릭: 새 탭에서 보기 / 우클릭: 바로 다운로드'; // 좌클릭: 새 탭에서 열기 btn.addEventListener('click', () => { try { // 인자를 false 또는 생략하여 호출 captureVideoFrame(false); } catch (err) { console.error('캡처 실패:', err); } }); // 우클릭: 즉시 다운로드 btn.addEventListener('contextmenu', (event) => { event.preventDefault(); // 기본 컨텍스트 메뉴 방지 try { // 인자를 true로 전달하여 호출 captureVideoFrame(true); } catch (err) { console.error('캡처 및 다운로드 실패:', err); } }); return btn; }; // 3. 버튼 삽입 const insertButton = async () => { try { const container = await waitForElementAsync('#player .player_ctrlBox .ctrlBox .right_ctrl'); if (container && !container.querySelector('.imageCapture')) { const btn = createButton(); container.insertBefore(btn, container.firstChild); } } catch (error) { console.error("버튼 추가 실패! 원인:", error.message); } }; insertButton(); }; const addStyleExpandVODChat = () => { const style = ` .expandVODChat:not(.screen_mode,.fullScreen_mode) #serviceHeader, .expandVODChat:not(.screen_mode,.fullScreen_mode) .broadcast_information, .expandVODChat:not(.screen_mode,.fullScreen_mode) .section_selectTab, .expandVODChat:not(.screen_mode,.fullScreen_mode) .wrapping.player_bottom{ display: none !important; } .expandVODChat:not(.screen_mode,.fullScreen_mode) #webplayer_contents { margin: 0 auto !important; min-height: 100vh !important; } `; GM_addStyle(style); }; const addStyleRemoveShadowsFromCatch = () => { const style = ` .catch_webplayer_wrap .vod_player:after { background-image: none !important; } `; GM_addStyle(style); }; const setupExpandVODChatFeature = async() => { try { const element = await waitForElementAsync('#chatting_area div.area_header > div.chat_title > ul', 15000); // 15초 타임아웃 addStyleExpandVODChat(); makeExpandChatButton(element, 'expandVODChat'); // `await`로 받은 element를 사용 toggleExpandChatShortcut(); updateBodyClass('expandVODChat'); window.addEventListener('resize', debounce(() => updateBodyClass('expandVODChat'), 500)); } catch (error) { console.error("VOD 채팅 확장 기능 설정에 실패했습니다:", error.message); } }; const setupExpandLiveChatFeature = async () => { try { // 1. 첫 번째 조건: 채팅창 헤더가 나타날 때까지 기다립니다. const element = await waitForElementAsync('#chatting_area div.area_header > div.chat_title > ul'); // 2. 두 번째 조건: body에 'ratio169_mode' 클래스가 추가될 때까지 기다립니다. //await waitForConditionAsync(() => document.body.classList.contains('ratio169_mode')); // 3. 모든 조건이 충족되었으므로, 이제 기능들을 순서대로 실행합니다. addStyleExpandLiveChat(); makeExpandChatButton(element, 'expandLiveChat'); toggleExpandChatShortcut(); updateBodyClass('expandLiveChat'); window.addEventListener('resize', debounce(() => updateBodyClass('expandLiveChat'), 500)); } catch (error) { // console.error("setupExpandLiveChatFeature 실패:", error.message); } } const setupSettingButtonTopbar = async () => { const serviceUtilDiv = await waitForElementAsync('div.serviceUtil'); addModalSettings(serviceUtilDiv); const openModalBtnDiv = await waitForElementAsync('#openModalBtn > button'); manageRedDot(openModalBtnDiv); }; // --- 리캡 관련 유틸리티 함수 --- function formatDate(date) { const y = date.getFullYear(), m = String(date.getMonth() + 1).padStart(2, '0'), d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } function formatSecondsToHMS(totalSeconds) { if (totalSeconds === 0) return '0초'; const h = Math.floor(totalSeconds / 3600), m = Math.floor((totalSeconds % 3600) / 60), s = totalSeconds % 60; let p = []; if (h > 0) p.push(h + '시간'); if (m > 0) p.push(m + '분'); if (s > 0 || p.length === 0) p.push(s + '초'); return p.join(' '); } function formatSecondsToHM(seconds) { const totalMinutes = Math.round(seconds / 60); if (totalMinutes < 1) return '1분 미만'; const h = Math.floor(totalMinutes / 60), m = totalMinutes % 60; let p = []; if (h > 0) p.push(h + '시간'); if (m > 0) p.push(m + '분'); return p.join(' ') || '0분'; } function formatAxisSeconds(seconds) { if (seconds === 0) return '0'; if (seconds >= 3600) return Math.round(seconds / 3600) + '시간'; // Show hours if (seconds >= 60) return Math.round(seconds / 60) + '분'; // Show minutes return seconds + '초'; // Show seconds } function parseHMSToSeconds(timeString) { if (!timeString || typeof timeString !== 'string') return 0; const parts = timeString.split(':').map(Number); while (parts.length < 3) { parts.unshift(0); // H나 M이 없는 경우를 위해 배열 앞쪽에 0을 추가 } const [h, m, s] = parts; return (h * 3600) + (m * 60) + s; } function formatSecondsToHHMMSS(totalSeconds) { if (totalSeconds === 0) return '00:00:00'; const h = String(Math.floor(totalSeconds / 3600)).padStart(2, '0'); const m = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0'); const s = String(totalSeconds % 60).padStart(2, '0'); return `${h}:${m}:${s}`; } function createPlaceholderSvg(text) { const svg = `${text}`; return `data:image/svg+xml,${encodeURIComponent(svg)}`; } // --- 리캡 관련 API 호출 및 데이터 처리 함수 --- function getUserInfo() { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: INFO_API_URL, onload: (res) => { try { const d = JSON.parse(res.responseText); if (d?.CHANNEL?.IS_LOGIN === 1 && d.CHANNEL.LOGIN_ID) { resolve({ id: d.CHANNEL.LOGIN_ID, nick: d.CHANNEL.LOGIN_NICK }); } else { reject(new Error('로그인 정보를 찾을 수 없습니다.')); } } catch (e) { reject(new Error('로그인 정보 파싱 실패')); } }, onerror: (err) => { reject(new Error('로그인 정보 API 요청 실패')); } }); }); } function fetchData(userId, startDate, endDate, module) { return new Promise((resolve, reject) => { const p = new URLSearchParams({ szModule: module, szMethod: 'watch', szStartDate: startDate, szEndDate: endDate, nPage: 1, szId: userId }); GM_xmlhttpRequest({ method: "POST", url: STATS_API_URL, data: p.toString(), headers: { "Content-Type": "application/x-www-form-urlencoded" }, onload: (res) => { if (res.status >= 200 && res.status < 300) { resolve(JSON.parse(res.responseText)); } else { reject(new Error(`통계 데이터 요청 실패: ${res.statusText}`)); } }, onerror: (err) => { reject(new Error(`통계 API 요청 실패`)); } }); }); } async function getStreamerProfileUrl(originalNick) { const search = (searchTerm) => new Promise(resolve => { const params = new URLSearchParams({ m: 'searchHistory', service: 'list', d: searchTerm }); GM_xmlhttpRequest({ method: "GET", url: `${SEARCH_API_URL}?${params.toString()}`, onload: (res) => { try { const data = JSON.parse(res.responseText); const exactMatch = data?.suggest_bj?.find(s => s.user_nick === originalNick); resolve(exactMatch ? exactMatch.station_logo : null); } catch { resolve(null); } }, onerror: () => resolve(null) }); }); let logoUrl = await search(originalNick); if (logoUrl) return logoUrl; const sanitizedNick = originalNick.replace(/[^\p{L}\p{N}\s]/gu, ''); if (sanitizedNick !== originalNick) { logoUrl = await search(sanitizedNick); if (logoUrl) return logoUrl; } return null; } function imageToDataUri(url) { return new Promise(resolve => { if (!url) { resolve(null); return; } GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'blob', onload: function(response) { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.onerror = () => resolve(null); reader.readAsDataURL(response.response); }, onerror: () => resolve(null) }); }); } async function getCategoryImageMap() { if (categoryImageMap) return categoryImageMap; return new Promise((resolve) => { const params = new URLSearchParams({ m: 'categoryList', szOrder: 'prefer', nListCnt: 200 }); GM_xmlhttpRequest({ method: "GET", url: `${CATEGORY_API_URL}?${params.toString()}`, onload: (res) => { try { const data = JSON.parse(res.responseText); const map = new Map(); data?.data?.list?.forEach(cat => map.set(cat.category_name, cat.cate_img)); categoryImageMap = map; resolve(map); } catch { resolve(new Map()); } }, onerror: () => resolve(new Map()) }); }); } // --- UI 렌더링 함수 --- async function renderAll(streamerRawData, categoryRawData, userInfo, categoryImages) { const wrapper = document.getElementById('recap-content-wrapper'); wrapper.innerHTML = ''; const streamerData = streamerRawData?.data || {}; const categoryData = categoryRawData?.data || {}; const stats = streamerData?.broad_cast_info?.data || { average_watch_time: 0, cumulative_watch_time: 0 }; const visitedDays = streamerData?.table1?.data?.filter(d => d.total_watch_time !== '00:00:00').length || 0; const allStreamersRaw = streamerData?.chart?.data_stack?.map(s => ({ nick: s.bj_nick, total: s.data.reduce((a, b) => a + b, 0) })) || []; const otherEntry = allStreamersRaw.find(s => s.nick === '기타'); const sortedStreamers = allStreamersRaw.filter(s => s.nick !== '기타').sort((a, b) => b.total - a.total); const allStreamersSorted = otherEntry ? [...sortedStreamers, otherEntry] : sortedStreamers; const top4Streamers = sortedStreamers.slice(0, 4); const rankedCategories = categoryData?.table2?.data || []; const profilePicUrl = `https://profile.img.sooplive.co.kr/LOGO/${userInfo.id.substring(0, 2)}/${userInfo.id}/${userInfo.id}.jpg`; const profileDataUri = await imageToDataUri(profilePicUrl); const placeholderUserAvatar = createPlaceholderSvg(userInfo.nick.substring(0,1)); const profileHeader = document.createElement('div'); profileHeader.className = 'recap-profile-header'; profileHeader.innerHTML = `${userInfo.nick}님`; wrapper.appendChild(profileHeader); const keyStatsGrid = document.createElement('div'); keyStatsGrid.className = 'key-stats-grid'; keyStatsGrid.innerHTML = `
평균 ${formatSecondsToHM(stats.average_watch_time)}
${formatSecondsToHM(stats.cumulative_watch_time).replace(/(\d+)([가-힣]+)/g, '$1$2')}
이 달의 출석
${visitedDays}
`; wrapper.appendChild(keyStatsGrid); const topStreamersSection = document.createElement('div'); topStreamersSection.innerHTML = `
많이 본 방송
`; const topContainer = document.createElement('div'); topContainer.className = 'top-streamers-container'; topStreamersSection.appendChild(topContainer); wrapper.appendChild(topStreamersSection); const avatarHttpUrls = await Promise.all(top4Streamers.map(s => getStreamerProfileUrl(s.nick))); const avatarDataUris = await Promise.all(avatarHttpUrls.map(url => imageToDataUri(url))); const streamerCardHTML = (streamer, avatarUri) => { const placeholder = createPlaceholderSvg(streamer.nick.substring(0, 1)); return `
${streamer.nick}
${formatSecondsToHM(streamer.total)}
`; }; const [s1, s2, s3, s4] = top4Streamers; if (s1) topContainer.innerHTML += `
${streamerCardHTML(s1, avatarDataUris[0])}
`; if (s2) topContainer.innerHTML += `
${streamerCardHTML(s2, avatarDataUris[1])}
`; if (s3) topContainer.innerHTML += `
${streamerCardHTML(s3, avatarDataUris[2])}
${s4 ? `
${streamerCardHTML(s4, avatarDataUris[3])}
` : ''}
`; const streamerCards = wrapper.querySelectorAll('.top-streamers-container .streamer-card'); streamerCards.forEach(card => { const rank = parseInt(card.dataset.rank, 10); if (rank === 1 || rank === 2) { card.addEventListener('mousemove', (e) => { const rect = card.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const x = (mouseX / rect.width) - 0.5; const y = (mouseY / rect.height) - 0.5; let sensitivity = 0; let translateZ = 0; switch(rank) { case 1: sensitivity = 20; translateZ = 25; break; case 2: sensitivity = 15; break; } const rotateY = x * sensitivity; const rotateX = -y * sensitivity; card.style.setProperty('--mouse-x', `${mouseX}px`); card.style.setProperty('--mouse-y', `${mouseY}px`); card.style.setProperty('--mouse-x-percent', `${(mouseX / rect.width) * 200 - 50}%`); card.style.setProperty('--mouse-active', '1'); const angle = Math.atan2(y, x) * (180 / Math.PI) + 90; card.style.setProperty('--angle', `${angle}deg`); card.style.transform = `perspective(1200px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) translateZ(${translateZ}px)`; }); card.addEventListener('mouseleave', () => { card.style.transform = 'perspective(1200px) rotateX(0) rotateY(0) translateZ(0)'; card.style.setProperty('--mouse-active', '0'); }); } }); const rankExpandButton = document.createElement('button'); rankExpandButton.className = 'expand-button'; rankExpandButton.textContent = '전체 채널 순위 보기 ▾'; wrapper.appendChild(rankExpandButton); const fullRankContainer = document.createElement('div'); fullRankContainer.id = 'full-ranking-chart-container'; fullRankContainer.style.display = 'none'; wrapper.appendChild(fullRankContainer); let isRankChartRendered = false; const renderAndToggleRankChart = () => { const isHidden = fullRankContainer.style.display === 'none'; fullRankContainer.style.display = isHidden ? 'block' : 'none'; rankExpandButton.textContent = isHidden ? '숨기기 ▴' : '전체 채널 순위 보기 ▾'; if (isHidden && !isRankChartRendered) { const colors = ['#8e44ad', '#3498db', '#e74c3c', '#16a085', '#f39c12', '#2c3e50', '#d35400', '#c0392b', '#1abc9c', '#f1c40f']; const container = document.getElementById('full-ranking-chart-container'); const chartHeight = Math.max(400, allStreamersSorted.length * 28); container.style.height = `${chartHeight}px`; const canvas = document.createElement('canvas'); container.appendChild(canvas); activeCharts.push(new Chart(canvas, { type: 'bar', data: { labels: allStreamersSorted.map(s => s.nick), datasets: [{ label: '총 시청 시간', data: allStreamersSorted.map(s => s.total), backgroundColor: colors }] }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (c) => formatSecondsToHMS(c.parsed.x) } } }, scales: { x: { ticks: { color: '#efeff1', callback: (value) => formatAxisSeconds(value) }, grid: { color: 'rgba(239, 239, 241, 0.1)' } }, y: { ticks: { color: '#efeff1', autoSkip: false }, grid: { color: 'rgba(239, 239, 241, 0.1)' } } } } })); isRankChartRendered = true; } }; rankExpandButton.addEventListener('click', renderAndToggleRankChart); const dailyExpandButton = document.createElement('button'); dailyExpandButton.className = 'expand-button'; dailyExpandButton.textContent = '일별 통계 보기 ▾'; wrapper.appendChild(dailyExpandButton); const dailyStatsContainer = document.createElement('div'); dailyStatsContainer.id = 'daily-stats-container'; dailyStatsContainer.style.display = 'none'; const tableData = streamerData?.table1; if (tableData?.data) { let tableHTML = ``; tableData.data.forEach(row => { tableHTML += ``; }); dailyStatsContainer.innerHTML = tableHTML + '
${tableData.column_name.day}${tableData.column_name.total_watch_time}
${row.day}${row.total_watch_time}
'; } wrapper.appendChild(dailyStatsContainer); dailyExpandButton.addEventListener('click', () => { const isHidden = dailyStatsContainer.style.display === 'none'; dailyStatsContainer.style.display = isHidden ? 'block' : 'none'; dailyExpandButton.textContent = isHidden ? '숨기기 ▴' : '일별 통계 보기 ▾'; }); const categorySection = document.createElement('div'); categorySection.innerHTML = `
자주 본 카테고리
`; const categoryGrid = document.createElement('div'); categoryGrid.className = 'category-grid'; const totalCategoryCount = rankedCategories.reduce((sum, cat) => sum + parseInt(cat.cnt, 10), 0); const createCategoryCardHTML = (cat) => { const imgUrl = categoryImages.get(cat.skey) || createPlaceholderSvg(cat.skey.substring(0,1)); const percentage = totalCategoryCount > 0 ? ((cat.cnt / totalCategoryCount) * 100).toFixed(1) : 0; return `
#${cat.rank}
${cat.skey}
${percentage}%
`; }; const top5Categories = rankedCategories.slice(0, 5); const restCategories = rankedCategories.slice(5); top5Categories.forEach(cat => { categoryGrid.innerHTML += createCategoryCardHTML(cat); }); categorySection.appendChild(categoryGrid); if (restCategories.length > 0) { const catExpandButton = document.createElement('button'); catExpandButton.className = 'expand-button'; catExpandButton.textContent = '더보기 ▾'; const moreCategoriesContainer = document.createElement('div'); moreCategoriesContainer.className = 'category-grid'; moreCategoriesContainer.style.display = 'none'; restCategories.forEach(cat => { moreCategoriesContainer.innerHTML += createCategoryCardHTML(cat); }); catExpandButton.addEventListener('click', () => { const isHidden = moreCategoriesContainer.style.display === 'none'; moreCategoriesContainer.style.display = isHidden ? 'grid' : 'none'; catExpandButton.textContent = isHidden ? '숨기기 ▴' : '더보기 ▾'; }); categorySection.appendChild(catExpandButton); categorySection.appendChild(moreCategoriesContainer); } wrapper.appendChild(categorySection); const otherInfoSection = document.createElement('div'); otherInfoSection.innerHTML = `
기타 정보
`; const chartContainer = document.createElement('div'); chartContainer.className = 'recap-container'; otherInfoSection.appendChild(chartContainer); wrapper.appendChild(otherInfoSection); const createCard = (title) => { const c = document.createElement('div'); c.className = 'recap-card'; const t = document.createElement('h2'); t.textContent = title; const w = document.createElement('div'); w.className = 'chart-wrapper'; const n = document.createElement('canvas'); w.appendChild(n); c.appendChild(t); c.appendChild(w); chartContainer.appendChild(c); return n; }; if (streamerData?.barchart?.device) { const deviceCanvas = createCard('시청 환경'); const deviceLabels = Object.keys(streamerData.barchart.device).map(key => deviceTranslations[key] || key); activeCharts.push(new Chart(deviceCanvas, { type: 'doughnut', data: { labels: deviceLabels, datasets: [{ data: Object.values(streamerData.barchart.device), backgroundColor: ['#5dade2', '#a9cce3'], borderColor: '#2e2e33' }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#efeff1' } } } } })); } // ▼▼▼ [추가] VOD 유형 차트 렌더링 ▼▼▼ if (streamerData?.barchart?.vod_type) { const vodTypeData = streamerData.barchart.vod_type; // 값이 0보다 큰 데이터만 필터링 const filteredVodTypes = Object.entries(vodTypeData).filter(([, value]) => value > 0); if (filteredVodTypes.length > 0) { const vodTypeCanvas = createCard('VOD 유형'); const vodTypeLabels = filteredVodTypes.map(([key]) => vodTypeTranslations[key] || key); const vodTypeValues = filteredVodTypes.map(([, value]) => value); activeCharts.push(new Chart(vodTypeCanvas, { type: 'doughnut', data: { labels: vodTypeLabels, datasets: [{ data: vodTypeValues, backgroundColor: chartColors, // 미리 정의한 색상 팔레트 사용 borderColor: '#2e2e33' }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#efeff1' } } } } })); } } // ▲▲▲ [추가] VOD 유형 차트 렌더링 ▲▲▲ if (streamerData?.barchart?.bj_type) { const typeCanvas = createCard('스트리머 유형 분포'); const typeLabels = Object.keys(streamerData.barchart.bj_type).map(key => typeTranslations[key] || key); activeCharts.push(new Chart(typeCanvas, { type: 'bar', data: { labels: typeLabels, datasets: [{ data: Object.values(streamerData.barchart.bj_type), backgroundColor: ['#ff6b6b', '#feca57', '#1dd1a1'] }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: '#efeff1' } }, y: {} } } })); } } function mergeData(liveData, vodData, type) { if (!liveData || liveData.result !== 1) return vodData || { result: 1, data: {} }; if (!vodData || vodData.result !== 1) return liveData || { result: 1, data: {} }; const merged = JSON.parse(JSON.stringify(liveData)); // Deep copy if (type === 'streamer') { const liveInfo = liveData.data.broad_cast_info.data; const vodInfo = vodData.data.broad_cast_info.data; const mergedInfo = merged.data.broad_cast_info.data; if (vodInfo) { mergedInfo.cumulative_watch_time += vodInfo.cumulative_watch_time; mergedInfo.top_watch_time = Math.max(liveInfo.top_watch_time, vodInfo.top_watch_time); // 평균 시청 시간은 누적 시간을 기준으로 재계산 (방문일수 데이터가 없으므로 단순 합산은 부정확) // 여기서는 일단 누적 시간 합산에 집중합니다. } const streamerTotals = new Map(); liveData.data.chart.data_stack?.forEach(s => { if (s.bj_nick !== '기타') streamerTotals.set(s.bj_nick, s.data.reduce((a, b) => a + b, 0)); }); vodData.data.chart.data_stack?.forEach(s => { if (s.bj_nick !== '기타') streamerTotals.set(s.bj_nick, (streamerTotals.get(s.bj_nick) || 0) + s.data.reduce((a, b) => a + b, 0)); }); const sortedStreamers = Array.from(streamerTotals.entries()).sort((a, b) => b[1] - a[1]); // renderAll 함수가 기대하는 data_stack 포맷으로 재구성 merged.data.chart.data_stack = sortedStreamers.map(([nick, total]) => ({ bj_nick: nick, data: [total] // 합산된 총 시간을 data 배열에 넣음 })); const dailyTotals = new Map(); // 1. Live 데이터의 일별 시청 시간을 초 단위로 변환하여 Map에 저장 liveData.data.table1?.data?.forEach(row => { dailyTotals.set(row.day, parseHMSToSeconds(row.total_watch_time)); }); // 2. VOD 데이터의 일별 시청 시간을 기존 값에 더해줌 vodData.data.table1?.data?.forEach(row => { const existingSeconds = dailyTotals.get(row.day) || 0; dailyTotals.set(row.day, existingSeconds + parseHMSToSeconds(row.total_watch_time)); }); // 3. 합산된 데이터를 다시 table1.data 형식으로 변환 if (merged.data.table1) { // table1 객체가 존재하는지 확인 const sortedDays = Array.from(dailyTotals.entries()).sort((a, b) => a[0].localeCompare(b[0])); merged.data.table1.data = sortedDays.map(([day, totalSeconds]) => ({ day: day, total_watch_time: formatSecondsToHHMMSS(totalSeconds) })); } } else if (type === 'category') { const categoryTotals = new Map(); liveData.data.table2?.data?.forEach(c => categoryTotals.set(c.skey, parseInt(c.cnt, 10))); vodData.data.table2?.data?.forEach(c => categoryTotals.set(c.skey, (categoryTotals.get(c.skey) || 0) + parseInt(c.cnt, 10))); const sortedCategories = Array.from(categoryTotals.entries()).sort((a, b) => b[1] - a[1]); merged.data.table2.data = sortedCategories.map(([skey, cnt], index) => ({ rank: index + 1, skey: skey, cnt: String(cnt) })); } return merged; } // --- 이벤트 핸들러 함수 --- async function handleFetchButtonClick() { const loader = document.getElementById('recap-loader'); const wrapper = document.getElementById('recap-content-wrapper'); loader.style.display = 'block'; wrapper.innerHTML = ''; wrapper.style.display = 'none'; activeCharts.forEach(chart => chart.destroy()); activeCharts = []; try { const userInfo = await getUserInfo(); const typeSelector = document.getElementById('recap-type-selector'); const monthSelector = document.getElementById('recap-month-selector'); const selectedType = typeSelector.value; const [year, month] = monthSelector.value.split('-').map(Number); const today = new Date(); const currentYear = today.getFullYear(); const currentMonth = today.getMonth() + 1; let startDate, endDate; if (year === currentYear && month === currentMonth) { startDate = new Date(year, month - 1, 1); const yesterday = new Date(); yesterday.setDate(today.getDate() - 1); endDate = yesterday; } else { startDate = new Date(year, month - 1, 1); endDate = new Date(year, month, 0); } const formattedStartDate = formatDate(startDate); const formattedEndDate = formatDate(endDate); const modules = { live: { streamer: 'UserLiveWatchTimeData', category: 'UserLiveSearchKeywordData' }, vod: { streamer: 'UserVodWatchTimeData', category: 'UserVodSearchKeywordData' } }; if (selectedType === 'live' || selectedType === 'vod') { const [streamerResponse, categoryResponse, categoryImages] = await Promise.all([ fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules[selectedType].streamer), fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules[selectedType].category), getCategoryImageMap() ]); if (streamerResponse.result === 1 && categoryResponse.result === 1) { await renderAll(streamerResponse, categoryResponse, userInfo, categoryImages); } else { wrapper.innerHTML = `

데이터를 불러오는 데 실패했습니다.

`; } } else if (selectedType === 'combined') { const [liveStreamer, liveCategory, vodStreamer, vodCategory, categoryImages] = await Promise.all([ fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules.live.streamer), fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules.live.category), fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules.vod.streamer), fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules.vod.category), getCategoryImageMap() ]); const mergedStreamerData = mergeData(liveStreamer, vodStreamer, 'streamer'); const mergedCategoryData = mergeData(liveCategory, vodCategory, 'category'); await renderAll(mergedStreamerData, mergedCategoryData, userInfo, categoryImages); } } catch (error) { console.error("[리캡 스크립트] Error:", error); wrapper.innerHTML = `

오류 발생: ${error.message}

`; } finally { loader.style.display = 'none'; wrapper.style.display = 'block'; } } async function captureScreenshot(options = {}) { const modalBody = document.querySelector('.recap-modal-body'); const modalPanel = document.getElementById('recap-modal-panel'); const button = document.getElementById('recap-screenshot-btn'); const originalButtonContent = button.innerHTML; button.innerHTML = '...'; button.disabled = true; // --- 원본 스타일 및 요소 상태 저장 --- const originalPanelHeight = modalPanel.style.height; const originalBodyOverflow = modalBody.style.overflowY; const cardElements = modalBody.querySelectorAll('.top-streamers-container .streamer-card'); const originalCardStyles = []; const profileHeader = modalBody.querySelector('.recap-profile-header'); let originalProfileDisplay = ''; // --- 스크린샷용 임시 요소 생성 및 수정 --- const typeSelector = document.getElementById('recap-type-selector'); const monthSelector = document.getElementById('recap-month-selector'); const selectedType = typeSelector.value; const selectedTypeText = typeSelector.options[typeSelector.selectedIndex].text; const screenshotTitle = document.createElement('div'); screenshotTitle.id = 'screenshot-title-temp'; // [수정] 제목에 데이터 타입을 괄호로 묶어 바로 추가합니다. screenshotTitle.textContent = `${monthSelector.options[monthSelector.selectedIndex].text} 시청 요약 (${selectedTypeText})`; // --- 스크린샷 전처리 --- cardElements.forEach((el, i) => { const bgChild = el.querySelector('.streamer-card-bg'); originalCardStyles.push({ card: el, bgChild: bgChild, cardBg: el.style.background, childDisplay: bgChild?.style.display }); el.style.background = screenshotGradientPalette[i % screenshotGradientPalette.length]; if (bgChild) bgChild.style.display = 'none'; }); if (options.hideProfile && profileHeader) { originalProfileDisplay = profileHeader.style.display; profileHeader.style.display = 'none'; } try { modalBody.prepend(screenshotTitle); // 수정된 제목 추가 modalPanel.style.height = 'auto'; modalBody.style.overflowY = 'visible'; const canvas = await html2canvas(modalBody, { allowTaint: true, useCORS: true, backgroundColor: '#18181b', logging: false, }); const link = document.createElement('a'); const date = new Date(); const timestamp = `${date.getFullYear()}${(date.getMonth()+1).toString().padStart(2,'0')}${date.getDate().toString().padStart(2,'0')}`; link.download = `recap-${selectedType}-${timestamp}.png`; link.href = canvas.toDataURL("image/png"); link.click(); } catch(err) { console.error("스크린샷 생성 오류:", err); alert("스크린샷 생성에 실패했습니다."); } finally { // --- 모든 변경사항 원래대로 복구 --- button.innerHTML = originalButtonContent; button.disabled = false; modalPanel.style.height = originalPanelHeight; modalBody.style.overflowY = originalBodyOverflow; originalCardStyles.forEach(item => { item.card.style.background = item.cardBg; if (item.bgChild) item.bgChild.style.display = item.childDisplay; }); if (options.hideProfile && profileHeader) { profileHeader.style.display = originalProfileDisplay; } screenshotTitle.remove(); // 임시 제목 요소 제거 } } function createRecapModule() { // 이미 UI가 생성되었다면 함수를 즉시 종료하여 중복 생성을 방지합니다. if (recapModalBackdrop) { console.warn("Recap module UI is already created. Skipping creation."); return; } // --- 1. 스타일(CSS) 주입 --- GM_addStyle(` #recap-modal-backdrop { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.6); z-index: 10000; display: flex; justify-content: center; align-items: center; } #recap-modal-panel { background-color: #18181b; color: #efeff1; width: 90%; max-width: 1000px; height: 90vh; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); display: flex; flex-direction: column; overflow: hidden; } .recap-modal-header { padding: 15px 25px; border-bottom: 1px solid #4f4f54; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; } .recap-modal-header h1 { margin: 0; font-size: 20px; color: #5dade2; } #recap-screenshot-btn, #recap-modal-close-btn { background: none; border: none; color: #efeff1; font-size: 24px; cursor: pointer; width: 36px; height: 36px; display: grid; place-items: center; border-radius: 50%; } #recap-screenshot-btn:hover, #recap-modal-close-btn:hover { background-color: #2e2e33; } .recap-modal-header-buttons { display: flex; align-items: center; gap: 10px; } .recap-modal-controls { display: flex; justify-content: center; align-items: center; gap: 15px; padding: 20px; border-bottom: 1px solid #4f4f54; flex-shrink: 0; } .recap-modal-controls select, .recap-modal-controls button { padding: 10px 15px; border-radius: 6px; border: 1px solid #4f4f54; background-color: #2e2e33; color: #efeff1; font-size: 16px; } .recap-modal-controls button { background-color: #5dade2; border-color: #5dade2; cursor: pointer; } .recap-modal-controls button:hover { background-color: #4a9fce; } .recap-modal-body { padding: 20px; overflow-y: auto; flex-grow: 1; } #recap-loader { text-align: center; padding: 40px; font-size: 18px; } .recap-profile-header { display: flex; align-items: center; gap: 20px; margin-bottom: 20px; } .profile-pic { width: 70px; height: 70px; border-radius: 50%; border: 3px solid #5dade2; } .profile-name { font-size: 24px; font-weight: bold; } .key-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; } .stat-card { border-radius: 8px; padding: 20px; text-align: center; color: #333; } .stat-card.time { background: linear-gradient(135deg, #ff9a9e 0%, #fad0c4 100%); } .stat-card.days { background: linear-gradient(135deg, #a1c4fd 0%, #c2e9fb 100%); } .stat-card .label { font-size: 14px; opacity: 0.8; } .stat-card .value { font-size: 48px; font-weight: bold; line-height: 1.1; } .stat-card .unit { font-size: 24px; margin-left: 5px; } .section-title { font-size: 20px; font-weight: bold; margin-bottom: 15px; } .top-streamers-container { display: flex; gap: 15px; height: 320px; margin-bottom: 15px; } .streamer-card { border-radius: 8px; padding: 15px; position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: center; align-items: center; transition: background-color 0.3s; } .streamer-card-bg { position: absolute; top: -10%; left: -10%; width: 120%; height: 120%; background-size: cover; background-position: center; filter: blur(8px) brightness(0.7); z-index: 1; transition: all 0.2s; } .streamer-card-content { z-index: 2; color: white; text-align: center; } .streamer-card-avatar { border-radius: 50%; border: 2px solid white; transition: all 0.2s; } .streamer-card-name, .streamer-card-time { text-shadow: 1px 1px 4px rgba(0,0,0,0.8); } .streamer-card-name { font-weight: bold; transition: all 0.2s; } .streamer-card-time { opacity: 0.9; transition: all 0.2s; } .streamer-col-1 { flex: 2; } .streamer-col-2 { flex: 1; } .streamer-col-3 { flex: 1; display: flex; flex-direction: column; gap: 15px; } .streamer-col-3 .streamer-card { flex: 1; } .streamer-col-1 .streamer-card-avatar, .streamer-col-2 .streamer-card-avatar { width: 100px; height: 100px; margin-bottom: 10px; } .streamer-col-1 .streamer-card-name { font-size: 40px; } .streamer-col-1 .streamer-card-time { font-size: 25px; margin-top: 5px; } .streamer-col-2 .streamer-card-name { font-size: 30px; } .streamer-col-2 .streamer-card-time { font-size: 20px; margin-top: 5px; } .streamer-col-3 .streamer-card-avatar { width: 70px; height: 70px; margin-bottom: 5px; } .streamer-col-3 .streamer-card-name { font-size: 20px; } .streamer-col-3 .streamer-card-time { font-size: 16px; } .category-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; } .category-card { width: 180px; height: 240px; border-radius: 8px; background-size: cover; background-position: center; position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: flex-end; padding: 10px; background-color: #2e2e33; } .category-card::after { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 50%); border-radius: 8px;} .category-info { z-index: 2; color: white; font-weight: bold; font-size: 14px; text-shadow: 1px 1px 3px rgba(0,0,0,0.7); } .category-info .rank { font-size: 18px; } .category-info .percent { font-size: 12px; opacity: 0.8; } .expand-button { width: 100%; padding: 10px; background-color: #2e2e33; color: #efeff1; border: 1px solid #4f4f54; border-radius: 6px; cursor: pointer; margin-top: 15px; margin-bottom: 10px; } #full-ranking-chart-container { margin-bottom: 20px; } #daily-stats-container table { width: 100%; border-collapse: collapse; } #daily-stats-container th, #daily-stats-container td { text-align: left; padding: 12px; border-bottom: 1px solid #4f4f54; } #daily-stats-container th { background-color: #3a3a40; } .recap-container { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } .recap-card { background-color: #2e2e33; border-radius: 8px; padding: 20px; display: flex; flex-direction: column; min-height: 300px; } .recap-card h2 { flex-shrink: 0; margin-top: 0; border-bottom: 1px solid #4f4f54; padding-bottom: 10px; } .chart-wrapper { position: relative; flex-grow: 1; min-height: 0; } #screenshot-title-temp { font-size: 24px; font-weight: bold; text-align: center; margin-bottom: 20px; color: #efeff1; } .streamer-card { border-radius: 8px; padding: 15px; position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: center; align-items: center; transition: background-color 0.3s; } .streamer-card-bg { position: absolute; top: -10%; left: -10%; width: 120%; height: 120%; background-size: cover; background-position: center; filter: blur(8px) brightness(0.7); z-index: 1; transition: all 0.2s; } .streamer-card-content { z-index: 2; color: white; text-align: center; } .streamer-card-avatar { border-radius: 50%; border: 2px solid white; transition: all 0.2s; } .streamer-card-name, .streamer-card-time { text-shadow: 1px 1px 4px rgba(0,0,0,0.8); } .streamer-card-name { font-weight: bold; transition: all 0.2s; } .streamer-card-time { opacity: 0.9; transition: all 0.2s; } .streamer-col-1 { flex: 2; } .streamer-col-2 { flex: 1; } .streamer-col-3 { flex: 1; display: flex; flex-direction: column; gap: 15px; } .streamer-col-3 .streamer-card { flex: 1; } .streamer-col-1 .streamer-card-avatar, .streamer-col-2 .streamer-card-avatar { width: 100px; height: 100px; margin-bottom: 10px; } .streamer-col-1 .streamer-card-name { font-size: 40px; } .streamer-col-1 .streamer-card-time { font-size: 25px; margin-top: 5px; } .streamer-col-2 .streamer-card-name { font-size: 30px; } .streamer-col-2 .streamer-card-time { font-size: 20px; margin-top: 5px; } .streamer-col-3 .streamer-card-avatar { width: 70px; height: 70px; margin-bottom: 5px; } .streamer-col-3 .streamer-card-name { font-size: 20px; } .streamer-col-3 .streamer-card-time { font-size: 16px; } @keyframes rank1-avatar-glow { 0% { box-shadow: 0 0 10px 2px rgba(255, 215, 0, 0.5); } 50% { box-shadow: 0 0 16px 5px rgba(255, 235, 150, 0.7); } 100% { box-shadow: 0 0 10px 2px rgba(255, 215, 0, 0.5); } } .streamer-card[data-rank="1"] .streamer-card-avatar { border-color: #ffbf00c9; animation: rank1-avatar-glow 5s ease-in-out infinite; } `); // --- 2. UI 요소 생성 및 DOM에 추가 --- recapModalBackdrop = document.createElement('div'); recapModalBackdrop.id = 'recap-modal-backdrop'; // [수정] 데이터 타입 선택 UI 추가 recapModalBackdrop.innerHTML = `

월별 방송 데이터 리캡

조회할 타입을 선택하고 '데이터 조회' 버튼을 눌러주세요.

`; document.body.appendChild(recapModalBackdrop); // --- 3. 초기 이벤트 리스너 연결 --- const monthSelector = document.getElementById('recap-month-selector'); populateMonthSelector(monthSelector); // monthSelector를 인자로 전달하여 해당 select 요소에 옵션 채우기 document.getElementById('recap-modal-close-btn').addEventListener('click', () => { recapModalBackdrop.style.display = 'none'; }); const screenshotBtn = document.getElementById('recap-screenshot-btn'); // 기본 좌클릭: 프로필 포함하여 스크린샷 screenshotBtn.addEventListener('click', () => captureScreenshot()); // 우클릭: 프로필 숨기고 스크린샷 screenshotBtn.addEventListener('contextmenu', (e) => { e.preventDefault(); // 브라우저 기본 우클릭 메뉴 방지 captureScreenshot({ hideProfile: true }); // hideProfile 옵션을 주어 호출 }); recapModalBackdrop.addEventListener('click', (e) => { // 모달 배경 클릭 시 닫기 if (e.target === recapModalBackdrop) { recapModalBackdrop.style.display = 'none'; } }); document.getElementById('recap-fetch-button').addEventListener('click', handleFetchButtonClick); // 모달을 기본적으로 숨김 상태로 시작 recapModalBackdrop.style.display = 'none'; }; function populateMonthSelector(selectorElement) { selectorElement.innerHTML = ''; const today = new Date(); const limitDate = new Date(); limitDate.setDate(today.getDate() - 90); // 90일(3개월) 제한 for (let i = 0; i < 12; i++) { // 최대 12개월 전까지 const dateOption = new Date(today.getFullYear(), today.getMonth() - i, 1); const lastDayOfMonth = new Date(dateOption.getFullYear(), dateOption.getMonth() + 1, 0); // 현재 날짜로부터 90일 이내의 월만 표시 if (lastDayOfMonth < limitDate) { break; } const year = dateOption.getFullYear(); const month = dateOption.getMonth() + 1; const option = document.createElement('option'); option.value = `${year}-${String(month).padStart(2, '0')}`; option.textContent = `${year}년 ${month}월`; selectorElement.appendChild(option); } }; function toggleRecapModule(forceShow = false) { // UI가 아직 생성되지 않았다면 안전하게 먼저 생성합니다. if (!recapModalBackdrop) { console.log("Recap module UI not found, creating it now."); createRecapModule(); } // 모달의 현재 표시 상태를 확인합니다. const isModalVisible = recapModalBackdrop.style.display === 'flex'; if (forceShow || !isModalVisible) { // 모달을 열거나 강제로 열어야 하는 경우: recapModalBackdrop.style.display = 'flex'; // 모달 표시 // 컨텐츠 영역 초기화 및 로더 숨김 const wrapper = document.getElementById('recap-content-wrapper'); const loader = document.getElementById('recap-loader'); wrapper.innerHTML = `

조회할 월을 선택하고 '데이터 조회' 버튼을 눌러주세요.

`; wrapper.style.display = 'block'; loader.style.display = 'none'; // 이전에 생성된 Chart.js 인스턴스가 있다면 모두 파괴하여 메모리 누수 방지 activeCharts.forEach(chart => chart.destroy()); activeCharts = []; console.log("Recap module opened and initialized."); } else { // 모달을 닫아야 하는 경우: recapModalBackdrop.style.display = 'none'; console.log("Recap module closed."); } }; function createRecapMenuButton() { const targetMenu = document.querySelector('#userArea ul.menuList:nth-child(1)'); if (!targetMenu || targetMenu.querySelector('a.my_recap')) { return; } if (!document.getElementById('recap-menu-icon-style')) { const styleEl = document.createElement('style'); styleEl.id = 'recap-menu-icon-style'; styleEl.textContent = ` #userArea .menuList li a.my_recap::before { content: ''; display: inline-block; width: 24px; height: 24px; margin-right: 12px; vertical-align: middle; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%235dade2' d='M4 9h4v11H4zM10 4h4v16h-4zM16 12h4v8h-4z'/%3E%3C/svg%3E"); background-position: 50% 50%; background-repeat: no-repeat; background-size: 100% 100%; } `; document.head.appendChild(styleEl); } const listItem = document.createElement('li'); const link = document.createElement('a'); link.href = '#'; link.className = 'my_recap'; link.innerHTML = '월별 리캡'; listItem.appendChild(link); targetMenu.appendChild(listItem); }; const observeAndAppendRecapButton = async () => { document.body.addEventListener('click', async (e) => { const recapLink = e.target.closest('a.my_recap'); if (!recapLink) return; e.preventDefault(); if (recapInitialized) { // 두번째 이상 실행 toggleRecapModule(); return; } const span = recapLink.querySelector('span'); const originalText = span.textContent; span.textContent = '로딩 중...'; try { // 첫번째 실행 await loadScript('https://cdn.jsdelivr.net/npm/chart.js'); await loadScript('https://html2canvas.hertzen.com/dist/html2canvas.min.js'); createRecapModule(); recapInitialized = true; toggleRecapModule(); } catch (error) { alert('리캡 기능에 필요한 라이브러리를 로드하는 데 실패했습니다.'); console.error('Recap Script Load Error:', error); } finally { span.textContent = originalText; } }); // --- DOM 변경을 감시하여 버튼이 사라지면 다시 생성하는 로직 --- const parentSelector = '#logArea'; const targetSelector = await waitForElementAsync(parentSelector); if (targetSelector) { const handleLogAreaChange = async () => { const userAreaSelector = await waitForElementAsync('#userArea ul.menuList:nth-child(1)'); if (userAreaSelector) { createRecapMenuButton(); } }; observeElementChanges(parentSelector, handleLogAreaChange); } } function displayCenterVolume(isMuted, currentVolume) { // 필요한 UI 요소들을 찾습니다. const centerVolumeText = document.querySelector('.volume_text'); const centerButton = document.querySelector('.center_btn'); const centerVolumeIcon = document.querySelector('.volume_icon'); if (!centerVolumeText || !centerButton || !centerVolumeIcon) { console.error("중앙 볼륨 표시 UI 요소를 찾을 수 없습니다."); return; } // 상태에 따라 아이콘 클래스와 표시될 텍스트를 결정합니다. let t = ''; isMuted ? t = 'mute' : currentVolume < 0.5 && (t = 'low'); let e = isMuted ? 0 : currentVolume; // UI 요소들을 화면에 표시합니다. centerVolumeText.textContent = `${Math.round(100 * e)}%`; centerVolumeText.classList.remove('hide_text'); centerButton.classList.remove('fadeOut'); centerButton.querySelectorAll('div, button').forEach(el => { if (!el.classList.contains('volume_icon')) { el.style.display = 'none'; } }); centerVolumeIcon.classList.remove('low', 'mute'); if (t) { centerVolumeIcon.classList.add(t); } centerVolumeIcon.style.display = 'block'; // 0.4초 후에 UI를 다시 숨깁니다. setTimeout(() => { centerButton.classList.add('fadeOut'); centerVolumeText.classList.add('hide_text'); centerVolumeIcon.style.display = 'none'; }, 400); } // 3.5. 이벤트 핸들러 및 옵저버 (Event Handlers & Observers) const initPlayerClickToMute = async () => { const player = await waitForElementAsync('#player'); const muteButton = await waitForElementAsync('#btn_sound'); const video = await waitForElementAsync('#livePlayer'); if (!player || !muteButton || !video) { console.error("플레이어, 음소거 버튼, 또는 비디오 요소를 찾을 수 없어 초기화에 실패했습니다."); return; } // 플레이어에 클릭 이벤트 리스너를 설정합니다. player.addEventListener('click', function(event) { // 클릭된 타겟이 'videoLayerCover'일 경우에만 if (event.target.id === 'videoLayerCover') { // 1. 음소거 버튼을 클릭합니다. muteButton.click(); // 2. 잠시 후(50ms), 변경된 최신 볼륨/음소거 상태를 가져와 화면에 표시합니다. setTimeout(() => { displayCenterVolume(video.muted, video.volume); }, 50); } }); } const checkSidebarVisibility = () => { let intervalId = null; let lastExecutionTime = Date.now(); // 마지막 실행 시점 기록 const handleVisibilityChange = () => { const body = document.body; const isScreenmode = body.classList.contains('screen_mode'); const isShowSidebar = body.classList.contains('showSidebar'); const isFullScreenmode = body.classList.contains('fullScreen_mode'); const isSidebarHidden = (isScreenmode ? !isShowSidebar : false) || isFullScreenmode; const webplayer = document.getElementById('webplayer'); const webplayerStyle = webplayer?.style; const sidebar = document.getElementById('sidebar'); // 스크린 모드에서 사이드바 항상 보이는 옵션 if (webplayer && isScreenmode && showSidebarOnScreenModeAlways && !isShowSidebar) { body.classList.add('showSidebar'); webplayer.style.left = '0px'; webplayer.style.left = sidebar.offsetWidth + 'px'; webplayer.style.width = `calc(100vw - ${sidebar.offsetWidth}px)`; } // 사이드바가 보이는 상태에서 스크린 모드 종료할 때 if (webplayer && !isScreenmode && isShowSidebar) { body.classList.remove('showSidebar'); webplayerStyle.removeProperty('width'); webplayerStyle.removeProperty('left'); } if (document.visibilityState === 'visible' && isSidebarHidden) { console.log('#sidebar는 숨겨져 있음'); return; } const currentTime = Date.now(); const timeSinceLastExecution = (currentTime - lastExecutionTime) / 1000; // 초 단위로 변환 if (document.visibilityState === 'visible' && timeSinceLastExecution >= 60) { console.log('탭 활성화됨'); generateBroadcastElements(1); lastExecutionTime = currentTime; // 갱신 시점 기록 restartInterval(); // 인터벌 재시작 } else if (document.visibilityState === 'visible') { console.log('60초 미만 경과: 방송 목록 갱신하지 않음'); } else { console.log(`탭 비활성화됨: 마지막 갱신 = ${parseInt(timeSinceLastExecution)}초 전`); } }; const restartInterval = () => { if (intervalId) clearInterval(intervalId); // 기존 인터벌 중단 intervalId = setInterval(() => { handleVisibilityChange(); }, 60 * 1000); // 60초마다 실행 }; (async () => { const sidebarDiv = await waitForElementAsync('#sidebar'); observeClassChanges('body', handleVisibilityChange); restartInterval(); // 인터벌 시작 document.addEventListener('visibilitychange', handleVisibilityChange); })(); }; const processStreamers = () => { const processedLayers = new Set(); // 처리된 레이어를 추적 // 버튼 생성 및 클릭 이벤트 처리 const createHideButton = (listItem, optionsLayer) => { const hideButton = document.createElement('button'); // "숨기기" 버튼 생성 hideButton.type = 'button'; const span = document.createElement('span'); span.textContent = '이 브라우저에서 스트리머 숨기기'; hideButton.appendChild(span); // 클릭 이벤트 추가 hideButton.addEventListener('click', () => { const userNameElement = listItem.querySelector('a.nick > span'); // 사용자 이름 요소 const userIdElement = listItem.querySelector('.cBox-info > a'); // 사용자 ID 요소 if (userNameElement && userIdElement) { const userId = userIdElement.href.split('/')[3]; // 사용자 ID 추출 const userName = userNameElement.innerText; // 사용자 이름 추출 console.log(`Blocking user: ${userName}, ID: ${userId}`); // 로그 추가 if (userId && userName) { blockUser(userName, userId); // 사용자 차단 함수 호출 listItem.style.display = 'none'; } } else { console.log("User elements not found."); // 요소가 없을 경우 로그 추가 } }); optionsLayer.appendChild(hideButton); // 옵션 레이어에 버튼 추가 }; /* */ const createCategoryHideButton = (listItem, optionsLayer) => { const hideButton = document.createElement('button'); // "숨기기" 버튼 생성 hideButton.type = 'button'; const span = document.createElement('span'); span.textContent = '이 브라우저에서 해당 카테고리 숨기기'; hideButton.appendChild(span); // 클릭 이벤트 추가 [data-type=cBox] .cBox-info .tag_wrap a.category hideButton.addEventListener('click', () => { const categoryElement = listItem.querySelector('.cBox-info .tag_wrap a.category'); if (categoryElement) { const categoryName = categoryElement.textContent; const categoryNo = getCategoryNo(categoryName); if (categoryName && categoryNo) { blockCategory(categoryName, categoryNo); } } else { console.log("User elements not found."); // 요소가 없을 경우 로그 추가 } }); optionsLayer.appendChild(hideButton); // 옵션 레이어에 버튼 추가 } // DOM 변경 감지 및 처리 const handleDOMChange = (mutationsList) => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { const moreOptionsContainer = document.querySelector('div._moreDot_wrapper'); // 추가 옵션 컨테이너 const optionsLayer = moreOptionsContainer ? moreOptionsContainer.querySelector('div._moreDot_layer') : null; // 옵션 레이어 if (optionsLayer && optionsLayer.style.display !== 'none' && !processedLayers.has(optionsLayer)) { const activeButton = document.querySelector('button.more_dot.on'); // 활성화된 버튼 const listItem = activeButton.closest('li[data-type="cBox"]'); // 가장 가까운 리스트 아이템 찾기 if (listItem) { createHideButton(listItem, optionsLayer); // 숨기기 버튼 생성 createCategoryHideButton(listItem, optionsLayer); processedLayers.add(optionsLayer); // 이미 처리된 레이어로 추가 } } else if (!optionsLayer) { processedLayers.clear(); // 요소가 없을 때 처리된 레이어 초기화 } // cBox-list의 리스트 아이템 처리 const cBoxListItems = document.querySelectorAll('div.cBox-list li[data-type="cBox"]:not(.hide-checked)'); // cBoxListItems를 for...of 루프로 반복 for (const listItem of cBoxListItems) { listItem.classList.add('hide-checked'); const userIdElement = listItem.querySelector('.cBox-info > a'); // 사용자 ID 요소 const categoryElement = listItem.querySelector('.cBox-info .tag_wrap a.category'); const titleElement = listItem.querySelector('.cBox-info .title a'); if (userIdElement) { const userId = userIdElement.href.split('/')[3]; // 사용자 ID 추출 // 차단된 사용자일 경우 li 삭제 if (isUserBlocked(userId)) { listItem.style.display = 'none'; console.log(`Removed blocked user with ID: ${userId}`); // 로그 추가 } } if (categoryElement) { const categoryName = categoryElement.textContent; if (isCategoryBlocked(getCategoryNo(categoryName))) { listItem.style.display = 'none'; console.log(`Removed blocked category with Name: ${categoryName}`); // 로그 추가 } } if (titleElement) { const broadTitle = titleElement.textContent; // blockedWords에 포함된 단어가 broadTitle에 있는지 체크 for (const word of blockedWords) { if (broadTitle.toLowerCase().includes(word.toLowerCase())) { listItem.style.display = 'none'; console.log(`Removed item with blocked word in title: ${broadTitle}`); // 로그 추가 break; // 하나의 차단 단어가 발견되면 더 이상 확인할 필요 없음 } } } } // 외부 재생기 사용 if (isOpenExternalPlayerEnabled) { const allThumbsBoxLinks = document.querySelectorAll('[data-type=cBox] .thumbs-box > a[href]:not([href^="https://vod.sooplive.co.kr"])'); if (allThumbsBoxLinks.length) makeExternalLinks(allThumbsBoxLinks); } // 빈 썸네일 대체 if (isReplaceEmptyThumbnailEnabled){ const noThumbsBoxLinks = document.querySelectorAll('[data-type=cBox] .thumbs-box > a[href].thumb-adult:not([href^="https://vod.sooplive.co.kr"])'); if (noThumbsBoxLinks.length) replaceThumbnails(noThumbsBoxLinks); } } } }; const observer = new MutationObserver(handleDOMChange); // DOM 변경 감지기 // 감지할 옵션 설정 const config = { childList: true, subtree: true }; // 관찰 시작 observer.observe(document.body, config); }; const observeChat = (elementSelector, elem) => { hideBadges(); if (!isBlockWordsEnabled) return; if (REG_WORDS.length === 0) return; const observer = new MutationObserver((mutations) => { mutations.forEach(({ addedNodes }) => { addedNodes.forEach(node => { if (node.nodeType !== Node.ELEMENT_NODE) return; // 메시지 요소를 직접 감지하거나 하위에서 찾음 const message = node.matches?.('div.message-text > p.msg') ? node : node.querySelector?.('div.message-text > p.msg'); if (message) { deleteMessages([message]); } }); }); }); observer.observe(elem, { childList: true, subtree: true }); }; const deleteMessages = (messages) => { if (!Array.isArray(messages) || messages.length === 0) return; // 필터 단어가 없으면 아무것도 하지 않음 if (REG_WORDS.length === 0) return; for (const message of messages) { const messageText = message.textContent.trim(); let shouldRemove = false; for (const word of REG_WORDS) { const isExactCheck = word.startsWith("e:"); const wordToCheck = isExactCheck ? word.slice(2) : word; if ((isExactCheck && messageText === wordToCheck) || (!isExactCheck && messageText.includes(wordToCheck))) { shouldRemove = true; break; } } if (shouldRemove) { const listItem = message.closest('.chatting-list-item'); if (listItem) { listItem.remove(); } } } }; const setupChatMessageTrackers = (element) => { const OriginalWebSocket = window.WebSocket; const targetUrlPattern = /^wss:\/\/chat-[\w\d]+\.sooplive\.co\.kr/; const MAX_MESSAGES = 500; const messageHistory = []; const bannedMessages = []; const targetUserMessages = []; let bannedWindow = null; let targetWindow = null; let banIcon = null; let highlightIcon = null; const highlightPosition = isShowDeletedMessagesEnabled ? "40px" : "10px"; let totalChatCount = 0; let lastChatCount = 0; let last10Intervals = []; if (isChatCounterEnabled) { const container = document.querySelector('.chatting-item-wrap'); const cpsDisplay = document.createElement('div'); cpsDisplay.id = 'cps_display'; container.appendChild(cpsDisplay); Object.assign(cpsDisplay.style, { position: 'absolute', top: '8px', left: '8px', background: 'rgba(0, 0, 0, 0.3)', color: '#fff', fontSize: '14px', padding: '4px 8px', borderRadius: '4px', zIndex: '10', pointerEvents: 'none' }); setInterval(() => { const delta = totalChatCount - lastChatCount; lastChatCount = totalChatCount; last10Intervals.push(delta); if (last10Intervals.length > 10) { last10Intervals.shift(); } const sum = last10Intervals.reduce((a, b) => a + b, 0); const avg = sum / 5; cpsDisplay.textContent = `${Math.round(avg)}개/s`; }, 500); } GM_addStyle(` #cps_display { position: absolute; top: 8px; left: 8px; background: rgba(0, 0, 0, 0.5); color: #fff; font-size: 14px; padding: 4px 8px; border-radius: 4px; z-index: 10; pointer-events: none; } .chat-icon { position: absolute; bottom: 10px; right: 6px; width: 24px; height: 24px; cursor: pointer; z-index: 1000; background-size: contain; background-repeat: no-repeat; } .chat-icon.highlight { right: 7px; width: 22px; height: 22px; bottom: ${highlightPosition}; } html:not([dark="true"]) .trash-icon { background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2264%22%20height%3D%2264%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20stroke%3D%22%23000%22%20stroke-width%3D%220%22%3E%3Cg%2F%3E%3Cg%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke%3D%22%23CCC%22%20stroke-width%3D%22.192%22%2F%3E%3Cg%20fill%3D%22%236A6A75%22%20stroke%3D%22none%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M10.31%202.25h3.38c.217%200%20.406%200%20.584.028a2.25%202.25%200%200%201%201.64%201.183c.084.16.143.339.212.544l.111.335.03.085a1.25%201.25%200%200%200%201.233.825h3a.75.75%200%200%201%200%201.5h-17a.75.75%200%200%201%200-1.5h3.09a1.25%201.25%200%200%200%201.173-.91l.112-.335c.068-.205.127-.384.21-.544a2.25%202.25%200%200%201%201.641-1.183c.178-.028.367-.028.583-.028Zm-1.302%203a3%203%200%200%200%20.175-.428l.1-.3c.091-.273.112-.328.133-.368a.75.75%200%200%201%20.547-.395%203%203%200%200%201%20.392-.009h3.29c.288%200%20.348.002.392.01a.75.75%200%200%201%20.547.394c.021.04.042.095.133.369l.1.3.039.112q.059.164.136.315z%22%2F%3E%3Cpath%20d%3D%22M5.915%208.45a.75.75%200%201%200-1.497.1l.464%206.952c.085%201.282.154%202.318.316%203.132.169.845.455%201.551%201.047%202.104s1.315.793%202.17.904c.822.108%201.86.108%203.146.108h.879c1.285%200%202.324%200%203.146-.108.854-.111%201.578-.35%202.17-.904.591-.553.877-1.26%201.046-2.104.162-.813.23-1.85.316-3.132l.464-6.952a.75.75%200%200%200-1.497-.1l-.46%206.9c-.09%201.347-.154%202.285-.294%202.99-.137.685-.327%201.047-.6%201.303-.274.256-.648.422-1.34.512-.713.093-1.653.095-3.004.095h-.774c-1.35%200-2.29-.002-3.004-.095-.692-.09-1.066-.256-1.34-.512-.273-.256-.463-.618-.6-1.302-.14-.706-.204-1.644-.294-2.992z%22%2F%3E%3Cpath%20d%3D%22M9.425%2010.254a.75.75%200%200%201%20.821.671l.5%205a.75.75%200%200%201-1.492.15l-.5-5a.75.75%200%200%201%20.671-.821m5.15%200a.75.75%200%200%201%20.671.82l-.5%205a.75.75%200%200%201-1.492-.149l.5-5a.75.75%200%200%201%20.82-.671Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E"); } html[dark="true"] .trash-icon { background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2264%22%20height%3D%2264%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20stroke%3D%22%23000%22%20stroke-width%3D%220%22%3E%3Cg%2F%3E%3Cg%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke%3D%22%23CCC%22%20stroke-width%3D%22.192%22%2F%3E%3Cg%20fill%3D%22%2394949C%22%20stroke%3D%22none%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M10.31%202.25h3.38c.217%200%20.406%200%20.584.028a2.25%202.25%200%200%201%201.64%201.183c.084.16.143.339.212.544l.111.335.03.085a1.25%201.25%200%200%200%201.233.825h3a.75.75%200%200%201%200%201.5h-17a.75.75%200%200%201%200-1.5h3.09a1.25%201.25%200%200%200%201.173-.91l.112-.335c.068-.205.127-.384.21-.544a2.25%202.25%200%200%201%201.641-1.183c.178-.028.367-.028.583-.028Zm-1.302%203a3%203%200%200%200%20.175-.428l.1-.3c.091-.273.112-.328.133-.368a.75.75%200%200%201%20.547-.395%203%203%200%200%201%20.392-.009h3.29c.288%200%20.348.002.392.01a.75.75%200%200%201%20.547.394c.021.04.042.095.133.369l.1.3.039.112q.059.164.136.315z%22%2F%3E%3Cpath%20d%3D%22M5.915%208.45a.75.75%200%201%200-1.497.1l.464%206.952c.085%201.282.154%202.318.316%203.132.169.845.455%201.551%201.047%202.104s1.315.793%202.17.904c.822.108%201.86.108%203.146.108h.879c1.285%200%202.324%200%203.146-.108.854-.111%201.578-.35%202.17-.904.591-.553.877-1.26%201.046-2.104.162-.813.23-1.85.316-3.132l.464-6.952a.75.75%200%200%200-1.497-.1l-.46%206.9c-.09%201.347-.154%202.285-.294%202.99-.137.685-.327%201.047-.6%201.303-.274.256-.648.422-1.34.512-.713.093-1.653.095-3.004.095h-.774c-1.35%200-2.29-.002-3.004-.095-.692-.09-1.066-.256-1.34-.512-.273-.256-.463-.618-.6-1.302-.14-.706-.204-1.644-.294-2.992z%22%2F%3E%3Cpath%20d%3D%22M9.425%2010.254a.75.75%200%200%201%20.821.671l.5%205a.75.75%200%200%201-1.492.15l-.5-5a.75.75%200%200%201%20.671-.821m5.15%200a.75.75%200%200%201%20.671.82l-.5%205a.75.75%200%200%201-1.492-.149l.5-5a.75.75%200%200%201%20.82-.671Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E"); } html:not([dark="true"]) .highlight-icon { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAsTAAALEwEAmpwYAAADyUlEQVR4nO1YTWhdVRA+/TH+Ba3gorgQFBS0ulEsqItXFCWLmuZ8k0GSvDtzo/C0YFu0+IfIU7rQnSKCIIo7XYmRIiL+oCsRBbFimwr+ddOVWKy2aovK5OYl552Q9+59P7kp3A8O5OWe+c7MnJk5c45zFSpUqFBhLTA9PX3Z5KTMAjoHyDygfwByGtAfAX3Te/HMvKkbj80hSgHIW5nsAodxzRPJO4Cmu3bploEpzswXAvIkICeI9L/OQ44wJ7euxkWU3mZzcvD8BugTqnpBX8qPj993BaBfdl+wbZwxg51zG1o8zWZzI6BP2bciXIB+YTr0rDyRHIsIvyHSvcyz19fr9YvNQ/Y3oPuJ5OfIiy+1uAB5Ofr2E5E8PDGRXGccxkUk2wDZB8ihaO6xwkZkYRN6Xv6anJQHzJOdZEzpyIvPEMmz0f9e7BQazWZzI5E+aGuGO1EonCwEQuWZk1p+WX109ZBIHsnLQ6Q7IiMey11twoQ1z+dddHlxeW2l8vJqUR5AdoeJnas6WakMY75T2KyGnTsbFxHpt4HyhyzEivIw86b2nEi0q5DV4sBze12PmJhIrl2s7UeA9JpeeQDZFzji7a4CRHq0JWAVxpUMItkWGDDfVQDQk8sG8KgrGcw8GoT0ya4Cdqy3BGZmZi5xJYO5cWlBAyxulzL/blcyvNexICcPdxWwclcoaYYMQOcCfV7pKkCkdwYW/8ss211JYNabTYdAnx155DYs9jytMPq4l7OgXzSzluKTwPtf5xb2Pr0jauL2uzUGVrQkBfORSN4IrP9nLUPJ+/QmQP8O1n+9MEnW4urhsAVm1q1uyGDWrVm7vaT8d9aa9EiW3gjoqSCUvhrm4cbMo7ZGsN4p7+s39EUKKAF6NtiJ92q12mY3YNRqtc3GHSh/1u7OAyG3ljpK6oNjY3vOHwi5c67RaJxnZ064hve6xw0SRHogMmKOmUf65WXmkfCwWhwH3DAQG0Ek7/fzcsDMI0T6bsT5ghsmsntu20580MtlxUIQ0INrqnwLRPp0tOUfFil1NheQj0pRvgUieTzaiU/ztN82h0g/i5R/zpWBxUv3UrNlNXxqauryTg8GRPJ5ZPjzrkwA8lC7EXaBX3liZyfs8kXfZPygS2WvIJJ6+GyYPdrOXt36zpxcSSTfh4cUoPe79QQgudeavkDJX+xlgrl+FZH8EHj+DKCJW4/wXsbbX9PkuI3g92kgvcetZ1D2JPh7/DJHJH96n97lzgV4n94CyK+B508Aers7l8AL99mFxD1qF5Sy9alQoUIFNxT8D3rbJQHjF4hlAAAAAElFTkSuQmCC"); } html[dark="true"] .highlight-icon { background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAsTAAALEwEAmpwYAAAD1ElEQVR4nO2YXYhVVRTHd6NZ1tAH9CA9CAYJZb4oCdVDUhS9GFQPvSgWPViQiklmRJzCh3orIgijGFAo2Gf9z5mud85aZ6Y69SSSEBrlFGTli0+hqGkf0sSac+/Mvnvy3nvu5wjnBxcunLP+e6191tp77W1MSUlJSckgqFartwLyLJGMAzIN8AWALwF8EpBPgOQJa+2SVjr6ThTxk0T8aW47q3GhphkT8TNxnN3SM8ettcuJ+FWAzwIy0/zHJ6KI77uSVhjK/fpOKx0iPkMke8fGsuu7cn58fPJ2gL9p7XjD4P9owDMzM9fUdYIgGCGS1/RZQa0j6kPHzhPxKU/0GMA7rE3uPnAgvVFnSP8TyW5AfvUGf6+uRSTvNz6TX4B0VxRN3qUaqhWGvIaIdxLJcU/nVOEgNG3cmSeSPwHepjPZzEad9lLqDYDf9Cbh3WapEQTBSBTx8/mY81+iUDrlOT/vfBQlDxawfblJSrzUrk4Y8kYviD0FVhu3YHmbKQggH/2P8x8W1SGSF9zCbmt10qXSzflmaXMlKpXKDUT8nfMVj2uKFdWx1i5xayIM061tBMCxM/s7TIdE0cTq2tp+gmjyzk51iHinMxFow0B+rBvoCmOGTBjyGicjplsaAHx+PoBs1AwZa7NRJyPOtzSobeuzBgcPJjeZIWPt1M0FA9C8nSuaRwfiZRMAecxZiX4wrdDlrlDR9BnKm8e6Px+0NAjD5GEn4n+jSDaYIRHH6Xr1YT4jeGNLI23C8p5nLogvOtkLuiUIghGAv3T8+LZtYyB9yGu+dpsBQ15LUrgeARlzqv/vQaYSwOsA+csZ/+PCItriatW7LbC11RWmz1hbXZG323Oz/722Jh2JxfHEWkAuOnl4tJ+bm7XZqI7hOH8RSO/pSpRIngLksiM6kWXZUtNjsixbqtrOOJf17NwTcW2pvdb4UJIk1/VE3Bizf//Ra3XP8Vrw7aaXALLPW5nGrbXLutW11i5zN6vab5/pBwuDYO7m5sDmzn/mab5j+kl+zm0496adHFY0BYn40ECdrwPw694nnyqy1Om7gHw+FOfrAPKKVxNftdN+6zsAf+19xbfMMNBDt9ts6RpeqVRua3ZhQCSHvZl/2wwTInmxMQg9wC/csfMd1j3oz9psN4uBMEw2N14b8kmiqTvqz62dWgnwT+4mRZQ8ZxYTRPK0Nn1OEL/pzYS16SqAf/buTreYxQjAj7u3aYCcrv3qQV0iSjeZxUyYXwmeW3gzJ38A8oi5GgCSewH+3Zn5s0TpA+ZqIo7T9Vq4+UUZrxu2PyUlJSWmL/wH5eEJ5sFzGH4AAAAASUVORK5CYII="); } `); const toggleRedDot = (icon, shouldShow) => { if (!icon) return; const existingDot = icon.querySelector(".red-dot"); if (shouldShow && !existingDot) { const redDot = document.createElement("div"); redDot.classList.add("red-dot"); Object.assign(redDot.style, { position: "absolute", top: "0px", right: "0px", width: "4px", height: "4px", borderRadius: "50%", backgroundColor: "red", zIndex: 1001 }); icon.appendChild(redDot); } else if (!shouldShow && existingDot) { existingDot.remove(); } }; const addMessageToDOM = (messageList, msg, isBannedList = false) => { // "메시지가 없습니다" 항목이 있다면 제거 const noMessageItem = messageList.querySelector('.no-message'); if (noMessageItem) { noMessageItem.remove(); } const listItem = document.createElement("li"); if (isBannedList) { const systemMessage = msg.message === `[강제퇴장 됨]`; const timestampSpan = document.createElement("span"); timestampSpan.className = "message-timestamp"; timestampSpan.textContent = `[${msg.timestamp}]`; const nameTag = systemMessage ? document.createElement("i") : document.createElement("strong"); nameTag.textContent = systemMessage ? `${msg.userName} (${msg.userId}) 님이 강제 퇴장 되었습니다.` : `${msg.userName} (${msg.userId})`; const text = systemMessage ? '' : `: ${msg.message}`; const messageText = document.createTextNode(text); listItem.appendChild(timestampSpan); listItem.appendChild(document.createTextNode(" ")); listItem.appendChild(nameTag); listItem.appendChild(messageText); } else { listItem.textContent = `[${msg.timestamp}] ${msg.userName} (${msg.userId}): ${msg.message}`; } messageList.insertBefore(listItem, messageList.firstChild); }; const recordMessage = (userId, userName, message, timestamp) => { const msgData = { userId, userName, message, timestamp }; messageHistory.push(msgData); if (messageHistory.length > MAX_MESSAGES) { messageHistory.shift(); } if (isShowSelectedMessagesEnabled && targetUserIdSet.has(userId)) { targetUserMessages.push(msgData); // 창이 열려 있을 때만 새 메시지를 DOM에 추가 if (targetWindow && !targetWindow.closed) { const messageList = targetWindow.document.getElementById("targetUserMessages"); addMessageToDOM(messageList, msgData, false); } else { toggleRedDot(highlightIcon, true); } } }; const decodeMessage = (data) => { const decoder = new TextDecoder("utf-8"); const decodedText = decoder.decode(data); const parts = decodedText.split("\x0c"); const now = new Date(); const timestamp = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`; if (parts.length === 13 || parts.length === 14) { const message = parts[1]; const userId = parts[2].split('(')[0]; const userName = parts[6]; if (userId.includes('|') || userName.includes('|') || !userId || !userName || userName === '0') return; recordMessage(userId, userName, message, timestamp); if (isChatCounterEnabled) totalChatCount++; } else if (parts[1] === '-1' && parts[4] === '2') { const userId = parts[2].split('(')[0]; const userName = parts[3]; if (userId.includes('|') || userName.includes('|') || !userId || !userName) return; if (isShowDeletedMessagesEnabled) { const userMessages = messageHistory.filter(msg => msg.userId === userId); const banNotice = { userId, userName, message: "[강제퇴장 됨]", timestamp }; bannedMessages.push(...userMessages, banNotice); // 창이 열려 있을 때만 새 메시지들을 DOM에 추가 if (bannedWindow && !bannedWindow.closed) { const messageList = bannedWindow.document.getElementById("bannedMessages"); // 강퇴된 유저의 이전 메시지들과 강퇴 알림을 순서대로 추가 [...userMessages, banNotice].forEach(msg => { addMessageToDOM(messageList, msg, true); }); } else { toggleRedDot(banIcon, true); } } } }; unsafeWindow.WebSocket = function(url, protocols) { const ws = new OriginalWebSocket(url, protocols); if (targetUrlPattern.test(url)) { ws.addEventListener("message", (event) => decodeMessage(event.data)); } return ws; }; unsafeWindow.WebSocket.prototype = OriginalWebSocket.prototype; const createIcon = (type, onClick) => { const icon = document.createElement("div"); icon.classList.add("chat-icon", type === "highlight" ? "highlight-icon" : "trash-icon", type); icon.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); onClick(); }); element.appendChild(icon); return icon; }; const showBannedMessages = () => { if (bannedWindow && !bannedWindow.closed) { bannedWindow.focus(); return; } toggleRedDot(banIcon, false); const features = `width=600,height=600,left=${(screen.width - 600) / 2},top=${(screen.height - 600) / 2}`; bannedWindow = window.open("", "_blank", features); bannedWindow.document.write(` 강제퇴장된 유저의 채팅`); bannedWindow.document.close(); // 창이 로드된 후 메시지 목록 채우기 const messageList = bannedWindow.document.getElementById("bannedMessages"); if (bannedMessages.length === 0) { const noMessageItem = document.createElement("li"); noMessageItem.textContent = "메시지가 없습니다."; noMessageItem.className = "no-message"; // 식별을 위한 클래스 추가 messageList.appendChild(noMessageItem); } else { bannedMessages.forEach(msg => addMessageToDOM(messageList, msg, true)); } }; const showTargetMessages = () => { if (targetWindow && !targetWindow.closed) { targetWindow.focus(); return; } toggleRedDot(highlightIcon, false); const features = `width=600,height=600,left=${(screen.width - 600) / 2},top=${(screen.height - 600) / 2}`; targetWindow = window.open("", "_blank", features); targetWindow.document.write(` 지정 유저 채팅 | 즐겨찾기 ${allFollowUserIds.length}명 | 수동 입력 ${selectedUsersArray.length}명`); targetWindow.document.close(); const messageList = targetWindow.document.getElementById("targetUserMessages"); if (targetUserMessages.length === 0) { const noMessageItem = document.createElement("li"); noMessageItem.textContent = "메시지가 없습니다."; noMessageItem.className = "no-message"; messageList.appendChild(noMessageItem); } else { targetUserMessages.forEach(msg => addMessageToDOM(messageList, msg, false)); } }; const resetChatData = () => { messageHistory.length = 0; bannedMessages.length = 0; targetUserMessages.length = 0; // 열려있는 창이 있다면, 내용도 업데이트 if (bannedWindow && !bannedWindow.closed) { const messageList = bannedWindow.document.getElementById("bannedMessages"); messageList.replaceChildren(); // 모든 자식 노드 제거 const noMessageItem = document.createElement("li"); noMessageItem.textContent = "메시지가 없습니다."; noMessageItem.className = "no-message"; messageList.appendChild(noMessageItem); } if (targetWindow && !targetWindow.closed) { const messageList = targetWindow.document.getElementById("targetUserMessages"); messageList.replaceChildren(); const noMessageItem = document.createElement("li"); noMessageItem.textContent = "메시지가 없습니다."; noMessageItem.className = "no-message"; messageList.appendChild(noMessageItem); } toggleRedDot(banIcon, false); toggleRedDot(highlightIcon, false); }; unsafeWindow.resetChatData = resetChatData; if (isShowDeletedMessagesEnabled) { banIcon = createIcon("trash", showBannedMessages); } if (isShowSelectedMessagesEnabled) { highlightIcon = createIcon("highlight", showTargetMessages); } }; const showSidebarOnMouseOver = () => { const sidebar = document.getElementById('sidebar'); const videoLayer = document.getElementById('player'); const webplayerContents = document.getElementById('webplayer'); const body = document.body; webplayerContents.style.left = '0px'; webplayerContents.style.width = '100vw'; const handleSidebarMouseOver = () => { if (body.classList.contains('screen_mode') && !body.classList.contains('showSidebar')) { body.classList.add('showSidebar'); webplayerContents.style.left = sidebar.offsetWidth + 'px'; webplayerContents.style.width = `calc(100vw - ${sidebar.offsetWidth}px)`; } }; const handleSidebarMouseOut = () => { if (body.classList.contains('screen_mode') && body.classList.contains('showSidebar')) { body.classList.remove('showSidebar'); webplayerContents.style.left = '0px'; webplayerContents.style.width = '100vw'; } }; const mouseMoveHandler = (event) => { const mouseX = event.clientX; const mouseY = event.clientY; if (!body.classList.contains('showSidebar')) { if (mouseX < 52 && mouseY < (videoLayer.clientHeight / 3 * 2)) { handleSidebarMouseOver(); } } else { if (mouseX < sidebar.clientWidth && mouseY < sidebar.clientHeight) { handleSidebarMouseOver(); } else { handleSidebarMouseOut(); } } }; const windowMouseOutHandler = (event) => { if (!event.relatedTarget && !event.toElement) { handleSidebarMouseOut(); } }; document.addEventListener('mousemove', mouseMoveHandler); window.addEventListener('mouseout', windowMouseOutHandler); // 창 벗어남 감지 }; const setupKeydownHandler = (targetCode, toggleFunction) => { document.addEventListener('keydown', (event) => { if (event.code === targetCode && !isUserTyping()) { toggleFunction(); } }, true); }; const toggleExpandChatShortcut = () => { setupKeydownHandler("KeyX", toggleExpandChat); // X 키 }; const toggleSharpModeShortcut = () => { setupKeydownHandler("KeyE", togglesharpModeCheck); // E 키 updateLabel('clear_screen', '선명한 모드', '선명한 모드(e)'); }; const toggleLowLatencyShortcut = () => { setupKeydownHandler("KeyD", toggleDelayCheck); // D 키 updateLabel('delay_check', '시차 단축', '시차 단축(d)'); }; const updateLabel = (forId, oldText, newText) => { const labelElement = document.body.querySelector(`#player label[for="${forId}"]`); if (labelElement) { labelElement.innerHTML = labelElement.innerHTML.replace(oldText, newText); } else { console.error('Label element not found.'); } }; const toggleExpandChat = async () => { if (!isElementVisible('.expand-toggle-li')) return; try { const toggleLink = await waitForElementAsync('.expand-toggle-li a'); toggleLink.click(); } catch (error) { console.error("채팅 확장 토글 링크 클릭 실패:", error); } }; const togglesharpModeCheck = () => { const sharpModeCheckElement = document.getElementById('clear_screen'); if (sharpModeCheckElement) { sharpModeCheckElement.click(); showPlayerBar('quality_box'); } }; const toggleDelayCheck = () => { if (isAdjustDelayNoGridEnabled) { moveToLatestBufferedPoint(); } else { const delayCheckElement = document.getElementById('delay_check'); if (delayCheckElement) { delayCheckElement.click(); showPlayerBar('setting_box'); } } }; const showPlayerBar = (target) => { const player = document.getElementById('player'); player.classList.add('mouseover'); let settingButton, settingBoxOn; if (target === 'quality_box') { settingButton = document.body.querySelector('#player button.btn_quality_mode'); settingBoxOn = document.body.querySelector('.quality_box.on'); } else if (target === 'setting_box') { settingButton = document.body.querySelector('#player button.btn_setting'); settingBoxOn = document.body.querySelector('.setting_box.on'); } if (settingButton) { if (!settingBoxOn) { settingButton.click(); } setTimeout(() => { // 현재 열려있는(on 클래스를 가진) 설정 박스를 찾습니다. const openBox = document.body.querySelector('.quality_box.on, .setting_box.on'); // 만약 있다면 .on 클래스를 제거합니다. if (openBox) { openBox.classList.remove('on'); } player.classList.remove('mouseover'); // 이 코드는 그대로 유지합니다. }, 1500); } else { // 버튼을 못 찾았더라도 mouseover는 제거해줍니다. setTimeout(() => { player.classList.remove('mouseover'); }, 1500); console.error('Setting button not found or not visible.'); } }; const moveToLatestBufferedPoint = () => { const video = document.querySelector('video'); const buffered = video.buffered; if (buffered.length > 0) { // 버퍼링된 구간의 마지막 시간 const bufferedEnd = buffered.end(buffered.length - 1); const targetTime = bufferedEnd - 2; // 2초 전으로 설정 // targetTime이 현재 시간보다 뒤에 있을 경우에만 이동 if (targetTime > video.currentTime) { video.currentTime = targetTime; } } }; const checkPlayerPageHeaderAd = async () => { try { const headerAd = await waitForElementAsync('#header_ad', 5000); headerAd.remove(); } catch (error) { console.info("헤더 광고가 없습니다. (정상)"); } }; const getRemainingBufferTime = (video) => { const buffered = video.buffered; if (buffered.length > 0) { // 마지막 버퍼의 끝과 현재 시간의 차이를 계산 const remainingBufferTime = buffered.end(buffered.length - 1) - video.currentTime; // 0초 또는 정수일 경우 소수점 한 자리로 반환 return remainingBufferTime >= 0 ? remainingBufferTime.toFixed(remainingBufferTime % 1 === 0 ? 0 : 1) : ''; } return ''; // 버퍼가 없으면 빈 문자열 반환 }; const insertRemainingBuffer = (element) => { const video = element; const emptyChat = document.body.querySelector('#empty_chat'); // video의 onprogress 이벤트 핸들러 video.onprogress = () => { const remainingBufferTime = getRemainingBufferTime(video); // remainingBufferTime 계산 if (emptyChat && remainingBufferTime !== '') { emptyChat.innerText = `${remainingBufferTime}s 지연됨`; } }; }; const isVideoInPiPMode = () => { const videoElement = document.body.querySelector('video'); return videoElement && document.pictureInPictureElement === videoElement; }; const handleMuteByVisibility = () => { if (!isAutoChangeMuteEnabled || isVideoInPiPMode()) return; const button = document.body.querySelector("#btn_sound"); if (document.hidden) { // 탭이 비활성화됨 if (!button.classList.contains("mute")) { button.click(); // console.log("탭이 비활성화됨, 음소거"); } } else { // 탭이 활성화됨 if (button.classList.contains("mute")) { button.click(); // console.log("탭이 활성화됨, 음소거 해제"); } } }; const registerVisibilityChangeHandler = () => { document.addEventListener('visibilitychange', handleMuteByVisibility, true); }; const handleVisibilityChangeForQuality = async () => { if (!isAutoChangeQualityEnabled || isVideoInPiPMode()) return; if (document.hidden) { console.log("[탭 상태] 비활성화됨"); previousQualityBeforeDowngrade = getCurrentInternalQuality(); previousIsAutoMode = getIsAutoQualityMode(); if (!previousQualityBeforeDowngrade) { console.warn("[현재 화질] 정보를 가져오지 못함"); } else { console.log(`[현재 화질 저장] ${previousQualityBeforeDowngrade} (자동모드: ${previousIsAutoMode})`); } qualityChangeTimeout = setTimeout(async () => { await changeQualityLivePlayer('LOW'); // LOW = 최저화질 didChangeToLowest = true; console.log("[타이머 실행] 최저화질로 전환됨"); }, 6500); console.log("[타이머] 몇 초 후 최저화질로 변경 예약됨"); } else { console.log("[탭 상태] 활성화됨"); if (qualityChangeTimeout) { clearTimeout(qualityChangeTimeout); qualityChangeTimeout = null; console.log("[타이머] 예약된 최저화질 변경 취소됨"); } if (didChangeToLowest && previousQualityBeforeDowngrade) { const current = getCurrentInternalQuality(); if (previousIsAutoMode) { if (getIsAutoQualityMode()) { console.log("[복귀] 이미 자동 모드이므로 변경 생략"); } else { await changeQualityLivePlayer('AUTO'); console.log("[복귀] 자동 모드 복원됨"); } } else { if (current === previousQualityBeforeDowngrade) { console.log(`[복귀] 현재 화질(${current})과 동일하여 복원 생략`); } else { await changeQualityLivePlayer(previousQualityBeforeDowngrade); console.log(`[복귀] 수동 화질 복원됨 → ${previousQualityBeforeDowngrade}`); } } } else { console.log("[복귀] 화질 변경 없었으므로 복원 생략"); } // 상태 초기화 didChangeToLowest = false; previousQualityBeforeDowngrade = null; previousIsAutoMode = null; } }; const registerVisibilityChangeHandlerForQuality = () => { document.addEventListener('visibilitychange', handleVisibilityChangeForQuality, true); }; const autoClaimGem = () => { const element = document.querySelector('#actionbox > div.ic_gem'); if (element && getComputedStyle(element).display !== 'none') { element.click(); } }; const videoSkipHandler = (e) => { const activeElement = document.activeElement; const tagName = activeElement.tagName.toLowerCase(); // 입력란 활성화 여부 체크 const isInputActive = (tagName === 'input') || (tagName === 'textarea') || (activeElement.id === 'write_area') || (activeElement.contentEditable === 'true'); // 입력란이 활성화되어 있지 않은 경우 비디오 제어 if (!isInputActive) { const video = document.querySelector('video'); if (video) { switch (e.code) { case 'ArrowRight': // 오른쪽 방향키: 동영상을 1초 앞으로 이동 video.currentTime += 1; break; case 'ArrowLeft': // 왼쪽 방향키: 동영상을 1초 뒤로 이동 video.currentTime -= 1; break; } } } }; const homePageCurrentTab = async () => { try { const logoLink = await waitForElementAsync('#logo > a'); logoLink.removeAttribute("target"); } catch (error) { console.error("로고 링크 처리 실패:", error); } }; const useBottomChat = () => { const toggleBottomChat = () => { const playerArea = document.querySelector('#player_area'); if (!playerArea) { console.warn('#player_area 요소를 찾을 수 없습니다.'); return; } const playerHeight = playerArea.getBoundingClientRect().height; const browserHeight = window.innerHeight; const isPortrait = window.innerHeight * 1.1 > window.innerWidth; document.body.classList.toggle('bottomChat', isPortrait); }; window.addEventListener('resize', debounce(toggleBottomChat, 500)); toggleBottomChat(); }; const getViewersNumber = (raw = false) => { const element = document.querySelector('#nAllViewer'); if (!element) return '0'; const rawNumber = element.innerText.replace(/,/g, '').trim(); if (Boolean(raw)) { return rawNumber; } return addNumberSeparator(rawNumber); }; const updateTitleWithViewers = () => { const originalTitle = document.title.split(' ')[0]; // 기존 제목의 첫 번째 단어 const viewers = getViewersNumber(true); // 현재 시청자 수 갱신 const formattedViewers = addNumberSeparatorAll(viewers); // 형식화된 시청자 수 let title = originalTitle; if (originalTitle !== previousTitle) { previousViewers = 0; // 제목이 변경되면 이전 시청자 수 초기화 } if (viewers && previousViewers) { if (viewers > previousViewers) { title += ` 🔺${formattedViewers}`; } else if (viewers < previousViewers) { title += ` 🔻${formattedViewers}`; } else { title += ` • ${formattedViewers}`; // 시청자 수가 변동 없을 때 } } else { title += ` • ${formattedViewers}`; // 시청자 수가 변동 없을 때 } document.title = title; // 제목을 업데이트 previousViewers = viewers; // 이전 시청자 수 업데이트 previousTitle = originalTitle; // 현재 제목을 이전 제목으로 업데이트 }; const disableAutoVOD = () => { const container = unsafeWindow.liveView?.aContainer?.[1]; if (container) { if (container.autoPlayVodBanner) { container.autoPlayVodBanner.show = () => {}; } } else { setTimeout(disableAutoVOD, 1000); // container가 없으면 재시도 } }; const checkMediaInfo = async (mediaName, isAutoLevelEnabled) => { if (mediaName !== 'original' || isAutoLevelEnabled) { // 원본 화질로 설정되지 않은 경우 or 자동 화질 선택인 경우 const player = await waitForElementAsync('#player'); player.className = 'video mouseover ctrl_output'; // 설정 버튼 클릭 const settingButton = await waitForElementAsync('#player > div.player_ctrlBox > div.ctrlBox > div.right_ctrl .setting_box > button.btn_setting'); settingButton.click(); // 화질 변경 리스트 대기 const settingList = await waitForElementAsync('#player > div.player_ctrlBox > div.ctrlBox > div.right_ctrl .setting_box.on .setting_list'); const spanElement = Array.from(settingList.querySelectorAll('span')).find(el => el.textContent.includes("화질 변경")); const buttonElement = spanElement.closest('button'); buttonElement.click(); // 두 번째 설정 대기 const resolutionButton = await waitForElementAsync('#player > div.player_ctrlBox > div.ctrlBox > div.right_ctrl .setting_box .setting_list_subLayer ul > li:nth-child(2) > button'); resolutionButton.click(); resolutionButton.className = 'video'; } }; const getCurrentInternalQuality = () => { try { const playerInfo = unsafeWindow.LivePlayer.getPlayerInfo(); return playerInfo?.quality || null; } catch (e) { console.warn("[getCurrentInternalQuality] 오류 발생:", e); return null; } }; const getIsAutoQualityMode = () => { try { const playerInfo = unsafeWindow.LivePlayer.getPlayerInfo(); return !!playerInfo?.qualityInfo?.isAuto; } catch (e) { console.warn("[getIsAutoQualityMode] 오류 발생:", e); return false; } }; const changeQualityLivePlayer = async (qualityName) => { const current = getCurrentInternalQuality(); if (current === qualityName) { console.log(`[화질 변경 스킵] 현재(${current}) = 요청(${qualityName})`); return; } try { unsafeWindow.livePlayer.changeQuality(qualityName); console.log(`[화질 변경] → ${qualityName}`); } catch (e) { console.warn("[changeQualityLivePlayer] 변경 실패:", e); } }; const downgradeFrom1440p = async () => { try { const livePlayer = await waitForLivePlayer(); const info = await livePlayer.getLiveInfo(); const presets = info.CHANNEL.VIEWPRESET.filter(p => p.name !== 'auto' && p.bps); const index1440 = presets.findIndex(p => p.label_resolution === '1440'); if (index1440 === -1) { console.warn('1440p 화질 정보를 찾을 수 없습니다.'); return; } if (index1440 === 0) { console.log('1440p가 최저 화질이라서 더 낮출 수 없습니다.'); return; } const lowerPreset = presets[index1440 - 1]; const targetName = qualityNameToInternalType[lowerPreset.name]; if (!targetName) { console.warn(`하위 화질 ${lowerPreset.name}에 대한 매핑이 없습니다.`); return; } console.log(`1440p에서 ${lowerPreset.label}(${targetName})로 다운그레이드 시도`); livePlayer.changeQuality(targetName); } catch (e) { console.error(e.message); } }; const initializeQualityShortcuts = () => { // --- 1. 상태 관리 변수 --- let shortcutMap = new Map(); let isKeyListenerAdded = false; // --- 2. 핵심 로직 함수 --- const setupQualityShortcuts = async (targetDiv) => { try { const qualityBox = targetDiv || document.querySelector('.quality_box ul'); // 화질 목록이 없거나, 화질 목록의 li 요소가 없으면 실행 중단 (안정성 강화) if (!qualityBox || !qualityBox.querySelector('li')) { console.log('화질 목록을 찾을 수 없어 단축키 설정을 건너뜁니다.'); return; } console.log('화질 목록 변경 감지. 단축키를 업데이트합니다.'); const livePlayer = await waitForLivePlayer(); const info = await livePlayer.getLiveInfo(); const presets = info.CHANNEL.VIEWPRESET; if (!presets || presets.length === 0) return; // (이하 화질 정렬, 버튼 매핑, 단축키 설정 로직은 원본과 동일) presets.sort((a, b) => { if (a.name === 'auto') return -1; if (b.name === 'auto') return 1; return parseInt(b.label_resolution || 0) - parseInt(a.label_resolution || 0); }); const buttonMap = new Map(); qualityBox.querySelectorAll('li button').forEach(btn => { if (btn.closest('li')?.style.display !== 'none') { const span = btn.querySelector('span'); if (span) { const currentText = (span.textContent.split(' (')[0]).trim(); buttonMap.set(currentText, btn); } } }); const newShortcutMap = new Map(); const shortcutKeys = ['`', '1', '2', '3', '4', '5', '6', '7', '8', '9']; presets.forEach((preset, index) => { if (index >= shortcutKeys.length) return; const button = buttonMap.get(preset.label); if (button) { const shortcutKey = shortcutKeys[index]; const internalType = qualityNameToInternalType[preset.name]; if (internalType) { newShortcutMap.set(shortcutKey, internalType); button.querySelector('span').textContent = `${preset.label} (${shortcutKey})`; } } }); shortcutMap = newShortcutMap; } catch (e) { console.error('화질 단축키 설정 중 오류 발생:', e); } }; // --- 3. 이벤트 핸들러 --- const handleQualityKeyDown = async (event) => { if (isUserTyping()) return; const key = event.key === '~' ? '`' : event.key; if (shortcutMap.has(key)) { event.preventDefault(); const targetQuality = shortcutMap.get(key); try { showPlayerBar(); const livePlayer = await waitForLivePlayer(); livePlayer.changeQuality(targetQuality); } catch (e) { console.error('화질 변경에 실패했습니다.', e); } } }; // --- 4. 기능 설치 로직 --- // 키보드 리스너는 한 번만 설치 if (!isKeyListenerAdded) { document.addEventListener('keydown', handleQualityKeyDown, true); isKeyListenerAdded = true; } // 디바운스가 적용된 단축키 설정 함수 생성 const debouncedSetup = debounce(setupQualityShortcuts, 1000); (async () => { const qualityBoxDiv = await waitForElementAsync('.quality_box ul'); setupQualityShortcuts(qualityBoxDiv); })() observeUrlChanges(() => { setTimeout(setupQualityShortcuts,2000); }); } // 3.6. 스크립트 실행 관리 함수 (Execution Management) const runCommonFunctions = () => { if (isCustomSidebarEnabled) { orderSidebarSection(); hideUsersSection(); generateBroadcastElements(0); checkSidebarVisibility(); } // 본문 방송 목록의 새 탭 열기 방지 if(!isOpenNewtabEnabled){ setInterval(removeTargetFromLinks, 1000); } setupSettingButtonTopbar(); if (isMonthlyRecapEnabled) observeAndAppendRecapButton(); registerMenuBlockingWord(); blockedUsers.forEach(function(user) { registerUnblockMenu(user); }); blockedCategories.forEach(function(category) { registerCategoryUnblockMenu(category); }); blockedWords.forEach(function(word) { registerWordUnblockMenu(word); }); }; const hideUsersSection = () => { const styles = [ !displayMyplus && '#sidebar .myplus { display: none !important; }', !displayMyplusvod && '#sidebar .myplusvod { display: none !important; }', !displayTop && '#sidebar .top { display: none !important; }' ].filter(Boolean).join(' '); // 빈 값 제거 및 합침 if (styles) { GM_addStyle(styles); } }; const removeTargetFromLinks = () => { try { const links = document.querySelectorAll('#container a[target], .side_list a[target]'); links.forEach(link => { link.removeAttribute('target'); }); } catch (error) { console.error('target 속성 제거 중 오류 발생:', error); } }; const orderSidebarSection = () => { const style = ` #sidebar .top-section.top { order: 3 !important; } #sidebar .users-section.top { order: 4 !important; } #sidebar .top-section.myplus { order: 5 !important; } #sidebar .users-section.myplus { order: 6 !important; } #sidebar .top-section.myplusvod { order: 7 !important; } #sidebar .users-section.myplusvod { order: 8 !important; } `; if (!myplusPosition) { GM_addStyle(style); } }; class StreamerActivityScanner { // 설정값 #STREAMER_ID_LIST; #LOCALSTORAGE_KEY = 'VOD_CHAT_PANEL_STATE_V2'; // 상태값 #vodCore; #streamerActivityLog = []; #isScanCompleted = false; // UI 요소 #panelElement = null; #controlButton = null; #notificationTimeout = null; #abortController = null; /** * 클래스 생성자 * @param {object} vodCore - SOOP VOD 플레이어의 핵심 객체 * @param {Set} targetIds - 추적할 스트리머 ID 목록 (Set 객체) */ constructor(vodCore, targetIds) { if (!vodCore) throw new Error("vodCore 객체가 필요합니다."); if (!targetIds) throw new Error("타겟 ID 목록이 필요합니다."); this.#vodCore = vodCore; this.#STREAMER_ID_LIST = targetIds; this.#setupUI(); // UI 생성 및 초기화 } // --- Private Static Methods (내부 헬퍼 함수) --- static #secondsToHMS(seconds) { seconds = Math.floor(seconds); const h = String(Math.floor(seconds / 3600)).padStart(2, '0'), m = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0'), s = String(seconds % 60).padStart(2, '0'); return `[${h}:${m}:${s}]`; } static #xmlToJson(xml) { var obj = {}; if (xml.nodeType === 1) { if (xml.attributes.length > 0) { obj["@attributes"] = {}; for (var j = 0; j < xml.attributes.length; j++) { var attribute = xml.attributes.item(j); obj["@attributes"][attribute.nodeName] = attribute.nodeValue; } } } else if (xml.nodeType === 3 || xml.nodeType === 4) { obj = xml.nodeValue; } if (xml.hasChildNodes()) { for (var i = 0; i < xml.childNodes.length; i++) { var item = xml.childNodes.item(i); var nodeName = item.nodeName; if (typeof(obj[nodeName]) === "undefined") { obj[nodeName] = StreamerActivityScanner.#xmlToJson(item); } else { if (typeof(obj[nodeName].push) === "undefined") { var old = obj[nodeName]; obj[nodeName] = []; obj[nodeName].push(old); } obj[nodeName].push(StreamerActivityScanner.#xmlToJson(item)); } } } return obj; } static #getColorFromUserId(userId) { let hash = 0; for (let i = 0; i < userId.length; i++) { hash = userId.charCodeAt(i) + ((hash << 5) - hash); } const hue = hash % 360; return `hsl(${hue}, 75%, 75%)`; } static async #fetchAndParseChatData(url, signal) { const response = await fetch(url, { cache: "force-cache", signal }); const data = await response.text(); const parser = new DOMParser(); const xmlDoc = parser.parseFromString(data, "text/xml"); const jsonData = StreamerActivityScanner.#xmlToJson(xmlDoc); if (jsonData.root && Array.isArray(jsonData.root['#text'])) { delete jsonData.root['#text']; } return jsonData; } // --- Private Instance Methods (내부 로직) --- #addStyles() { GM_addStyle(` .chatting-item-wrap{position:relative !important}#streamer-activity-panel{display:none;flex-direction:column;background-color:#202024;border:1px solid #444;border-radius:8px;box-shadow:0 5px 20px rgba(0,0,0,0.4);z-index:9999;color:#efeff1;min-width:340px;min-height:200px;position:relative;overflow:hidden}#sa-panel-header{padding:10px 15px;background-color:#2a2a2e;cursor:move;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #444}#sa-panel-header-title{font-weight:bold;pointer-events:none;flex-grow:1}#sa-panel-notification{color:#6bff96;font-size:13px;font-weight:bold;opacity:0;transition:opacity 0.5s;pointer-events:none;text-align:right}#sa-panel-close-btn{background:none;border:none;color:#aaa;font-size:20px;cursor:pointer;line-height:1;margin-left:10px}#sa-panel-content{flex-grow:1;overflow-y:auto}#sa-activity-list{list-style:none;padding:10px;margin:0}#sa-activity-list li{display:grid;grid-template-columns:65px 24px 1fr;gap:0 8px;align-items:flex-start;padding:6px 10px;border-radius:4px;line-height:1.5;font-size:14px}#sa-activity-list li:hover{background-color:#3a3a3d}#sa-activity-list .timestamp{color:#a9a9b3;cursor:pointer;white-space:nowrap;font-size:15px;margin-top:2px}#sa-activity-list .timestamp:hover{text-decoration:underline}#sa-activity-list .profile-pic{width:24px;height:24px;border-radius:50%;margin-top:1px}#sa-activity-list .content-wrap{word-break:break-all;color:#dcdcdc}#sa-activity-list .username-link{text-decoration:none;color:inherit;font-weight:bold;margin-right:6px;font-size:16px}#sa-activity-list .username-link:hover .username{text-decoration:underline}#sa-activity-list .message{font-size:16px}#sa-activity-list li.special-activity .content-wrap .message{font-style:italic}#sa-panel-resize-handle{position:absolute;right:0;bottom:0;width:15px;height:15px;cursor:se-resize;background-color:transparent;z-index:10000}#sa-panel-resize-handle::after{content:'';position:absolute;right:2px;bottom:2px;width:8px;height:8px;background:linear-gradient(135deg,transparent 0%,transparent 40%,#888 40%,#888 60%,transparent 60%,transparent 100%);pointer-events:none}#sa-control-btn{position:absolute;top:10px;right:10px;z-index:1000;padding:5px 10px;font-size:12px;color:white;background-color:#757575;border:none;border-radius:5px;cursor:pointer;transition:all 0.2s}#sa-control-btn:hover:not(:disabled){background-color:#9E9E9E} `); } #setupUI() { this.#addStyles(); this.#panelElement = document.createElement('div'); this.#panelElement.id = 'streamer-activity-panel'; this.#panelElement.innerHTML = `
    `; this.#panelElement.querySelector('#sa-panel-close-btn').onclick = () => this.hidePanel(); this.#initDraggableAndResizablePanel(); const chatWrap = document.querySelector('.chatting-item-wrap'); if (chatWrap) { this.#controlButton = document.createElement("button"); this.#controlButton.id = "sa-control-btn"; this.#controlButton.onclick = () => { this.#isScanCompleted ? this.showPanel() : this.startScan(); }; chatWrap.appendChild(this.#controlButton); this.#updateButton('채팅 스캔', false); } } #storeStreamerActivity(jsonData, accumulatedTime) { const processItems = (items, type) => { if (!items) return; if (!Array.isArray(items)) items = [items]; for (const item of items) { const userId = item.u ? item.u['#text'].split('(')[0] : ''; if (this.#STREAMER_ID_LIST.has(userId)) { const seconds = parseFloat(item.t['#text']) + accumulatedTime; const activity = { type, seconds: Math.floor(seconds), userId, userName: item.n ? item.n['#cdata-section'] : '알 수 없음', message: '' }; switch (type) { case 'chat': activity.message = item.m ? item.m['#cdata-section'] : ''; break; case 'balloon': activity.message = `별풍선 ${item.c ? item.c['#text'] : '0'}개`; break; case 'challenge_mission': case 'battle_mission': activity.message = `${type === 'challenge_mission' ? '도전' : '대결'} 미션 후원 ${item.c ? item.c['#text'] : '0'}개 (${item.title ? item.title['#cdata-section'] : '제목 없음'})`; break; } this.#streamerActivityLog.push(activity); // [수정] 올바른 로그 배열에 추가 } } }; if (jsonData && jsonData.root) { processItems(jsonData.root.chat, 'chat'); processItems(jsonData.root.balloon, 'balloon'); processItems(jsonData.root.challenge_mission, 'challenge_mission'); processItems(jsonData.root.battle_mission, 'battle_mission'); } } #updateButton(text, disabled) { if (!this.#controlButton) return; this.#controlButton.textContent = text; this.#controlButton.disabled = disabled; this.#controlButton.style.cursor = disabled ? "not-allowed" : "pointer"; this.#controlButton.style.opacity = disabled ? "0.7" : "1"; } #showNotification(message, isError = false) { if (!this.#panelElement) return; const notification = this.#panelElement.querySelector('#sa-panel-notification'); if (!notification) return; clearTimeout(this.#notificationTimeout); notification.textContent = message; notification.style.color = isError ? '#ff6b6b' : '#6bff96'; notification.style.opacity = '1'; this.#notificationTimeout = setTimeout(() => { notification.style.opacity = '0'; }, 3000); } #positionPanelFirstTime() { const panel = this.#panelElement; let savedState = null; try { savedState = JSON.parse(localStorage.getItem(this.#LOCALSTORAGE_KEY)); } catch (e) { localStorage.removeItem(this.#LOCALSTORAGE_KEY); } if (savedState?.width && savedState?.height && savedState?.top && savedState?.left) { Object.assign(panel.style, { position: 'fixed', width: savedState.width, height: savedState.height, top: savedState.top, left: savedState.left, right: 'auto', bottom: 'auto' }); document.body.appendChild(panel); } else { const chatWrap = document.querySelector('.chatting-item-wrap'); Object.assign(panel.style, { position: 'absolute', width: '390px', height: '390px', top: '0px' }); if (chatWrap) { panel.style.right = chatWrap.offsetWidth > 390 ? '0px' : 'auto'; panel.style.left = chatWrap.offsetWidth > 390 ? 'auto' : '0px'; chatWrap.appendChild(panel); } else { Object.assign(panel.style, { position: 'fixed', top: '100px', left: '100px' }); document.body.appendChild(panel); } } } #initDraggableAndResizablePanel() { const panel = this.#panelElement; const header = panel.querySelector('#sa-panel-header'); const resizeHandle = panel.querySelector('#sa-panel-resize-handle'); if (!header || !resizeHandle) return; let isDragging = false, isResizing = false; let initial = {}; const onDrag = e => { if (isDragging) { panel.style.top = `${e.clientY - initial.y}px`; panel.style.left = `${e.clientX - initial.x}px`; } if (isResizing) { panel.style.width = `${initial.w + (e.clientX - initial.x)}px`; panel.style.height = `${initial.h + (e.clientY - initial.y)}px`; } }; const stopActions = () => { if ((isDragging || isResizing) && panel.parentElement === document.body) { const finalState = { width: panel.style.width, height: panel.style.height, top: panel.style.top, left: panel.style.left }; localStorage.setItem(this.#LOCALSTORAGE_KEY, JSON.stringify(finalState)); } isDragging = isResizing = false; panel.style.userSelect = 'auto'; window.removeEventListener('mousemove', onDrag); window.removeEventListener('mouseup', stopActions); }; header.addEventListener('mousedown', e => { if (e.target.closest('#sa-panel-close-btn')) return; if (panel.parentElement !== document.body) { const rect = panel.getBoundingClientRect(); Object.assign(panel.style, { position: 'fixed', top: `${rect.top}px`, left: `${rect.left}px`, right: 'auto', bottom: 'auto', margin: '0' }); document.body.appendChild(panel); } isDragging = true; panel.style.userSelect = 'none'; initial = { x: e.clientX - panel.offsetLeft, y: e.clientY - panel.offsetTop }; window.addEventListener('mousemove', onDrag); window.addEventListener('mouseup', stopActions); }); resizeHandle.addEventListener('mousedown', e => { isResizing = true; panel.style.userSelect = 'none'; initial = { x: e.clientX, y: e.clientY, w: panel.offsetWidth, h: panel.offsetHeight }; e.stopPropagation(); window.addEventListener('mousemove', onDrag); window.addEventListener('mouseup', stopActions); }); } // --- Public Methods --- async startScan() { // [핵심 추가] 이전 스캔이 있다면 취소하고, 새 AbortController를 생성 this.#abortController?.abort(); this.#abortController = new AbortController(); const signal = this.#abortController.signal; try { const streamerCount = this.#STREAMER_ID_LIST.size; this.#updateButton(`스트리머 ${streamerCount}명 스캔 중... 0%`, true); this.#streamerActivityLog = []; let accumulatedTime = 0; for (const item of this.#vodCore.fileItems) { const progress = Math.round((accumulatedTime / this.#vodCore.config.totalFileDuration) * 100); this.#updateButton(`스트리머 ${streamerCount}명 스캔 중... ${progress}%`, true); const url = item.fileInfoKey.includes("clip_") ? `https://vod-normal-kr-cdn-z01.sooplive.co.kr/${item.fileInfoKey.split("_").join("/")}_c.xml?type=clip&rowKey=${item.fileInfoKey}_c` : `https://videoimg.sooplive.co.kr/php/ChatLoadSplit.php?rowKey=${item.fileInfoKey}_c`; for (let cs = 0; cs <= item.duration; cs += 300) { // [핵심 수정] fetch 함수에 signal 전달 const chatData = await StreamerActivityScanner.#fetchAndParseChatData(`${url}&startTime=${cs}`, signal); this.#storeStreamerActivity(chatData, accumulatedTime); await new Promise(r => setTimeout(r, 50)); } accumulatedTime += parseInt(item.duration); } this.#streamerActivityLog.sort((a, b) => { const timeDiff = a.seconds - b.seconds; return (timeDiff !== 0) ? timeDiff : (a.type !== 'chat' ? 0 : 1) - (b.type !== 'chat' ? 0 : 1); }); this.#isScanCompleted = true; this.#updateButton("스트리머 활동 보기", false); this.#showNotification(`스캔 완료! (${this.#streamerActivityLog.length}개)`); this.showPanel(); } catch (error) { // [핵심 추가] AbortError는 사용자가 의도한 취소이므로 오류로 처리하지 않음 if (error.name === 'AbortError') { console.log("VOD Chat Scanner: 스캔이 중단되었습니다."); return; } console.error("스트리머 활동 스캔 중 오류:", error); this.#updateButton("오류 (클릭하여 재시도)", false); this.#showNotification("오류가 발생했습니다.", true); } } populatePanel() { if (!this.#panelElement) return; const list = this.#panelElement.querySelector("#sa-activity-list"); const header = this.#panelElement.querySelector("#sa-panel-header-title"); const contentDiv = this.#panelElement.querySelector("#sa-panel-content"); if (!list || !header || !contentDiv) return; header.textContent = `스트리머 활동 로그`; list.innerHTML = this.#streamerActivityLog.length ? '' : "
  • 감지된 스트리머 활동이 없습니다.
  • "; this.#streamerActivityLog.forEach(activity => { const item = document.createElement("li"); const { userId, userName, message, type, seconds } = activity; const userColor = StreamerActivityScanner.#getColorFromUserId(userId); const profileImgUrl = `https://profile.img.sooplive.co.kr/LOGO/${userId.substring(0, 2)}/${userId}/${userId}.jpg`; const messageContent = type !== 'chat' ? ('🎈 ' + message) : message; if(type !== 'chat') item.classList.add('special-activity'); item.innerHTML = ` ${StreamerActivityScanner.#secondsToHMS(seconds).replace(/[\[\]]/g, '')} profile
    ${userName} ${messageContent}
    `; item.querySelector('.timestamp').onclick = () => { unsafeWindow.vodCore.seek(Math.max(0, seconds - 2)); }; list.appendChild(item); }); contentDiv.scrollTop = contentDiv.scrollHeight; } showPanel() { if (!this.#panelElement) return; if (!this.#panelElement.parentElement) { this.#positionPanelFirstTime(); } this.populatePanel(); this.#panelElement.style.display = 'flex'; } hidePanel() { if (this.#panelElement) this.#panelElement.style.display = 'none'; } destroy() { this.#abortController?.abort(); this.#panelElement?.remove(); this.#controlButton?.remove(); const leftoverButton = document.getElementById("sa-control-btn"); const leftoverPanel = document.getElementById("streamer-activity-panel"); leftoverButton?.remove(); leftoverPanel?.remove(); console.log("VOD Chat Scanner: 인스턴스가 파괴되었습니다."); } } //====================================== // 4. 메인 실행 로직 (Main Execution Logic) //====================================== if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { if (isChangeFontEnabled) applyFontStyles(); loadCategoryData(); }); } else { if (isChangeFontEnabled) applyFontStyles(); loadCategoryData(); } // 4.1. 메인 페이지 실행 (sooplive.co.kr) if (CURRENT_URL.startsWith("https://www.sooplive.co.kr")) { GM_addStyle(CommonStyles); GM_addStyle(mainPageCommonStyles); if (isReplaceEmptyThumbnailEnabled) { loadHlsScript(); } if (isCustomSidebarEnabled) document.body.classList.add('customSidebar'); (async () => { const serviceLnbDiv = await waitForElementAsync('#serviceLnb'); if (isCustomSidebarEnabled) makeTopNavbarAndSidebar("main"); runCommonFunctions(); })() removeUnwantedTags(); processStreamers(); return; } // 4.2. 플레이어 페이지 실행 (play.sooplive.co.kr) if (CURRENT_URL.startsWith("https://play.sooplive.co.kr")) { const blankA = !!document.getElementById("bannedMessages"); const blankB = !!document.getElementById("targetUserMessages"); // Embed 페이지에서는 실행하지 않음 const pattern = /^https:\/\/play.sooplive.co.kr\/.*\/.*\/embed(\?.*)?$/; if (pattern.test(CURRENT_URL) || CURRENT_URL.includes("vtype=chat") || blankA || blankB) { return; } GM_addStyle(CommonStyles); GM_addStyle(playerCommonStyles); if (isCustomSidebarEnabled) document.body.classList.add('customSidebar'); if (isCustomSidebarEnabled) { makeTopNavbarAndSidebar("player"); insertFoldButton(); if(showSidebarOnScreenMode && !showSidebarOnScreenModeAlways) { showSidebarOnMouseOver(); } } if(isBottomChatEnabled) useBottomChat(); if(isMakePauseButtonEnabled) { appendPauseButton(); observeUrlChanges(() => { appendPauseButton(); }); }; if(isMakeSharpModeShortcutEnabled) toggleSharpModeShortcut(); if(isMakeLowLatencyShortcutEnabled) toggleLowLatencyShortcut(); if(isMakeQualityChangeShortcutEnabled) initializeQualityShortcuts(); if(isRemainingBufferTimeEnabled){ (async () => { const livePlayerDiv = await waitForElementAsync('#livePlayer'); insertRemainingBuffer(livePlayerDiv); })() } if(isCaptureButtonEnabled){ makeCaptureButton(); } if(isAutoClaimGemEnabled){ setInterval(autoClaimGem, 30000); } if(isVideoSkipHandlerEnabled){ (async () => { const livePlayerDiv = await waitForElementAsync('#livePlayer'); window.addEventListener('keydown', videoSkipHandler); })() } registerVisibilityChangeHandler(); registerVisibilityChangeHandlerForQuality(); if (isNo1440pEnabled) { downgradeFrom1440p(); observeUrlChanges(() => { setTimeout(downgradeFrom1440p, 4000); }); } checkPlayerPageHeaderAd(); if(!isOpenNewtabEnabled){ homePageCurrentTab(); } if(isDocumentTitleUpdateEnabled){ setTimeout(updateTitleWithViewers, 10000); setInterval(updateTitleWithViewers, 60000); } runCommonFunctions(); // LIVE 채팅창 (async () => { const chatAreaDiv = await waitForElementAsync('#chat_area'); observeChat('#chat_area',chatAreaDiv); })() if (isUnlockCopyPasteEnabled) { (async () => { const writeArea = await waitForElementAsync('#write_area'); unlockCopyPaste(writeArea); })() }; if (isAlignNicknameRightEnabled) { alignNicknameRight(); } if (isAutoScreenModeEnabled) { (async () => { const btnScreenModeDiv = await waitForElementAsync('#livePlayer'); if (!document.body.classList.contains('screen_mode')) { document.body.querySelector('#player .btn_screen_mode').click(); } })() } if (isClickToMuteEnabled) { initPlayerClickToMute(); } if (ishideButtonsAboveChatInputEnabled) { hideButtonsAboveChatInput(); } if (isExpandLiveChatAreaEnabled) { setupExpandLiveChatFeature(); } if (isShowDeletedMessagesEnabled || isShowSelectedMessagesEnabled) { (async () => { const chattingItemWrapDiv = await waitForElementAsync('.chatting-item-wrap'); setupChatMessageTrackers(chattingItemWrapDiv); })(); observeUrlChanges(() => { unsafeWindow.resetChatData(); }); } if (isNoAutoVODEnabled) { disableAutoVOD(); } if (isHideEsportsInfoEnabled) { GM_addStyle(` body:not(.screen_mode,.fullScreen_mode,.embeded_mode) #webplayer #webplayer_contents #player_area .broadcast_information.detail_open .esports_info { display: none !important; } .broadcast_information .esports_info { display: none !important; } ` ); } observeUrlChanges(() => { emptyViewStreamer(); updateBjIdIfMismatch(); }); return; } // 4.3. VOD 페이지 실행 (vod.sooplive.co.kr) if (CURRENT_URL.startsWith("https://vod.sooplive.co.kr/player/")) { const isBaseUrl = (url) => /https:\/\/vod\.sooplive\.co\.kr\/player\/\d+/.test(url) && !isCatchUrl(url); const isCatchUrl = (url) => /https:\/\/vod\.sooplive\.co\.kr\/player\/\d+\/catch/.test(url) || /https:\/\/vod\.sooplive\.co\.kr\/player\/catch/.test(url); // 다시보기 페이지 if (isBaseUrl(CURRENT_URL)) { GM_addStyle(CommonStyles); const waitForVodCore = async () => { try { const vodCore = await waitForVariable('vodCore'); const mediaInfo = await new Promise((resolve, reject) => { const MEDIA_INFO_TIMEOUT = 15000; const timer = setInterval(() => { const info = vodCore.playerController?._currentMediaInfo; if (info?.name) { clearTimeout(timeoutHandle); clearInterval(timer); resolve(info); } }, 1000); const timeoutHandle = setTimeout(() => { clearInterval(timer); // 불필요한 인터벌 중지 reject(new Error('미디어 정보(mediaInfo) 로딩 시간을 초과했습니다.')); }, MEDIA_INFO_TIMEOUT); }); checkMediaInfo(mediaInfo.name, mediaInfo.isAutoLevelEnabled); } catch (error) { console.error("VOD 플레이어 초기화에 실패했습니다:", error); } }; if(isVODChatScanEnabled){ let scannerInstance = null; function initVODChatScanApp() { waitForVariable('vodCore') .then(vodCore => { const STREAMER_ID_LIST = targetUserIdSet; scannerInstance = new StreamerActivityScanner(vodCore, STREAMER_ID_LIST); }) .catch(console.error); } initVODChatScanApp(); observeUrlChanges(() => { scannerInstance?.destroy(); // 기존 인스턴스 파괴 scannerInstance = null; setTimeout(initVODChatScanApp, 2000); }); } if(isSelectBestQualityEnabled){ waitForVodCore(); observeUrlChanges(() => { setTimeout(waitForVodCore, 2000); }); } if(isCaptureButtonEnabled){ makeCaptureButton(); } // VOD 채팅창 (async () => { const webplayerContentsDiv = await waitForElementAsync('#webplayer_contents'); observeChat('#webplayer_contents', webplayerContentsDiv); })(); setupSettingButtonTopbar(); if (isAlignNicknameRightEnabled) { alignNicknameRight(); } if (isExpandVODChatAreaEnabled) { setupExpandVODChatFeature(); } if (isMonthlyRecapEnabled) observeAndAppendRecapButton(); // 캐치 페이지 } else if (isCatchUrl(CURRENT_URL)) { GM_addStyle(CommonStyles); GM_addStyle(mainPageCommonStyles); if (isCustomSidebarEnabled) document.body.classList.add('customSidebar'); (async () => { const serviceLnbDiv = await waitForElementAsync('#serviceLnb'); if (isCustomSidebarEnabled) makeTopNavbarAndSidebar("main"); runCommonFunctions(); })() if (isRemoveShadowsFromCatchEnabled) addStyleRemoveShadowsFromCatch(); } } })();