// ==UserScript== // @name SOOP (숲) - 사이드바 UI 변경(백업본) // @name:ko SOOP (숲) - 사이드바 UI 변경(백업본) // @namespace https://greasyfork.org/ko/scripts/551140 // @version 20260430(08.01) // @description 사이드바 UI 변경, 월별 리캡, 채팅 모아보기, 차단기능, VOD 사이드바, 채팅 삭제 등 // @description:ko 사이드바 UI 변경, 월별 리캡, 채팅 모아보기, 차단기능, VOD 사이드바, 채팅 삭제 등 // @author askld / eldirna(복구) // @match https://www.sooplive.com/* // @match https://play.sooplive.com/* // @match https://vod.sooplive.com/player/* // @match https://www.sooplive.com/station/* // @match https://ch.sooplive.com/* // @icon https://res.sooplive.com/afreeca.ico // @grant unsafeWindow // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_openInTab // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @connect sooplive.com // @connect *.sooplive.com // @run-at document-end // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/551140/SOOP%20%28%EC%88%B2%29%20-%20%EC%82%AC%EC%9D%B4%EB%93%9C%EB%B0%94%20UI%20%EB%B3%80%EA%B2%BD%28%EB%B0%B1%EC%97%85%EB%B3%B8%29.user.js // @updateURL https://update.greasyfork.icu/scripts/551140/SOOP%20%28%EC%88%B2%29%20-%20%EC%82%AC%EC%9D%B4%EB%93%9C%EB%B0%94%20UI%20%EB%B3%80%EA%B2%BD%28%EB%B0%B1%EC%97%85%EB%B3%B8%29.meta.js // ==/UserScript== (function () { 'use strict'; //====================================== // 1. 전역 변수 및 설정 (Global Variables & Configuration) //====================================== const NEW_UPDATE_DATE = 20260430; const CURRENT_URL = window.location.href; const IS_DARK_MODE = document.documentElement.getAttribute('dark') === 'true'; const HIDDEN_BJ_LIST = []; let bestStreamersList = GM_getValue('bestStreamersList', []); 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 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 isOpenBackgroundTabEnabled = GM_getValue("isOpenBackgroundTabEnabled", 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 targetQuality = GM_getValue("targetQuality", "원본"); 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 isCatchAutoNextEnabled = GM_getValue("isCatchAutoNextEnabled", 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", 0); 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 isPreviewModalEnabled = GM_getValue("isPreviewModalEnabled", 1); let isPreviewModalRightClickEnabled = GM_getValue("isPreviewModalRightClickEnabled", 0); let isPreviewModalFromSidebarEnabled = GM_getValue("isPreviewModalFromSidebarEnabled", 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 isRemoveShadowsFromCatchEnabled = GM_getValue("isRemoveShadowsFromCatchEnabled", 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 isRedirectLiveEnabled = GM_getValue("isRedirectLiveEnabled", 0); let redirectLiveSortOption = GM_getValue("redirectLiveSortOption", "custom"); 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); let isVODHighlightEnabled = GM_getValue("isVODHighlightEnabled", 0); let isCheckBestStreamersListEnabled = GM_getValue("isCheckBestStreamersListEnabled", 0); let isClickPlayerEventMapperEnabled = GM_getValue("isClickPlayerEventMapperEnabled", 0); let isFavoriteGroupEnabled = GM_getValue("isFavoriteGroupEnabled", 1); let isCategoryGroupEnabled = GM_getValue("isCategoryGroupEnabled", 1); let isShortenFavoriteGroupNameEnabled = GM_getValue("isShortenFavoriteGroupNameEnabled", 0); let isShortenCategoryNameEnabled = GM_getValue("isShortenCategoryNameEnabled", 0); let isPlayerAdvancedControlsLiveEnabled = GM_getValue("isPlayerAdvancedControlsLiveEnabled", 1); let isPlayerAdvancedControlsVODEnabled = GM_getValue("isPlayerAdvancedControlsVODEnabled", 1); let isPlayerPanzoomEnabled = GM_getValue("isPlayerPanzoomEnabled", 1); let isPlayerPanzoomVODEnabled = GM_getValue("isPlayerPanzoomVODEnabled", 1); let isHideDuplicateChatEnabled = GM_getValue("isHideDuplicateChatEnabled", 0); let isChatResizableEnabled = GM_getValue("isChatResizableEnabled", 0); let chatPanelWidth = GM_getValue("chatPanelWidth", 300); let isHideLiveProgressBarEnabled = GM_getValue("isHideLiveProgressBarEnabled", 0); // ★ [추가] 라이브 재생바 숨기기 let selectedFavoriteGroupIdx = isFavoriteGroupEnabled ? GM_getValue("selectedFavoriteGroupIdx", 'all') : 'all'; let selectedPinnedCategoryIdx = isCategoryGroupEnabled ? GM_getValue('selectedPinnedCategoryIdx', 'all') : 'all'; let sidebarSectionOrder = GM_getValue('sidebarSectionOrder', ['follow', 'myplus', 'myplusvod', 'top']); let pinnedCategories = GM_getValue('pinnedCategories', []); // 여러 카테고리 저장 let allSections = []; let cachedTopSectionBroadData = null; const WEB_PLAYER_SCROLL_LEFT = isSidebarMinimized ? 52 : 240; const parseRegisteredWords = () => registeredWords ? registeredWords.split(',').map(word => word.trim()).filter(Boolean) : []; let REG_WORDS = parseRegisteredWords(); const qualityNameToInternalType = { sd: 'LOW', hd: 'NORMAL', hd4k: 'HIGH_4000', hd8k: 'HIGH_8000', original: 'ORIGINAL', auto: 'AUTO' }; const BUTTON_DATA = [ { href: 'https://www.sooplive.com/live/all', text: 'LIVE', onClickTarget: '#live > a' }, { href: 'https://www.sooplive.com/my/favorite', text: 'MY', onClickTarget: '#my > a' }, { href: 'https://www.sooplive.com/directory/category', text: '탐색', onClickTarget: '#cate > a' }, { href: 'https://vod.sooplive.com/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, ...(isCheckBestStreamersListEnabled ? bestStreamersList : []) ]); // --- 리캡 관련 전역 변수 및 상수 --- // let recapInitialized = false; let recapModalBackdrop = null; // 모달 요소 참조 let activeCharts = []; // 활성 차트 인스턴스 저장 let categoryImageMap = null; // 카테고리 이미지 URL 캐시 const STATS_API_URL = 'https://broadstatistic.sooplive.com/api/watch_statistic.php'; const INFO_API_URL = 'https://afevent2.sooplive.com/api/get_private_info.php'; const SEARCH_API_URL = 'https://sch.sooplive.com/api.php'; const CATEGORY_API_URL = 'https://sch.sooplive.com/api.php'; const screenshotGradientPalette = ['linear-gradient(135deg, #667eea 0%, #764ba2 100%)', 'linear-gradient(135deg, #2af598 0%, #009efd 100%)', 'linear-gradient(135deg, #ffb300 0%, #f44336 100%)', 'linear-gradient(135deg, #2cd48b 0%, #16a085 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']; // 모아보기 버튼 위치 const highlightButtonPosition = isShowDeletedMessagesEnabled ? "40px" : "10px"; const statisticsButtonPosition = isVODChatScanEnabled ? "40px" : "10px"; // 플레이어 클릭 이벤트 설정 const USER_CLICK_CONFIG = { 'click': GM_getValue("livePlayerLeftClickFunction", "toggleMute"), 'contextmenu': GM_getValue("livePlayerRightClickFunction", "toggleScreenMode") }; let previewModalManager = null; let panzoomHandlerInstance = null; let offlineUserModal = null; let sharedTabSyncManager = null; const IS_DEV_MODE = false; const customLog = { log: function (...args) { if (IS_DEV_MODE) { console.log(...args); } }, warn: function (...args) { if (IS_DEV_MODE) { console.warn(...args); } }, error: function (...args) { if (IS_DEV_MODE) { console.error(...args); } } }; // 기본 사이드바 사용시 채팅 모아보기용 팔로우 채널 가져오기 if ( !isCustomSidebarEnabled && (isShowSelectedMessagesEnabled || isShowDeletedMessagesEnabled || isVODChatScanEnabled) && Date.now() - GM_getValue('lastFollowFetchTime', 0) > 900000 // 15분 쿨타임 ) { getFollowList(followUserIdList => { GM_setValue('lastFollowFetchTime', Date.now()); customLog.log('user_ids:', followUserIdList); }); } //====================================== // 2. CSS 스타일 정의 (CSS Styles) //====================================== const CommonStyles = ` .btn_panzoom_toggle { width: 32px; height: 32px; padding: 0; background: none; border: none; cursor: pointer; color: white; opacity: 0.9; } .btn_panzoom_toggle:hover { opacity: 1; } /* 컨트롤 패널과 선택 영역은 기본적으로 완전히 숨김 */ #zoom-controls-container, #roi-selector { display: none !important; } /* Panzoom 활성화 + 마우스 오버 시 컨트롤 패널 표시 */ #player[data-panzoom-enabled="true"].mouseover #zoom-controls-container { display: block !important; } /* ROI 활성화 시 선택 영역을 DOM에 표시하되, 투명하고 클릭 불가능하게 설정 */ #player[data-panzoom-enabled="true"][data-roi-active="true"] #roi-selector { display: block !important; opacity: 0 !important; pointer-events: none !important; transition: opacity 0.2s ease-in-out; } /* ROI 활성화 + 마우스 오버 시 선택 영역을 불투명하고 클릭 가능하게 변경 */ #player[data-panzoom-enabled="true"][data-roi-active="true"].mouseover #roi-selector { opacity: 1 !important; pointer-events: auto !important; } /* 컨트롤 패널 버튼 */ .player_ctrlBox .btn_advanced_controls { width: 32px; height: 32px; padding: 0; border: none; cursor: pointer; opacity: 0.9; background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Cpath d='M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zM15 9h2V7h4V5h-4V3h-2v6z'/%3E%3C/svg%3E") 50% 50% no-repeat; background-size: 22px; } .player_ctrlBox .btn_advanced_controls:hover { opacity: 1; } /* 고급 컨트롤 패널 */ .advanced-controls-panel { position: absolute; bottom: 50px; right: 20px; width: 320px; /* 너비 축소 */ background-color: rgba(24, 24, 27, 0.95); border: 1px solid #4f4f54; border-radius: 8px; z-index: 100; display: none; flex-direction: column; backdrop-filter: blur(5px); } .ac-header { /* 탭 대신 헤더 사용 */ padding: 12px 15px; border-bottom: 1px solid #4f4f54; text-align: center; font-weight: bold; color: #efeff1; } .ac-content { padding: 20px; } .ac-control-group { margin-bottom: 15px; } .ac-control-group label { display: block; margin-bottom: 8px; font-size: 13px; color: #efeff1; } .ac-control-group .slider-container { display: flex; align-items: center; gap: 10px; } .ac-control-group input[type="range"] { flex-grow: 1; -webkit-appearance: none; appearance: none; width: 100%; height: 4px; background: #4f4f54; border-radius: 2px; cursor: pointer; } .ac-control-group input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; background: #5dade2; border-radius: 50%; } .ac-control-group input[type="range"]::-moz-range-thumb { width: 16px; height: 16px; background: #5dade2; border-radius: 50%; border: none; } .ac-control-group .slider-value { font-size: 12px; width: 35px; text-align: right; color: #a9a9b3; } .ac-footer { display: flex; justify-content: flex-end; padding: 10px 20px; border-top: 1px solid #4f4f54; } .ac-reset-btn { background-color: #4f4f54; color: #efeff1; border: none; padding: 8px 15px; border-radius: 5px; cursor: pointer; font-size: 13px; } .ac-reset-btn:hover { background-color: #636369; } #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 .catch_webplayer_wrap { transition: margin-left 0.1s ease-in-out, width 0.1s ease-in-out !important; } body.customSidebar:has(#sidebar.max) .catch_webplayer_wrap { margin-left: 0px !important; width: calc(100vw - 0px) !important; max-width: none !important; } body.customSidebar:has(#sidebar.min) .catch_webplayer_wrap { margin-left: 0px !important; width: calc(100vw - 0px) !important; max-width: 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; } .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; } .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 { --bg-color-v8xK4z: #1a1a1a; --surface-color-v8xK4z: #2c2c2c; --primary-text-v8xK4z: #ffffff; --secondary-text-v8xK4z: #a0a0a0; --accent-color-v8xK4z: #0078d4; --border-color-v8xK4z: #444444; --font-family-v8xK4z: sans-serif; display: none; position: fixed; z-index: 9999; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden; background-color: rgba(0, 0, 0, 0.7); font-family: var(--font-family-v8xK4z); color: var(--primary-text-v8xK4z); } #myModal .modal-content_v8xK4z { background-color: var(--surface-color-v8xK4z); margin: 5vh auto; border: 1px solid var(--border-color-v8xK4z); border-radius: 12px; width: clamp(700px, 90%, 900px); height: 90vh; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); display: flex; flex-direction: row; overflow: hidden; } /* 인덱스 메뉴 스타일 */ #myModal .modal-index_v8xK4z { flex-shrink: 0; width: 180px; padding: 20px 10px; border-right: 1px solid var(--border-color-v8xK4z); background-color: var(--bg-color-v8xK4z); overflow-y: auto; } #myModal .index-title_v8xK4z { font-size: 16px; font-weight: 700; padding: 0 10px 10px; margin: 0 0 10px; border-bottom: 1px solid var(--border-color-v8xK4z); color: var(--primary-text-v8xK4z); } #myModal .index-button_v8xK4z { display: block; width: 100%; padding: 10px 15px; margin-bottom: 5px; background: none; border: none; border-radius: 6px; color: var(--secondary-text-v8xK4z); text-align: left; font-size: 14px; cursor: pointer; transition: background-color 0.2s, color 0.2s; } #myModal .index-button_v8xK4z:hover { background-color: rgba(255, 255, 255, 0.1); color: var(--primary-text-v8xK4z); } #myModal .index-button_v8xK4z.active { background-color: var(--accent-color-v8xK4z); color: white; font-weight: bold; } /* 메인 콘텐츠 영역 스타일 */ #myModal .modal-main-content_v8xK4z { flex-grow: 1; display: flex; flex-direction: column; overflow: hidden; } #myModal .modal-header_v8xK4z { padding: 16px 24px; border-bottom: 1px solid var(--border-color-v8xK4z); display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; } #myModal .modal-title_v8xK4z { font-size: 22px; font-weight: 700; margin: 0; } #myModal .close-button_v8xK4z { background: none; border: none; color: var(--secondary-text-v8xK4z); font-size: 32px; font-weight: bold; cursor: pointer; transition: color 0.2s; } #myModal .close-button_v8xK4z:hover, #myModal .close-button_v8xK4z:focus { color: var(--primary-text-v8xK4z); } #myModal .modal-body_v8xK4z { padding: 24px; overflow-y: auto; flex-grow: 1; padding-bottom: 60vh; } #myModal .modal-footer_v8xK4z { padding-top: 24px; margin-top: 24px; border-top: 1px solid var(--border-color-v8xK4z); } #myModal .section-title_v8xK4z { font-size: 18px; font-weight: 500; color: var(--primary-text-v8xK4z); margin-top: 0; margin-bottom: 20px; border-left: 3px solid var(--accent-color-v8xK4z); padding-left: 10px; scroll-margin-top: 24px; } #myModal .option_v8xK4z { display: grid; grid-template-columns: 1fr auto; align-items: center; padding: 8px; border-radius: 8px; transition: background-color 0.2s; } #myModal .option_v8xK4z label { font-size: 15px; color: var(--secondary-text-v8xK4z); } #myModal .option_v8xK4z:not(.multi-option_v8xK4z):hover { background-color: rgba(255, 255, 255, 0.05); } #myModal .range-option_v8xK4z { grid-template-columns: auto 1fr; gap: 20px; } #myModal .range-container_v8xK4z { display: flex; align-items: center; gap: 15px; } #myModal input[type="range"] { width: 100%; } #myModal .range-value_v8xK4z { font-size: 15px; color: var(--primary-text-v8xK4z); min-width: 30px; text-align: right; } #myModal .switch_v8xK4z { position: relative; display: inline-block; width: 50px; height: 28px; } #myModal .switch_v8xK4z input { opacity: 0; width: 0; height: 0; } #myModal .slider_v8xK4z { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #4d4d4d; transition: .4s; border-radius: 28px; } #myModal .slider_v8xK4z:before { position: absolute; content: ""; height: 20px; width: 20px; left: 4px; bottom: 4px; background-color: white; transition: .4s; border-radius: 50%; } #myModal input:checked + .slider_v8xK4z { background-color: var(--accent-color-v8xK4z); } #myModal input:focus + .slider_v8xK4z { box-shadow: 0 0 1px var(--accent-color-v8xK4z); } #myModal input:checked + .slider_v8xK4z:before { transform: translateX(22px); } #myModal .divider_v8xK4z { border: none; height: 1px; background-color: var(--border-color-v8xK4z); margin: 24px 0; } #myModal .option-details_v8xK4z { grid-column: 1 / -1; display: flex; gap: 15px; } #myModal .mapper-setting_v8xK4z { display: inline; margin-left: 16px; } #myModal .mapper-setting_v8xK4z select { background-color: var(--surface-color-v8xK4z); color: var(--primary-text-v8xK4z); border: 1px solid var(--border-color-v8xK4z); border-radius: 6px; padding: 5px 8px; } #myModal textarea { grid-column: 1 / -1; width: 100%; background-color: #333; border: 1px solid var(--border-color-v8xK4z); border-radius: 6px; color: var(--primary-text-v8xK4z); padding: 10px; resize: vertical; } #myModal .description_v8xK4z { font-size: 12px; color: var(--secondary-text-v8xK4z); margin: 0 0 10px; } #myModal .bug-report_v8xK4z a { color: var(--accent-color-v8xK4z); text-decoration: none; } #myModal .bug-report_v8xK4z a:hover { text-decoration: underline; } #myModal .download-link_v8xK4z a { color: var(--accent-color-v8xK4z); text-decoration: none; } #myModal .download-link_v8xK4z a:hover { text-decoration: underline; } #myModal .modal-body_v8xK4z::-webkit-scrollbar, #myModal .modal-index_v8xK4z::-webkit-scrollbar { width: 8px; } #myModal .modal-body_v8xK4z::-webkit-scrollbar-track, #myModal .modal-index_v8xK4z::-webkit-scrollbar-track { background: var(--surface-color-v8xK4z); } #myModal .modal-body_v8xK4z::-webkit-scrollbar-thumb, #myModal .modal-index_v8xK4z::-webkit-scrollbar-thumb { background-color: var(--border-color-v8xK4z); border-radius: 4px; } #myModal .modal-body_v8xK4z::-webkit-scrollbar-thumb:hover, #myModal .modal-index_v8xK4z::-webkit-scrollbar-thumb:hover { background-color: #555; } /* 여러 옵션을 담는 부모 컨테이너 스타일 */ #myModal .multi-option_v8xK4z { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0 4px; /* 아이템 사이의 간격 */ padding: 0; } /* 개별 옵션 그룹(레이블+스위치) 스타일 */ #myModal .option-group_v8xK4z { /* flex: 1; 이 속성은 더 이상 필요 없으므로 제거합니다. */ display: flex; justify-content: space-between; align-items: center; padding: 8px; border-radius: 8px; border: 1px solid transparent; transition: background-color 0.2s, border-color 0.2s; } /* 개별 그룹에 마우스를 올렸을 때의 스타일 */ #myModal .option-group_v8xK4z:hover { background-color: rgba(255, 255, 255, 0.1); border-color: var(--border-color-v8xK4z); } #myModal .subsection-title_v8xK4z { margin-top: 20px; margin-bottom: 8px; margin-left: 8px; font-size: 14px; color: var(--secondary-text-v8xK4z); font-weight: bold; } #myModal .order-list_v8xK4z { display: flex; flex-direction: row; /* 세로(column)에서 가로(row)로 변경 */ flex-wrap: wrap; /* 공간이 부족하면 다음 줄로 넘어가도록 설정 */ gap: 8px; } #myModal .draggable-item_v8xK4z { background-color: #3a3a40; padding: 8px 12px; /* 패딩을 약간 조정하여 더 컴팩트하게 만듬 */ border-radius: 5px; border: 1px solid #555; cursor: grab; transition: background-color 0.2s; font-size: 14px; white-space: nowrap; /* 아이템 내용이 줄바꿈되지 않도록 설정 */ } #myModal .draggable-item_v8xK4z:hover { background-color: #4a4a50; } #myModal .draggable-item_v8xK4z.dragging_v8xK4z { opacity: 0.5; background-color: #5dade2; cursor: grabbing; } #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, #toggleButton5 { 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, html[dark="true"] #toggleButton5 { color:#A1A1A1; } html:not([dark="true"]) #toggleButton, html:not([dark="true"]) #toggleButton2, html:not([dark="true"]) #toggleButton3, html:not([dark="true"]) #toggleButton4, html:not([dark="true"]) #toggleButton5 { 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; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .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; object-fit: cover; } #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; /* 밝은 모드: 투명하게 */ } /* 2026.02.25 즐겨찾기 그룹 LIVE스트리머 0명일 때 메세지 박스 */ /* ===== empty-follow 메시지 스타일 ===== */ .empty-follow { padding: 18px 12px; font-size: 13px; text-align: center; color: #555; background: rgba(0,0,0,0.04); border-radius: 8px; margin: 10px 12px; display: flex; justify-content: center; align-items: center; line-height: 1.5; font-weight: 500; } /* ===== empty-follow 메시지 스타일 다크모드 대응 ===== */ html[dark="true"] .empty-follow { color: #ccc; background: rgba(255,255,255,0.06); } /* 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; } #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: ${highlightButtonPosition}; } .chat-icon.statistics { right: 7px; width: 22px; height: 22px; bottom: ${statisticsButtonPosition}; } 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 { color: black; 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 { color: white; 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="); } html:not([dark="true"]) .statistics-icon_54334 { color: black; background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2264px%22%20height%3D%2264px%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22%235C5C66%22%3E%3Cg%20id%3D%22SVGRepo_bgCarrier%22%20stroke-width%3D%220%22%3E%3C%2Fg%3E%3Cg%20id%3D%22SVGRepo_tracerCarrier%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke%3D%22%23CCCCCC%22%20stroke-width%3D%220.048%22%3E%3C%2Fg%3E%3Cg%20id%3D%22SVGRepo_iconCarrier%22%3E%3Cdefs%3E%3Cstyle%3E.a%7Bfill%3Anone%3Bstroke%3A%235C5C66%3Bstroke-linecap%3Around%3Bstroke-linejoin%3Around%3Bstroke-width%3A1.5px%3Bfill-rule%3Aevenodd%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cpath%20class%3D%22a%22%20d%3D%22M12%2C2A10%2C10%2C0%2C1%2C0%2C22%2C12H12Z%22%3E%3C%2Fpath%3E%3Cpath%20class%3D%22a%22%20d%3D%22M15%2C9h6.54077A10.02174%2C10.02174%2C0%2C0%2C0%2C15%2C2.45923Z%22%3E%3C%2Fpath%3E%3C%2Fg%3E%3C%2Fsvg%3E"); } html[dark="true"] .statistics-icon_54334 { color: white; background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2264px%22%20height%3D%2264px%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22%23B0B0BA%22%20stroke%3D%22%23B0B0BA%22%3E%3Cg%20id%3D%22SVGRepo_bgCarrier%22%20stroke-width%3D%220%22%3E%3C%2Fg%3E%3Cg%20id%3D%22SVGRepo_tracerCarrier%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke%3D%22%23CCCCCC%22%20stroke-width%3D%220.048%22%3E%3C%2Fg%3E%3Cg%20id%3D%22SVGRepo_iconCarrier%22%3E%3Cdefs%3E%3Cstyle%3E.a%7Bfill%3Anone%3Bstroke%3A%23B0B0BA%3Bstroke-linecap%3Around%3Bstroke-linejoin%3Around%3Bstroke-width%3A1.5px%3Bfill-rule%3Aevenodd%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cpath%20class%3D%22a%22%20d%3D%22M12%2C2A10%2C10%2C0%2C1%2C0%2C22%2C12H12Z%22%3E%3C%2Fpath%3E%3Cpath%20class%3D%22a%22%20d%3D%22M15%2C9h6.54077A10.02174%2C10.02174%2C0%2C0%2C0%2C15%2C2.45923Z%22%3E%3C%2Fpath%3E%3C%2Fg%3E%3C%2Fsvg%3E"); } /*----- preview-modal 시작 -----*/ .preview-modal { display: none; position: fixed; z-index: 10000; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden; background-color: rgba(0, 0, 0, 0.9); backdrop-filter: blur(5px); } .preview-modal-content { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); padding: 0; width: 80%; max-width: 800px; max-height: 800px; border-radius: 10px; border: 1px solid #cccccc52; overflow: hidden; box-shadow: 0 4px 30px rgba(0, 0, 0, 0.7); pointer-events: auto; } .preview-modal .preview-close { position: absolute; top: 10px; right: 15px; color: #fff; font-size: 30px; font-weight: bold; cursor: pointer; transition: color 0.3s ease; z-index: 10; } .preview-modal .preview-close:hover, .preview-modal .preview-close:focus { color: #e50914; } .preview-modal .thumbnail-container { position: relative; width: 100%; height: 450px; background-color: black; display: flex; justify-content: center; align-items: center; } .preview-modal .thumbnail-container img { max-width: 100%; max-height: 100%; object-fit: cover; } .preview-modal .preview-modal-content video { width: clamp(100%, 50vw, 800px); height: 449px; display: none; } .preview-modal .info { color: white; text-align: left; padding: 28px; background-color: rgba(0, 0, 0, 0.65); } .preview-modal .streamer-name { font-size: 50px; font-weight: bold; letter-spacing: -2px; } .preview-modal .video-title { font-size: 20px; margin: 20px 0 30px 0; } .preview-modal .tags { display: flex; justify-content: left; flex-wrap: wrap; flex-direction: row; margin-left: -3px; } .preview-modal .tags a { margin: 5px; color: white; text-decoration: none; border: 1px solid #fff; padding: 5px 10px; border-radius: 5px; transition: background-color 0.3s; } .preview-modal .tags a:hover { background-color: rgba(255, 255, 255, 0.2); } .preview-modal .start-button { background-color: #2d6bffba; color: white; padding: 12px 20px; border: none; border-radius: 5px; font-size: 22px; cursor: pointer; display: inline-block; /* inline-block으로 변경 */ width: auto; /* 너비는 자동으로 */ text-align: center; text-decoration: none; transition: background-color 0.3s; } .preview-modal .start-button:hover { background-color: #2d6bff8f; } /*----- preview-modal 끝 -----*/ #category-group-wrapper, #favorite-group-wrapper { position: relative; margin-bottom: 5px; } #sidebar.min #category-group-wrapper, #sidebar.min #favorite-group-wrapper { display: none !important; } .fav-group-scroll-btn { position: absolute; top: -1px; width: 32px; height: 100%; border: none; font-size: 24px; font-weight: bold; z-index: 10; display: flex; align-items: center; justify-content: center; visibility: hidden; opacity: 0; color: transparent; cursor: default; transition: opacity 0.2s, visibility 0.2s, color 0.2s; } .fav-group-scroll-btn.visible { visibility: visible; opacity: 1; } #category-group-wrapper:hover .fav-group-scroll-btn.visible, #favorite-group-wrapper:hover .fav-group-scroll-btn.visible { cursor: pointer; } /* [수정] ID 선택자를 클래스 선택자로 변경 */ .scroll-btn-left { left: 0; } .scroll-btn-right { right: 0; } #favorite-group-tabs, #category-group-tabs { display: flex; align-items: center; overflow-x: auto; overflow-y: hidden; box-sizing: border-box; scrollbar-width: none; -ms-overflow-style: none; margin-left: 5px; } #favorite-group-tabs::-webkit-scrollbar, #category-group-tabs::-webkit-scrollbar { display: none; } .fav-group-tab { flex-shrink: 0; padding: 4px 10px; margin: 0 3px; cursor: pointer; border-radius: 15px; font-size: 13px; border: 1px solid transparent; transition: background-color 0.2s, color 0.2s; } .fav-group-tab.active { font-weight: bold; } /* --- 다크 모드 스타일 --- */ html[dark="true"] #favorite-group-wrapper:hover .fav-group-scroll-btn.visible, html[dark="true"] #category-group-wrapper:hover .fav-group-scroll-btn.visible { /* [추가] 카테고리 래퍼용 hover 선택자 */ color: #DEDEE3; } /* [수정] ID 선택자를 클래스 선택자로 변경 */ html[dark="true"] .scroll-btn-left { background: linear-gradient(to right, #1F1F23, rgba(31, 31, 35, 0)); } html[dark="true"] .scroll-btn-right { background: linear-gradient(to left, #1F1F23, rgba(31, 31, 35, 0)); } html[dark="true"] .fav-group-tab { background-color: #2c2c31; color: #DEDEE3; } html[dark="true"] .fav-group-tab:hover { background-color: #3e3e44; } html[dark="true"] .fav-group-tab.active { background-color: #424242; } /* --- 화이트 모드 스타일 --- */ html:not([dark="true"]) #favorite-group-wrapper:hover .fav-group-scroll-btn.visible, html:not([dark="true"]) #category-group-wrapper:hover .fav-group-scroll-btn.visible { /* [추가] 카테고리 래퍼용 hover 선택자 */ color: #53535F; } /* [수정] ID 선택자를 클래스 선택자로 변경 */ html:not([dark="true"]) .scroll-btn-left { background: linear-gradient(to right, #EFEFF1, rgba(239, 239, 241, 0)); } html:not([dark="true"]) .scroll-btn-right { background: linear-gradient(to left, #EFEFF1, rgba(239, 239, 241, 0)); } html:not([dark="true"]) .fav-group-tab { background-color: #E6E6EA; color: #53535F; } html:not([dark="true"]) .fav-group-tab:hover { background-color: #DCDDE1; } html:not([dark="true"]) .fav-group-tab.active { background-color: #d2d2d2; } /* ===== 숨기기 관리 모달 ===== */ #hideManagerModal { display: none; position: fixed; z-index: 10001; left: 0; top: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); } #hideManagerModal .hm-panel { background: #1f1f23; color: #efeff1; width: 620px; max-height: 85vh; border-radius: 10px; box-shadow: 0 8px 30px rgba(0,0,0,0.6); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; overflow: hidden; } #hideManagerModal .hm-header { padding: 16px 20px; background: #2a2a2e; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #444; flex-shrink: 0; } #hideManagerModal .hm-header h2 { margin: 0; font-size: 18px; } #hideManagerModal .hm-close { background: none; border: none; color: #aaa; font-size: 24px; cursor: pointer; line-height: 1; } #hideManagerModal .hm-close:hover { color: #fff; } #hideManagerModal .hm-body { padding: 16px 20px; overflow-y: auto; flex-grow: 1; } #hideManagerModal .hm-section { margin-bottom: 18px; } #hideManagerModal .hm-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } #hideManagerModal .hm-section-title { font-size: 14px; font-weight: bold; display: flex; align-items: center; gap: 6px; } #hideManagerModal .hm-section-actions { display: flex; gap: 6px; } #hideManagerModal .hm-btn { padding: 4px 10px; font-size: 12px; border: none; border-radius: 4px; cursor: pointer; background: #3a3a40; color: #efeff1; } #hideManagerModal .hm-btn:hover { background: #4a4a52; } #hideManagerModal .hm-btn.danger:hover { background: #7a2020; } #hideManagerModal .hm-list-box { background: #111113; border-radius: 6px; min-height: 48px; padding: 8px; display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-start; } #hideManagerModal .hm-empty { color: #555; font-size: 13px; padding: 6px; width: 100%; text-align: center; } #hideManagerModal .hm-tag { display: inline-flex; align-items: center; gap: 5px; background: #2e2e33; border: 1px solid #444; border-radius: 14px; padding: 3px 10px 3px 10px; font-size: 13px; color: #efeff1; } #hideManagerModal .hm-tag .hm-tag-remove { background: none; border: none; color: #888; cursor: pointer; font-size: 15px; line-height: 1; padding: 0 2px; } #hideManagerModal .hm-tag .hm-tag-remove:hover { color: #ff6b6b; } #hideManagerModal .hm-footer { padding: 5px 20px; border-top: 1px solid #333; font-size: 13px; color: #FFFFFF; flex-shrink: 0; } #hideManagerModal .hm-footers { padding: 8px 20px; font-size: 13px; color: #FFFFFF; flex-shrink: 0; } #hideManagerBtn { position: fixed; bottom: 20px; right: 20px; background: #2e2e33; border: 1px solid #555; color: #efeff1; padding: 8px 14px; border-radius: 20px; cursor: pointer; font-size: 13px; z-index: 9000; box-shadow: 0 3px 10px rgba(0,0,0,0.4); } #hideManagerBtn:hover { background: #3a3a42; } `; //html:not([dark="true"]) (화이트) #6A6A75 //html[dark="true"] (다크) #94949C const mainPageCommonStyles = ` ._moreDot_layer button { text-align: left; } .customSidebar .btn_flexible { display: none; } #sidebar { z-index: 1401; } body.customSidebar:has(#sidebar.max) main { padding-left: 238px !important; } body.customSidebar:has(#sidebar.min) main { padding-left: 52px !important; } body.customSidebar .catch_webplayer_wrap { margin-left: 24px !important; } `; const playerCommonStyles = ` .default_logo.on { z-index: 0 !important; } .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 #sidebar { transition: all 0.25s ease-in-out !important; } /* screen_mode: #webplayer 뷰포트 높이 설정 (스크롤 가능하도록 fixed 제거) */ .customSidebar.screen_mode #webplayer { transition: left 0.25s ease-in-out, width 0.25s ease-in-out !important; position: relative !important; height: 100vh !important; background: #000 !important; } /* screen_mode: #webplayer 내부 헤더/네비 숨김 */ .customSidebar.screen_mode #webplayer #soop-gnb, .customSidebar.screen_mode #webplayer #serviceLnb { display: none !important; } .customSidebar.screen_mode.bottomChat #webplayer #player .view_ctrl, .customSidebar.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; } /* screen_mode: #webplayer_contents 뷰포트 꽉 채움 */ .customSidebar.screen_mode #webplayer #webplayer_contents { top: 0 !important; left: 0 !important; width: 100vw !important; height: 100vh !important; margin: 0 !important; display: flex !important; flex-direction: row !important; } .customSidebar.fullScreen_mode #webplayer #webplayer_contents { top: 0 !important; left: 0 !important; width: 100vw !important; height: 100vh !important; margin: 0 !important; } /* screen_mode: #player_area가 남은 공간 꽉 채우도록 */ .customSidebar.screen_mode:not(.bottomChat) #webplayer #webplayer_contents #player_area { flex: 1 1 auto !important; min-width: 0 !important; height: 100% !important; } /* screen_mode (bottomChat OFF): 영상 내부 요소 전체 채움 */ .customSidebar.screen_mode:not(.bottomChat) #webplayer #webplayer_contents #player_area .htmlplayer_wrap, .customSidebar.screen_mode:not(.bottomChat) #webplayer #webplayer_contents #player_area .htmlplayer_content, .customSidebar.screen_mode:not(.bottomChat) #webplayer #webplayer_contents #player_area .float_box, .customSidebar.screen_mode:not(.bottomChat) #webplayer #webplayer_contents #player_area #player { width: 100% !important; height: 100% !important; max-height: 100% !important; } /* screen_mode (bottomChat OFF): 채팅창 높이 유지 */ .customSidebar.screen_mode:not(.bottomChat) #webplayer #webplayer_contents .wrapping.side { flex-shrink: 0 !important; height: 100% !important; } /* 전체화면 webplayer_contents flex 레이아웃 설정 */ .customSidebar.fullScreen_mode #webplayer #webplayer_contents { display: flex !important; flex-direction: row !important; } /* 전체화면 기본 상태: 마우스오버 hover 빈창 버그 방지 */ .customSidebar.fullScreen_mode #webplayer #webplayer_contents .wrapping.side { width: 0 !important; min-width: 0 !important; overflow: hidden !important; padding: 0 !important; flex-shrink: 0 !important; } /* 전체화면에서 채팅창이 열렸을 때 패널 표시 */ .customSidebar.fullScreen_mode #webplayer.chat_open #webplayer_contents .wrapping.side, .customSidebar.fullScreen_mode #webplayer #webplayer_contents:has(li.btn_chat:not(.off)) .wrapping.side, .customSidebar.fullScreen_mode #webplayer #webplayer_contents:has(li.btn_list:not(.off)) .wrapping.side { width: var(--chatting_W, 380px) !important; min-width: var(--chatting_W, 380px) !important; max-width: var(--chatting_W, 380px) !important; overflow: visible !important; padding: 0 !important; flex-shrink: 0 !important; } /* 전체화면 player_area: 나머지 공간 채움 */ .customSidebar.fullScreen_mode #webplayer #webplayer_contents #player_area { flex: 1 1 auto !important; min-width: 0 !important; width: auto !important; } /* 전체화면에서 패널 열렸을 때 채팅/리스트 전환 버튼 항상 표시 */ .customSidebar.fullScreen_mode #webplayer #webplayer_contents:has(li.btn_chat:not(.off)) #player_info .view_ctrl, .customSidebar.fullScreen_mode #webplayer #webplayer_contents:has(li.btn_list:not(.off)) #player_info .view_ctrl { display: flex !important; opacity: 1 !important; visibility: visible !important; pointer-events: all !important; } /* screen_mode에서 빈 공간이 생기는 원인: broadcast_information hover로 인한 레이아웃 틀어짐 */ .customSidebar.screen_mode #webplayer #webplayer_contents { display: flex !important; flex-direction: row !important; } .customSidebar.screen_mode #sidebar{ top: 0 !important; display: none !important; } .customSidebar.screen_mode #sidebar .button-fold-sidebar, .customSidebar.screen_mode #sidebar .button-unfold-sidebar { display: none !important; } .customSidebar.fullScreen_mode #sidebar, .customSidebar.screen_mode.showSidebar.fullScreen_mode #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 } /* 스크린 모드 사이드바 표시 시: 채팅창 크기 고정, 플레이어 영역만 축소 */ .customSidebar.screen_mode.showSidebar #webplayer_contents .wrapping.side { flex-shrink: 1 !important; /* 0 → 1로 변경: 창 크기 따라 채팅창도 같이 줄어듦 */ min-width: 200px !important; /* 최소 너비 보장 (너무 작아지지 않도록) */ } .customSidebar.screen_mode.showSidebar #webplayer_contents #player_area { flex: 1 1 auto !important; min-width: 0 !important; } .customSidebar.screen_mode.bottomChat #player { min-height: auto !important; } /* 세로로 긴 화면(bottomChat): 영상 위, 채팅창 아래 배치 */ .customSidebar.screen_mode.bottomChat #webplayer #webplayer_contents { top: 0 !important; margin: 0 !important; position: relative; box-sizing: border-box; display: flex; flex-direction: column !important; /* row → column: 채팅창을 아래에 배치 */ justify-content: flex-start !important; height: 100vh !important; overflow: hidden !important; } /* 영상 영역 LIVE: 전체 너비, 16:9 비율 유지 */ .customSidebar.screen_mode.bottomChat:not(:has(.vod)) #webplayer #webplayer_contents #player_area { width: 100% !important; flex: 0 0 auto !important; min-width: 0 !important; aspect-ratio: 16 / 9 !important; max-height: 55vh !important; overflow: hidden !important; } /* 영상 영역 VOD: 전체 너비, 16:9 비율 유지 */ .customSidebar.screen_mode.bottomChat:has(.vod) #webplayer #webplayer_contents #player_area { width: 100% !important; flex: 0 0 auto !important; min-width: 0 !important; aspect-ratio: 16 / 9 !important; max-height: 55vh !important; overflow: hidden !important; } /* 채팅창: 전체 너비, 나머지 공간 채움 */ .customSidebar.screen_mode.bottomChat #webplayer #webplayer_contents .wrapping.side { width: 100% !important; max-width: 100% !important; flex: 1 1 auto !important; height: auto !important; max-height: none !important; min-height: 150px !important; overflow: hidden !important; } /* 사이드바가 열려 있어도 채팅창은 전체 너비 유지 */ .customSidebar.screen_mode.bottomChat.showSidebar:has(#sidebar.min) #webplayer #webplayer_contents .wrapping.side, .customSidebar.screen_mode.bottomChat.showSidebar:has(#sidebar.max) #webplayer #webplayer_contents .wrapping.side { width: 100% !important; max-width: 100% !important; flex: 1 1 auto !important; } .customSidebar.screen_mode.bottomChat #webplayer #webplayer_contents .wrapping.side section.box.chatting_box { height: 100% !important; } .customSidebar.screen_mode.bottomChat #webplayer #webplayer_contents .wrapping.side section.box.chatting_box #chatting_area { height: 100% !important; min-height: 10vh !important; } /* 영상 내부 요소들 전체 영역 채움 */ .customSidebar.screen_mode.bottomChat #webplayer #webplayer_contents #player_area .htmlplayer_wrap, .customSidebar.screen_mode.bottomChat #webplayer #webplayer_contents #player_area .htmlplayer_content, .customSidebar.screen_mode.bottomChat #webplayer #webplayer_contents #player_area .float_box, .customSidebar.screen_mode.bottomChat #webplayer #webplayer_contents #player_area #player { height: 100% !important; max-height: 100% !important; width: 100% !important; } /* bottomChat: 채팅 영역 너비 100% 강제 (changeChatAreaWidth 덮어쓰기 방지) */ .customSidebar.screen_mode.bottomChat #webplayer #webplayer_contents .wrapping.side .chatting_area, .customSidebar.screen_mode.bottomChat #webplayer #webplayer_contents .chatting_area { width: 100% !important; flex: 1 1 auto !important; min-width: 0 !important; max-width: 100% !important; } /* bottomChat: 영상 아래 방송정보/탭/하단영역 숨김 → 흰 빈 공간 제거 */ .customSidebar.screen_mode.bottomChat #webplayer #webplayer_contents #player_area .broadcast_information, .customSidebar.screen_mode.bottomChat #webplayer #webplayer_contents #player_area .section_selectTab, .customSidebar.screen_mode.bottomChat #webplayer #webplayer_contents #player_area .wrapping.player_bottom, .customSidebar.screen_mode.bottomChat #webplayer #webplayer_contents > .broadcast_information, .customSidebar.screen_mode.bottomChat #webplayer #webplayer_contents > .section_selectTab, .customSidebar.screen_mode.bottomChat #webplayer #webplayer_contents > .wrapping.player_bottom { display: none !important; } .customSidebar #player { max-height: 100vh !important; } /* ★ [추가] 좁은 화면(≤ 700px)에서 스크린 모드: 채팅창 숨김 & 레이아웃 깨짐 방지 */ @media (max-width: 700px) { .customSidebar.screen_mode:not(.bottomChat) #webplayer #webplayer_contents { overflow: hidden !important; } .customSidebar.screen_mode:not(.bottomChat) #webplayer #webplayer_contents .wrapping.side { width: 0 !important; min-width: 0 !important; overflow: hidden !important; padding: 0 !important; flex-shrink: 1 !important; } .customSidebar.screen_mode:not(.bottomChat) #webplayer #webplayer_contents .wrapping.player_bottom { display: none !important; } } /* ★ [수정] VOD 스크린 모드: position:fixed 해제 → 마우스 휠로 페이지 스크롤 가능 */ body.vodPage.screen_mode #webplayer { position: relative !important; top: auto !important; left: auto !important; width: 100% !important; height: 100vh !important; z-index: auto !important; overflow: visible !important; } body.vodPage.screen_mode #webplayer #webplayer_contents { overflow: visible !important; } body.vodPage.screen_mode, html:has(body.vodPage.screen_mode) { overflow-y: auto !important; } `; //====================================== // 3. 함수 정의 (Function Definitions) //====================================== // 3.1. API 및 데이터 호출 함수 (API & Data Fetching) const fetchFavoriteGroups = async () => { const response = await fetchBroadList("https://myapi.sooplive.com/api/favorite/group/list", 50); return response?.data || []; }; const getHiddenbjList = async () => { const url = "https://live.sooplive.com/api/hiddenbj/hiddenbjController.php"; const response = await fetchBroadList(url, 25); if (response?.RESULT === 1) { return response.DATA || []; } else { return []; } }; const getStationFeed = async () => { // 채널 피드가 비활성화된 경우 빈 배열을 반환합니다. if (!isChannelFeedEnabled) { return []; } const feedUrl = "https://myapi.sooplive.com/api/feed?index_reg_date=0&user_id=&is_bj_write=1&feed_type=&page=1"; const response = await fetchBroadList(feedUrl, 150); 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.com/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; customLog.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 { customLog.error("Failed to load data:", response.statusText); } }, onerror: function (error) { customLog.error("Error occurred while loading data:", error); } }); } }; const fetchBroadList = async (url, expiry_seconds = 50, timeout = 5000) => { const CACHE_EXPIRY_MS = expiry_seconds * 1000; // 기본값 50초 const cacheKey = `fetchCache_${encodeURIComponent(url)}`; // (신규) 캐시 파싱 및 유효성 검사 헬퍼 함수 const _parseAndValidateCache = (cachedDataString) => { if (!cachedDataString) return null; try { const { timestamp, data } = JSON.parse(cachedDataString); if (Date.now() - timestamp < CACHE_EXPIRY_MS) { return data; } } catch (e) { customLog.warn(url, 'Cache parse error, ignoring.', e); } return null; }; // 1. LocalStorage 확인 (개선된 로직 적용) const localData = _parseAndValidateCache(localStorage.getItem(cacheKey)); if (localData) return localData; // 2. GM 저장소 확인 (개선된 로직 적용) const gmDataString = await GM_getValue(cacheKey, null); const gmData = _parseAndValidateCache(gmDataString); if (gmData) { // GM 저장소에 유효한 캐시가 있다면, 더 빠른 접근을 위해 LocalStorage에도 저장 localStorage.setItem(cacheKey, gmDataString); return gmData; } // 3. 요청 수행 return new Promise((resolve) => { let timeoutId; if (timeout) { timeoutId = setTimeout(() => { customLog.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)) { customLog.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) { customLog.error(url, "Unauthorized: 401 error - possibly invalid credentials"); resolve([]); } else { customLog.error(url, `Error: ${response.status}`); resolve([]); } } catch (error) { customLog.error(url, "Parsing error: ", error); resolve([]); } }, onerror: (error) => { if (timeoutId) clearTimeout(timeoutId); customLog.error(url, "Request error: " + error.message); resolve([]); } }); }); }; const getBroadM3u8Domain = async (broadNumber) => { const baseUrl = 'https://livestream-manager.sooplive.com/broad_stream_assign.html'; const params = new URLSearchParams({ return_type: 'lg_cdn_preview', use_cors: 'true', cors_origin_url: window.location.host, broad_key: `${broadNumber}-common-master-hls`, player_mode: 'preview', time: '0' }); const requestUrl = `${baseUrl}?${params.toString()}`; try { const res = await fetch(requestUrl, { method: 'GET', credentials: 'include', cache: 'no-store' }); if (!res.ok) { throw new Error(`HTTP error! status: ${res.status}`); } const data = await res.json(); if (data.result == '1' && data.view_url) { customLog.log("M3U8 URL:", data.view_url); return data.view_url; } else { customLog.log("Failed to retrieve M3U8 URL:", data); return null; } } catch (error) { customLog.error("Error fetching M3U8 URL:", error); return null; } }; 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.com/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.com/afreeca/player_live_api.php', options); return await res.json(); }; try { // 1차: 비밀번호 없이 AID 요청 const result1 = await requestAid(''); if (result1?.CHANNEL?.AID) { customLog.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) { customLog.log(result1.CHANNEL.AID); return retryResult.CHANNEL.AID; } else { alert('비밀번호가 틀렸거나 종료된 방송입니다.'); } } return null; } catch (error) { customLog.log('오류 발생:', error); return null; } }; const getM3u8url = async (id, broadNumber, quality = 'hd') => { try { // Use Promise.all to initiate both requests concurrently const [aid, baseUrl] = await Promise.all([ getBroadAid2(id, broadNumber, quality), getBroadM3u8Domain(broadNumber) ]); if (!aid) { customLog.log("Failed to get AID. Cannot construct complete URL."); return null; } if (!baseUrl) { customLog.log("Failed to get base M3u8 URL. Cannot construct complete URL."); return null; } // Construct the complete URL by appending the AID const completeUrl = `${baseUrl}?aid=${aid}`; customLog.log("Complete Broad URL:", completeUrl); return completeUrl; } catch (error) { customLog.error("Error in getM3u8url:", error); return null; } }; const getLatestFrameData = async (id, broadNumber) => { const videoElement = document.createElement('video'); videoElement.playbackRate = 16; // 빠른 재생 속도 설정 const m3u8url = await getM3u8url(id, broadNumber, 'sd'); 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 { customLog.error('HLS.js를 지원하지 않는 브라우저입니다.'); return null; } }; // 3.2. 핵심 유틸리티 함수 (Core Utility Functions) /** * URL 변경 시마다 재초기화 * @param {Function} initFn * @param {number} delayMs */ const observeWithReinit = (initFn, delayMs = 2000) => { initFn(); observeUrlChanges(() => { setTimeout(initFn, delayMs); }); }; /** * 즐겨찾기 목록에서 우선순위에 따라 정렬된 '라이브 방송 목록 전체'를 반환하는 함수 * @param {object} favoriteData - fetch로 받아온 즐겨찾기 데이터 * @returns {object[]} - 우선순위에 따라 정렬된 방송 정보 객체 배열 */ function getPrioritizedLiveBroadcasts(favoriteData) { if (!favoriteData?.data?.length) { return []; // 비어있는 배열 반환 } const liveCategories = { pinnedOnline: [], notifiedOnline: [], normalOnline: [], }; favoriteData.data.forEach(item => { if (item.is_live !== true) return; const isPin = item.is_pin === true; const isMobilePush = item.is_mobile_push === 'Y'; const broadInfo = item.broad_info?.[0]; if (!broadInfo) return; if (isPin) liveCategories.pinnedOnline.push(broadInfo); else if (isMobilePush) liveCategories.notifiedOnline.push(broadInfo); else liveCategories.normalOnline.push(broadInfo); }); const compareWatchers = (a, b) => (b.total_view_cnt || 0) - (a.total_view_cnt || 0); Object.values(liveCategories).forEach(category => { category.sort(compareWatchers); }); // 우선순위에 따라 카테고리를 합쳐서 최종 목록을 만듭니다. const prioritizedList = [ ...liveCategories.pinnedOnline, ...liveCategories.notifiedOnline, ...liveCategories.normalOnline, ]; return prioritizedList; } function getFollowList(callback) { GM_xmlhttpRequest({ method: 'GET', url: 'https://myapi.sooplive.com/api/favorite', headers: { 'Content-Type': 'application/json', }, onload: function (response) { try { const res = JSON.parse(response.responseText); if (res.code === -10000) { callback([]); } else { // user_id만 추출 const userIdList = res.data.map(item => item.user_id); // 저장 GM_setValue('allFollowUserIds', userIdList); // 콜백 전달 callback(userIdList); } } catch (e) { customLog.error('Parsing error:', e); callback([]); } }, onerror: function (error) { customLog.error('Request error:', error); callback([]); } }); } 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 () { customLog.log('hls.js가 성공적으로 로드되었습니다.'); }; hlsScript.onerror = function () { customLog.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(); customLog.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) => { // 1. 요소가 이미 존재하는지 즉시 확인 const element = document.querySelector(selector); if (element) { resolve(element); return; } let observer = null; let isSettled = false; const root = document.body || document.documentElement; const finish = (value) => { if (isSettled) return; isSettled = true; if (observer) observer.disconnect(); clearTimeout(timeoutId); resolve(value); }; // 2. 타임아웃 설정: 지정된 시간이 지나면 reject 실행 const timeoutId = setTimeout(() => { customLog.warn(`[waitForElementAsync] Timeout: '${selector}' 요소를 ${timeout}ms 내에 찾지 못했습니다.`); finish(null); }, timeout); if (!root) { finish(null); return; } // 3. MutationObserver 설정: DOM 변경 감지 observer = new MutationObserver(() => { // 변경이 감지되면 요소를 다시 찾아봄 const targetElement = document.querySelector(selector); if (targetElement) { finish(targetElement); } }); // 4. 관찰 시작 observer.observe(root, { childList: true, subtree: true }); }); }; //2026.02.27 방송국에서 SOOP메인 홈페이지 전환 시 사이드바 강제 펼침 // ================================ // 메인 페이지 진입 시 사이드바 강제 펼침 // ================================ const forceSidebarExpandOnMain = () => { const sidebar = document.getElementById('sidebar'); if (!sidebar) return; const isMainPage = location.pathname === '/' || location.pathname.startsWith('/live') || location.pathname.startsWith('/main'); if (!isMainPage) return; // 항상 펼침 상태로 강제 설정 (MY·탐색·캐치와 동일한 동작) sidebar.classList.remove('min'); sidebar.classList.add('max'); isSidebarMinimized = false; }; 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 () => { let isTimedOut = false; const timeout = new Promise((resolve) => setTimeout(() => { isTimedOut = true; resolve([]); }, 3000) // 3초 후 빈 배열 반환 ); const checkArray = (async () => { while (!isTimedOut && allFollowUserIds.length === 0) { await new Promise((resolve) => setTimeout(resolve, 100)); // 100ms 대기 } return isTimedOut ? [] : allFollowUserIds; })(); return Promise.race([timeout, checkArray]); }; 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 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 searchCategory(savedCategory.CHANNEL.BROAD_CATEGORY); }; 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) => { // portrait 조건 제거 — PC(landscape)에서도 항상 버튼 표시 const btn = document.querySelector('.expand-toggle-li'); if (btn) btn.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) { customLog.error(`[observeElementChanges] 오류: 선택자 '${targetSelector}'에 해당하는 요소를 찾을 수 없습니다.`); return null; } // 2. 콜백 함수 유효성 검사 if (typeof callback !== 'function') { customLog.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(); customLog.log(`[observeElementChanges] '${targetSelector}' 요소에 대한 관찰이 1회 실행 후 중단되었습니다.`); } }); // 6. 관찰 시작 observer.observe(targetElement, config); customLog.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) { customLog.error(`[observeClassChanges] 오류: 선택자 '${targetSelector}'에 해당하는 요소를 찾을 수 없습니다.`); return null; } // 2. 콜백 함수 유효성 검사 if (typeof callback !== 'function') { customLog.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); customLog.log(`[observeClassChanges] '${targetSelector}' 요소의 클래스 변경 감시를 시작합니다.`); // 6. 생성된 observer 인스턴스 반환 (필요시 중단 등을 위해) return observer; }; const loadScript = (url) => { return new Promise((resolve, reject) => { // 동일한 스크립트가 이미 로드되었는지 확인 if (document.querySelector(`script[src="${url}"]`)) { customLog.log(`스크립트가 이미 로드됨: ${url}`); resolve(); return; } const script = document.createElement('script'); script.src = url; script.onload = () => { customLog.log(`스크립트 로드 성공: ${url}`); resolve(); }; script.onerror = () => { customLog.error(`스크립트 로드 실패: ${url}`); reject(new Error(`${url} 로드 실패`)); }; document.head.appendChild(script); }); } // 3.3. 차단 기능 관련 함수 (Blocking Features) function savePinnedCategories() { GM_setValue('pinnedCategories', pinnedCategories); } function pinCategory(categoryName, categoryId) { if (!pinnedCategories.some(cat => cat.categoryId === categoryId)) { pinnedCategories.push({ categoryName, categoryId }); savePinnedCategories(); alert(`카테고리 '${categoryName}'을(를) 탭에 추가했습니다.\n해제는 Tampermonkey 메뉴에서 가능합니다.`); registerCategoryUnpinMenu({ categoryName, categoryId }); } else { alert(`'${categoryName}' 카테고리는 이미 추가되어 있습니다.`); } } function unpinCategory(categoryId) { const categoryToRemove = pinnedCategories.find(cat => cat.categoryId === categoryId); if (categoryToRemove) { pinnedCategories = pinnedCategories.filter(cat => cat.categoryId !== categoryId); savePinnedCategories(); alert(`'${categoryToRemove.categoryName}' 카테고리 고정을 해제했습니다.`); unregisterCategoryUnpinMenu(categoryToRemove.categoryId); if (selectedPinnedCategoryIdx === categoryId) { selectedPinnedCategoryIdx = 'all'; GM_setValue('selectedPinnedCategoryIdx', 'all'); } } } function registerCategoryUnpinMenu(category) { if (!category || !category.categoryName) return; let menuId = GM_registerMenuCommand(`📌 탭 해제 - ${category.categoryName}`, () => { unpinCategory(category.categoryId); }); categoryMenuIds[category.categoryId] = menuId; } function unregisterCategoryUnpinMenu(categoryId) { let menuId = categoryMenuIds[categoryId]; if (menuId) { GM_unregisterMenuCommand(menuId); delete categoryMenuIds[categoryId]; } } function saveBlockedUsers() { GM_setValue('blockedUsers', blockedUsers); }; function blockUser(userName, userId) { if (!isUserBlocked(userId)) { blockedUsers.push({ userName, userId }); saveBlockedUsers(); alert(`사용자 ${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}의 차단이 해제되었습니다.`); } }; 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})를 차단했습니다.`); } 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}의 차단이 해제되었습니다.`); } }; 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}"를 차단했습니다.`); } else { alert(`단어 "${word}"는 이미 차단되어 있습니다.`); } }; function unblockWord(word) { // 차단된 단어 목록에서 해당 단어 찾기 let unblockedWord = blockedWords.find(blockedWord => blockedWord === word); // 단어를 찾았을 때만 차단 해제 및 메뉴 삭제 수행 if (unblockedWord) { // 차단된 단어 목록에서 해당 단어 제거 blockedWords = blockedWords.filter(blockedWord => blockedWord !== word); // 변경된 목록을 저장 saveBlockedWords(); alert(`단어 "${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() { // UI 숨기기 관리 창으로 이전됨 }; // 카테고리 차단 메뉴 추가 function registerMenuBlockingCategory() { // UI 숨기기 관리 창으로 이전됨 }; // ================================================================= // 3.4. UI 생성 및 조작 함수 (UI Generation & Manipulation) - 개선안 // ================================================================= // [수정] 그룹 이름 옆의 숫자 카운트를 제거한 최종 버전 const createFavoriteGroupTabs = async (sectionParent) => { if (!isFavoriteGroupEnabled) return; const existingWrapper = document.getElementById('favorite-group-wrapper'); if (existingWrapper) existingWrapper.remove(); const groups = await fetchFavoriteGroups(); if (groups.length === 0) return; const wrapper = document.createElement('div'); wrapper.id = 'favorite-group-wrapper'; const tabContainer = document.createElement('div'); tabContainer.id = 'favorite-group-tabs'; const createTab = (title, idx) => { const tab = document.createElement('div'); tab.className = 'fav-group-tab'; // 옵션이 켜져 있고 '전체' 탭이 아닐 경우, 이름을 한 글자로 축약 if (isShortenFavoriteGroupNameEnabled && idx !== 'all') { tab.textContent = title.substring(0, 1); } else { tab.textContent = title; } // 마우스를 올렸을 때 전체 이름이 보이도록 title 속성 추가 tab.title = title; tab.dataset.idx = idx; if (idx == selectedFavoriteGroupIdx) { tab.classList.add('active'); } tab.addEventListener('click', async (e) => { const newIdx = e.currentTarget.dataset.idx; if (newIdx == selectedFavoriteGroupIdx) return; selectedFavoriteGroupIdx = newIdx; GM_setValue("selectedFavoriteGroupIdx", newIdx); tabContainer.querySelectorAll('.fav-group-tab').forEach(t => t.classList.remove('active')); e.currentTarget.classList.add('active'); const followSectionConfig = allSections.find(s => s.id === 'follow'); if (followSectionConfig) { await createAndPopulateSection(followSectionConfig, true); } }); return tab; }; tabContainer.appendChild(createTab('전체', 'all')); groups.forEach(group => { tabContainer.appendChild(createTab(group.title, group.idx)); }); const scrollLeftBtn = document.createElement('button'); scrollLeftBtn.id = 'scroll-left-btn'; scrollLeftBtn.className = 'fav-group-scroll-btn scroll-btn-left'; // 클래스 추가 scrollLeftBtn.innerHTML = '‹'; const scrollRightBtn = document.createElement('button'); scrollRightBtn.id = 'scroll-right-btn'; scrollRightBtn.className = 'fav-group-scroll-btn scroll-btn-right'; // 클래스 추가 scrollRightBtn.innerHTML = '›'; wrapper.appendChild(scrollLeftBtn); wrapper.appendChild(tabContainer); wrapper.appendChild(scrollRightBtn); const userSection = sectionParent.querySelector('.users-section.follow'); //2026.02.25 즐겨찾기 그룹 LIVE스트리머 0명일 때 확인 if (userSection) { sectionParent.insertBefore(wrapper, userSection); } else { sectionParent.prepend(wrapper); } const updateScrollButtonsVisibility = () => { const isScrollable = tabContainer.scrollWidth > tabContainer.clientWidth; if (!isScrollable) { scrollLeftBtn.classList.remove('visible'); scrollRightBtn.classList.remove('visible'); return; } scrollLeftBtn.classList.toggle('visible', tabContainer.scrollLeft > 1); const isAtEnd = tabContainer.scrollWidth - tabContainer.clientWidth - tabContainer.scrollLeft < 1; scrollRightBtn.classList.toggle('visible', !isAtEnd); }; const scrollAmount = 150; scrollLeftBtn.addEventListener('click', () => { tabContainer.scrollBy({ left: -scrollAmount, behavior: 'smooth' }); }); scrollRightBtn.addEventListener('click', () => { tabContainer.scrollBy({ left: scrollAmount, behavior: 'smooth' }); }); const debouncedUpdate = debounce(updateScrollButtonsVisibility, 50); tabContainer.addEventListener('scroll', debouncedUpdate); new ResizeObserver(updateScrollButtonsVisibility).observe(tabContainer); setTimeout(updateScrollButtonsVisibility, 100); }; // === 신규 '카테고리 탭' 생성 함수 === const createCategoryTabs = async (sectionParent) => { if (!isCategoryGroupEnabled) return; const existingWrapper = document.getElementById('category-group-wrapper'); if (existingWrapper) existingWrapper.remove(); // ↓↓↓ 수정된 부분: pinnedCategories가 없으면 인기 방송에서 자동 추출 let categoriesToShow = pinnedCategories; if (pinnedCategories.length === 0) { // cachedTopSectionBroadData가 없으면 전체 인기 목록을 직접 가져옴 if (!cachedTopSectionBroadData) { const allData = await fetchBroadList( 'https://live.sooplive.com/api/main_broad_list_api.php?selectType=action&orderType=view_cnt&pageNo=1&lang=ko_KR', 100 ); if (allData?.broad) { cachedTopSectionBroadData = allData.broad; } else { return; } } // 인기 방송 데이터에서 카테고리별 시청자 수 집계 const catMap = new Map(); cachedTopSectionBroadData.forEach(channel => { const { broad_cate_no, category_name, total_view_cnt } = channel; if (!broad_cate_no || !category_name) return; if (!catMap.has(broad_cate_no)) { catMap.set(broad_cate_no, { categoryId: broad_cate_no, categoryName: category_name, viewers: 0 }); } catMap.get(broad_cate_no).viewers += (parseInt(total_view_cnt) || 0); }); // 시청자 많은 순으로 정렬 후 상위 10개 사용 categoriesToShow = Array.from(catMap.values()) .sort((a, b) => b.viewers - a.viewers) .slice(0, 10); } // ↑↑↑ 수정 끝 if (categoriesToShow.length === 0) return; const wrapper = document.createElement('div'); wrapper.id = 'category-group-wrapper'; wrapper.style.position = 'relative'; wrapper.style.marginBottom = '5px'; const tabContainer = document.createElement('div'); tabContainer.id = 'category-group-tabs'; tabContainer.style.display = 'flex'; tabContainer.style.alignItems = 'center'; tabContainer.style.overflowX = 'auto'; tabContainer.style.overflowY = 'hidden'; tabContainer.style.boxSizing = 'border-box'; tabContainer.style.scrollbarWidth = 'none'; tabContainer.style.marginLeft = '5px'; tabContainer.style.setProperty('-ms-overflow-style', 'none'); const createTab = (title, idx) => { const tab = document.createElement('div'); tab.className = 'fav-group-tab'; // 옵션이 켜져 있고 '전체' 탭이 아닐 경우, 이름을 한 글자로 축약 if (isShortenCategoryNameEnabled && idx !== 'all') { tab.textContent = title.substring(0, 1); } else { tab.textContent = title; } // 마우스를 올렸을 때 전체 이름이 보이도록 title 속성 추가 tab.title = title; tab.dataset.idx = idx; if (idx == selectedPinnedCategoryIdx) { tab.classList.add('active'); } tab.addEventListener('click', async (e) => { const newIdx = e.currentTarget.dataset.idx; if (newIdx == selectedPinnedCategoryIdx) return; selectedPinnedCategoryIdx = newIdx; GM_setValue("selectedPinnedCategoryIdx", newIdx); tabContainer.querySelectorAll('.fav-group-tab').forEach(t => t.classList.remove('active')); e.currentTarget.classList.add('active'); const topSectionConfig = allSections.find(s => s.id === 'top'); if (topSectionConfig) { await createAndPopulateSection(topSectionConfig, true); } }); return tab; }; tabContainer.appendChild(createTab('전체', 'all')); categoriesToShow.forEach(cat => { // ← pinnedCategories → categoriesToShow 로 변경 tabContainer.appendChild(createTab(cat.categoryName, cat.categoryId)); }); const scrollLeftBtn = document.createElement('button'); scrollLeftBtn.id = 'scroll-left-btn-cat'; scrollLeftBtn.className = 'fav-group-scroll-btn scroll-btn-left'; // 클래스 추가 scrollLeftBtn.innerHTML = '‹'; const scrollRightBtn = document.createElement('button'); scrollRightBtn.id = 'scroll-right-btn-cat'; scrollRightBtn.className = 'fav-group-scroll-btn scroll-btn-right'; // 클래스 추가 scrollRightBtn.innerHTML = '›'; wrapper.appendChild(scrollLeftBtn); wrapper.appendChild(tabContainer); wrapper.appendChild(scrollRightBtn); const userSection = sectionParent.querySelector('.users-section.top'); if (userSection) { sectionParent.insertBefore(wrapper, userSection); } const updateScrollButtonsVisibility = () => { const isScrollable = tabContainer.scrollWidth > tabContainer.clientWidth; if (!isScrollable) { scrollLeftBtn.classList.remove('visible'); scrollRightBtn.classList.remove('visible'); return; } scrollLeftBtn.classList.toggle('visible', tabContainer.scrollLeft > 1); const isAtEnd = tabContainer.scrollWidth - tabContainer.clientWidth - tabContainer.scrollLeft < 1; scrollRightBtn.classList.toggle('visible', !isAtEnd); }; const scrollAmount = 150; scrollLeftBtn.addEventListener('click', () => { tabContainer.scrollBy({ left: -scrollAmount, behavior: 'smooth' }); }); scrollRightBtn.addEventListener('click', () => { tabContainer.scrollBy({ left: scrollAmount, behavior: 'smooth' }); }); const debouncedUpdate = debounce(updateScrollButtonsVisibility, 50); tabContainer.addEventListener('scroll', debouncedUpdate); new ResizeObserver(updateScrollButtonsVisibility).observe(tabContainer); setTimeout(updateScrollButtonsVisibility, 100); }; /** * 범용 사이드바 섹션 생성 및 채우기 함수 (DOM 재활용 및 정렬 기능 내장) * @param {object} config - 섹션 설정 객체 * @param {string} config.id - 섹션 ID (예: 'follow', 'top') * @param {string} config.title - 섹션 제목 (예: '즐겨찾기 채널') * @param {string} config.href - 섹션 제목 링크 * @param {string} config.iconHtml - 최소화 시 보일 아이콘 HTML * @param {string} config.containerSelector - 채널 목록이 들어갈 컨테이너의 CSS 선택자 * @param {function(): Promise} config.fetchData - 채널 데이터 배열을 반환하는 비동기 함수 * @param {function(object, ...any): HTMLElement} config.createElement - 단일 채널 요소를 생성하는 함수 * @param {string} config.showMoreButtonId - '더 보기' 버튼에 사용할 ID * @param {number} config.displayCount - 초기에 보여줄 채널 수 * @param {boolean} [update=false] - 전체 업데이트 여부 */ const createAndPopulateSection = async (config, update = false) => { const { id, containerSelector, fetchData, createElement, displayCount, showMoreButtonId } = config; const sectionContainer = document.querySelector(containerSelector); if (!sectionContainer) { // 최초 로딩 시 컨테이너가 없을 수 있으므로 이 부분은 유지 const sidebar = document.getElementById('sidebar'); if (!sidebar || update) return; // 업데이트 시에는 컨테이너가 반드시 있어야 함 const { title, href, iconHtml } = config; const sectionHtml = `
`; sidebar.insertAdjacentHTML('beforeend', sectionHtml); } const container = document.querySelector(containerSelector); if (!container) return; const topSection = document.querySelector(`.top-section.${id}`); let sectionParentNode = topSection?.parentNode; if (!sectionParentNode) { const sidebar = document.getElementById('sidebar'); if (!sidebar || update) return; const { title, href, iconHtml } = config; const sectionHtml = ` `; sidebar.insertAdjacentHTML('beforeend', sectionHtml); sectionParentNode = sidebar.querySelector(`.section-wrapper.${id}`); } // --- 최초 로딩 로직 --- if (!update) { try { const channels = await fetchData(); //2026.02.25 즐겨찾기 그룹 LIVE스트리머 0명일 때 메세지 if (id === 'follow') { await createFavoriteGroupTabs(sectionParentNode); } if (!channels || channels.length === 0) { container.innerHTML = `
이 그룹에는 현재 방송 중인 스트리머가 없습니다.
`; if (topSection) topSection.style.display = ''; return; } if (topSection) topSection.style.display = ''; let userElements = channels.map(cd => createElement(cd.channel, cd.type, ...cd.args)).filter(Boolean); // 정렬 if (id === 'follow') userElements = sortFollowSection(userElements); else if (id === 'myplus' && !myplusOrder) userElements.sort(compareWatchers); else if (id === 'top' || id === 'myplusvod') userElements.sort(compareWatchers); const fragment = document.createDocumentFragment(); userElements.forEach(el => fragment.appendChild(el)); container.innerHTML = ''; container.appendChild(fragment); const allUsers = Array.from(container.children); const limit = displayCount; allUsers.slice(limit).forEach(el => el.classList.add('show-more')); if (allUsers.length > limit) { const hiddenCount = allUsers.length - limit; createShowMoreButton(container, showMoreButtonId, hiddenCount, limit); } // 즐겨찾기 그룹 탭 생성 if (id === 'follow') { await createFavoriteGroupTabs(sectionParentNode); } // 인기 채널 카테고리 탭 생성 if (id === 'top') { await createCategoryTabs(sectionParentNode); } makeThumbnailTooltip(); } catch (error) { customLog.error(`[${id}] 섹션 로딩 실패:`, error); container.innerHTML = `
오류: ${error.message}
`; } } // --- 업데이트 로직 (컨테이너 교체 방식) --- else { const openListCount = container.querySelectorAll('.user:not(.show-more)').length; try { const newChannelsData = await fetchData(); // 1. 새로운 컨테이너를 메모리상에 생성 const newContainer = container.cloneNode(false); // 자식 노드 없이 껍데기만 복제 //2026.02.25 즐겨찾기 그룹 LIVE스트리머 0명일 때 메세지 if (!newChannelsData || newChannelsData.length === 0) { newContainer.innerHTML = `
이 그룹에는 현재 방송 중인 스트리머가 없습니다.
`; if (topSection) topSection.style.display = ''; } else { if (topSection) topSection.style.display = ''; let userElements = newChannelsData.map(cd => createElement(cd.channel, cd.type, ...cd.args)).filter(Boolean); // 2. 정렬 if (id === 'follow') userElements = sortFollowSection(userElements); else if (id === 'myplus' && !myplusOrder) userElements.sort(compareWatchers); else if (id === 'top' || id === 'myplusvod') userElements.sort(compareWatchers); const fragment = document.createDocumentFragment(); userElements.forEach(el => fragment.appendChild(el)); newContainer.appendChild(fragment); // 3. 새 컨테이너에 '더 보기/접기' 상태 적용 const allUsers = Array.from(newContainer.children); const limit = openListCount > displayCount ? openListCount : displayCount; allUsers.slice(limit).forEach(el => el.classList.add('show-more')); if (allUsers.length > displayCount) { const hiddenCount = allUsers.filter(el => el.classList.contains('show-more')).length; createShowMoreButton(newContainer, showMoreButtonId, hiddenCount, displayCount); } } // 4. 모든 준비가 끝난 새 컨테이너로 기존 컨테이너를 교체 container.parentNode.replaceChild(newContainer, container); // 툴팁은 교체된 새 컨테이너의 요소들에 대해 다시 실행 makeThumbnailTooltip(); } catch (error) { customLog.error(`[${id}] 섹션 업데이트 실패:`, error); } } }; /** * 즐겨찾기 섹션의 복합적인 정렬 로직을 처리하는 함수 * @param {Array} elements - 정렬할 유저 요소 배열 * @returns {Array} 복합 정렬된 유저 요소 배열 */ const sortFollowSection = (elements) => { const categories = { pinnedOnline: [], pinnedOffline: [], notifiedOnline: [], blocked: [], normalOnline: [], other: [], }; elements.forEach(user => { 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 = isBlockedCategorySortingEnabled && blockedCategories.some(b => b.categoryId === broad_cate_no); if (isPin && hasBroadThumbnail) categories.pinnedOnline.push(user); else if (isPin) categories.pinnedOffline.push(user); else if (isMobilePush && !isOffline) categories.notifiedOnline.push(user); else if (isBlocked) categories.blocked.push(user); else if (!isMobilePush && !isOffline) categories.normalOnline.push(user); else categories.other.push(user); }); // 각 카테고리 내부 정렬 const sortOrder = isRandomSortEnabled ? stableRandomOrder : compareWatchers; Object.keys(categories).forEach(key => { categories[key].sort(key === 'other' ? compareWatchers : sortOrder); }); return [ ...categories.pinnedOnline, ...categories.pinnedOffline, ...categories.notifiedOnline, ...categories.normalOnline, ...categories.blocked, ...categories.other ]; }; /** * 즐겨찾기 섹션의 데이터를 가져옵니다. * @returns {Promise} 채널 정보 배열 */ const fetchDataForFollowSection = async () => { // [수정] 선택된 그룹에 따라 API URL을 동적으로 변경 const soopApiUrl = selectedFavoriteGroupIdx === 'all' ? 'https://myapi.sooplive.com/api/favorite' : `https://myapi.sooplive.com/api/favorite/${selectedFavoriteGroupIdx}`; const [soopData, feedData] = await Promise.all([ fetchBroadList(soopApiUrl, 50), isChannelFeedEnabled ? getStationFeed() : Promise.resolve([]) ]); if (selectedFavoriteGroupIdx === 'all' && soopData?.data) { extractFollowUserIds(soopData); } const feedUserIdSet = new Set(feedData.map(item => item.station_user_id)); let combinedList = []; // 숲(SOOP) 채널 처리 if (soopData?.data) { soopData.data.forEach(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 => combinedList.push({ channel, args: [is_mobile_push, is_pin], type: 'soop_live' })); } else if (feedUserIdSet.has(user_id)) { feedData.filter(feed => feed.station_user_id === user_id && !checkIfTimeover(feed.reg_timestamp)) .forEach(feedItem => combinedList.push({ channel: item, args: [feedItem], type: 'soop_feed' })); } else if (is_pin && !isPinnedOnlineOnlyEnabled) { combinedList.push({ channel: item, args: [null], type: 'soop_offline' }); } }); } return combinedList; }; /** * 인기 채널 섹션의 데이터를 가져옵니다. * @returns {Promise} 채널 정보 배열 */ const fetchDataForTopSection = async () => { const soopApiUrl = selectedPinnedCategoryIdx === 'all' ? 'https://live.sooplive.com/api/main_broad_list_api.php?selectType=action&orderType=view_cnt&pageNo=1&lang=ko_KR' : `https://live.sooplive.com/api/main_broad_list_api.php?selectType=cate&selectValue=${selectedPinnedCategoryIdx}&orderType=view_cnt&pageNo=1&lang=ko_KR`; const [hiddenBjList, soopData] = await Promise.all([ getHiddenbjList(), fetchBroadList(soopApiUrl, 100) ]); HIDDEN_BJ_LIST.length = 0; HIDDEN_BJ_LIST.push(...hiddenBjList); //'전체' 탭 조회 시 원본 데이터를 캐시해 카테고리 탭 자동 생성에 활용 if (selectedPinnedCategoryIdx === 'all' && soopData?.broad) { cachedTopSectionBroadData = soopData.broad; } let combinedList = []; if (soopData?.broad) { soopData.broad.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) || containsBlockedWord(channel.hash_tags); if (!isBlocked) { combinedList.push({ channel, args: [0, 0], type: 'soop_live' }); } }); } return combinedList; }; /** * 추천 채널 및 VOD 섹션의 데이터를 가져옵니다. * @returns {Promise} live와 vod를 포함하는 객체 */ const fetchDataForMyplusSection = async () => { const response = await fetchBroadList('https://live.sooplive.com/api/myplus/preferbjLiveVodController.php?nInitCnt=6&szRelationType=C', 100); if (!response || typeof response !== 'object' || response.RESULT === -1 || !response.DATA) { return { live: [], vod: [] }; } await (isDuplicateRemovalEnabled && displayFollow ? waitForNonEmptyArray() : Promise.resolve()); const { live_list = [], vod_list = [] } = response.DATA; const followUserIdSet = (isDuplicateRemovalEnabled && displayFollow) ? new Set(allFollowUserIds) : null; const filterBlocked = (channel, isVod = false) => { const title = isVod ? channel.title : channel.broad_title; const category = isVod ? channel.category : channel.broad_cate_no; const isWordBlockedByTitle = title && blockedWords.some(word => title.toLowerCase().includes(word.toLowerCase())); if (isUserBlocked(channel.user_id) || isCategoryBlocked(category) || isWordBlockedByTitle || containsBlockedWord(channel.hash_tags)) { return false; } if (followUserIdSet && !isVod && followUserIdSet.has(channel.user_id)) { return false; } return true; }; return { live: live_list.filter(channel => filterBlocked(channel, false)).map(channel => ({ channel, args: [0, 0], type: 'soop_live' })), vod: vod_list.filter(channel => filterBlocked(channel, true)).map(channel => ({ channel, args: [], type: 'soop_vod' })) }; }; /** * 범용 createElement 함수 * 채널 데이터의 타입에 따라 적절한 생성 함수를 호출합니다. * @param {object} channel - 채널 데이터 * @param {string} type - 채널 데이터의 소스 타입 * @param {...any} args - 각 생성 함수에 필요한 추가 인자들 * @returns {HTMLElement | null} 생성된 DOM 요소 */ const createUniversalElement = (channel, type, ...args) => { switch (type) { case 'soop_live': return createUserElement(channel, ...args); case 'soop_feed': case 'soop_offline': return createUserElementOffline(channel, ...args); case 'soop_vod': return createUserElementVod(channel, ...args); default: customLog.warn('알 수 없는 채널 타입:', type, channel); return null; } } /** * 개선된 사이드바 초기화 함수 (기존 generateBroadcastElements 대체) * @param {boolean} [update=false] - 업데이트 여부 */ const initializeSidebar = async (update = false) => { customLog.log(`방송 목록 갱신 시작: ${new Date().toLocaleString()}`); const myplusIcon = IS_DARK_MODE ? `` : ``; const followIcon = IS_DARK_MODE ? `` : ``; const topIcon = IS_DARK_MODE ? `` : ``; // myplus 데이터를 먼저 가져와서 live와 vod로 분리 const myplusData = (displayMyplus > 0 || displayMyplusvod > 0) ? await fetchDataForMyplusSection() : { live: [], vod: [] }; // 각 섹션에 대한 설정을 객체 배열로 정의 allSections = [ { id: 'follow', title: '즐겨찾기 채널', href: 'https://www.sooplive.com/my/favorite', iconHtml: followIcon, containerSelector: '.users-section.follow', fetchData: fetchDataForFollowSection, createElement: createUniversalElement, showMoreButtonId: 'toggleButton2', displayCount: displayFollow, enabled: displayFollow > 0 }, { id: 'myplus', title: '추천 채널', href: '#', iconHtml: myplusIcon, containerSelector: '.users-section.myplus', fetchData: async () => myplusData.live, createElement: createUniversalElement, showMoreButtonId: 'toggleButton', displayCount: displayMyplus, enabled: displayMyplus > 0 }, { id: 'top', title: '인기 채널', href: 'https://www.sooplive.com/live/all', iconHtml: topIcon, containerSelector: '.users-section.top', fetchData: fetchDataForTopSection, createElement: createUniversalElement, showMoreButtonId: 'toggleButton3', displayCount: displayTop, enabled: displayTop > 0 }, { id: 'myplusvod', title: '추천 VOD', href: '#', iconHtml: myplusIcon, containerSelector: '.users-section.myplusvod', fetchData: async () => myplusData.vod, createElement: createUniversalElement, showMoreButtonId: 'toggleButton4', displayCount: displayMyplusvod, enabled: displayMyplusvod > 0 } ]; // 저장된 순서(sidebarSectionOrder)에 따라 섹션 배열을 재정렬 const sectionMap = new Map(allSections.map(s => [s.id, s])); const sections = sidebarSectionOrder.map(id => sectionMap.get(id)).filter(Boolean); // 활성화된 섹션만 병렬로 처리 const activeSections = sections.filter(s => s.enabled); await Promise.all( activeSections.map(config => createAndPopulateSection(config, update)) ); customLog.log(`방송 목록 갱신 완료: ${new Date().toLocaleString()}`); }; /** * 기존 generateBroadcastElements 함수는 이 새로운 함수를 호출하도록 변경합니다. */ const generateBroadcastElements = async (update) => { // 갱신 시에는, 기존에 표시되던 섹션만 다시 로드합니다. if (update) { await initializeSidebar(true); return; } // 첫 로딩 시, 모든 활성화된 섹션을 렌더링합니다. await initializeSidebar(false); }; const makeTopNavbarAndSidebar = (page) => { // [1] 확장프로그램용 왼쪽 사이드바(메뉴바) 관리 let leftNavbar = document.body.querySelector('.left_navbar'); // 메뉴바가 없을 때만 생성 (중복 실행 방지) if (!leftNavbar) { leftNavbar = document.createElement('div'); leftNavbar.className = 'left_navbar'; // 1-1. 버튼 생성 로직 const buttonFragment = document.createDocumentFragment(); const isWwwUrl = CURRENT_URL.startsWith("https://www.sooplive.com"); const pendingButtons = []; BUTTON_DATA.reverse().forEach(data => { const newButton = document.createElement('a'); // href를 항상 설정해두면 브라우저가 기본 새탭 동작을 처리 newButton.href = data.href; newButton.target = '_self'; newButton.innerHTML = ``; if (isWwwUrl && data.onClickTarget) { // www: 좌클릭은 SPA 내부 라우팅 (triggerClick) const triggerClick = (e) => { e.preventDefault(); const el = document.querySelector(data.onClickTarget); if (el) { el.click(); } else { customLog.warn("타겟 요소를 찾을 수 없음:", data.onClickTarget); } }; if (document.querySelector(data.onClickTarget)) { newButton.addEventListener('click', triggerClick); } else { pendingButtons.push({ selector: data.onClickTarget, btn: newButton, handler: triggerClick }); } } else { // play/vod/ch 등: 좌클릭은 설정에 따라 같은탭/새탭 newButton.addEventListener('click', (e) => { e.preventDefault(); if (isOpenNewtabEnabled) { window.open(data.href, '_blank'); } else { location.href = data.href; } }); } // ── 모든 페이지에서 가운데 클릭(휠클릭) → 새 탭 ── // href가 설정된 에 auxclick이 붙으면 브라우저 기본 새탭 동작이 실행됨 // 단, preventDefault 없이 그냥 두면 자연스럽게 새탭으로 열림 // GM_openInTab 대신 browser native 방식 사용 (모든 도메인 호환) newButton.addEventListener('mousedown', (e) => { if (e.button === 1) { // 가운데 클릭: 브라우저 기본 새탭 동작을 방해하지 않도록 // scrolling 방지만 막고 나머지는 브라우저에 맡김 e.preventDefault(); } }); newButton.addEventListener('click', (e) => { // button 1(휠) 클릭이 click으로 올 경우 새탭 if (e. button === 1) return; // 이미 mousedown에서 처리 }); // auxclick = 보조 버튼(가운데) 클릭 완료 이벤트 newButton.addEventListener('auxclick', (e) => { if (e.button !== 1) return; e.preventDefault(); e.stopPropagation(); window.open(data.href, '_blank'); }); buttonFragment.appendChild(newButton); }); // 단일 Observer로 pending 버튼 일괄 바인딩 (최적화) if (pendingButtons.length > 0) { const sharedObs = new MutationObserver(() => { let i = pendingButtons.length; while (i--) { const item = pendingButtons[i]; if (document.querySelector(item.selector)) { item.btn.addEventListener('click', item.handler); pendingButtons.splice(i, 1); } } if (pendingButtons.length === 0) sharedObs.disconnect(); }); sharedObs.observe(document.body, { childList: true, subtree: true }); } leftNavbar.appendChild(buttonFragment); // 1-2. 헤더에 메뉴바 붙이기 (비동기) (async () => { const serviceHeaderDiv = await waitForElementAsync('#serviceHeader'); if (serviceHeaderDiv) { serviceHeaderDiv.prepend(leftNavbar); } })(); // 1-3. [핵심] 방송국 vs 일반 페이지 레이아웃 관리자 const maintainStationLayout = () => { // (1) 왼쪽 강제 여백 제거 & 우측 사이드바 삭제 if (document.body.classList.contains('customSidebar')) { document.body.classList.remove('customSidebar'); } const oldSidebar = document.getElementById('sidebar'); if (oldSidebar) oldSidebar.remove(); // (2) 중앙 레이아웃(메인) 강제 확장 const stationMain = document.querySelector('div[class*="layout_stationMain"]'); if (stationMain) { stationMain.style.setProperty('max-width', 'none', 'important'); stationMain.style.setProperty('width', '100%', 'important'); stationMain.style.setProperty('grid-template-columns', '1fr', 'important'); stationMain.style.setProperty('padding-right', '0px', 'important'); } // ★★★ (3) [방송국 전용] 상단 버튼 & 로고 위치 조정 ★★★ const btnFlexible = document.querySelector('.btn_flexible'); if (btnFlexible) { btnFlexible.style.setProperty('margin-left', '-20px', 'important'); } const logoWrap = document.querySelector('.logo_wrap'); if (logoWrap) { logoWrap.style.setProperty('margin-left', '-25px', 'important'); } // (4) [방송국 전용] 확장프로그램 메뉴바 위치 이동 if (leftNavbar) { leftNavbar.style.setProperty('left', '130px', 'important'); } }; const restoreDefaultLayout = () => { // (일반 모드) 왼쪽 강제 여백 복구 if (!document.body.classList.contains('customSidebar')) { document.body.classList.add('customSidebar'); } // ★★★ [복구] 상단 버튼 & 로고 위치 초기화 (방송국 설정 제거) ★★★ const btnFlexible = document.querySelector('.btn_flexible'); if (btnFlexible) { btnFlexible.style.removeProperty('margin-left'); // 원래대로 } const logoWrap = document.querySelector('.logo_wrap'); if (logoWrap) { logoWrap.style.removeProperty('margin-left'); // 원래대로 } // 메뉴바 위치 원상복구 if (leftNavbar) { leftNavbar.style.removeProperty('left'); } // 삭제된 사이드바 부활 및 내용물 채우기 if (!document.getElementById('sidebar')) { const sidebarClass = isSidebarMinimized ? "min" : "max"; const sidebarHtml = ``; if (window.location.href.includes('/player/')) { document.body.insertAdjacentHTML('beforeend', sidebarHtml); } else { const gnb = document.getElementById('soop-gnb'); if (gnb) gnb.insertAdjacentHTML('afterend', sidebarHtml); } // 내용물 다시 채워 넣기 (초기화) if (typeof initializeSidebar === 'function') { initializeSidebar(false); } // 툴팁 컨테이너 복구 if (!document.querySelector('.tooltip-container')) { const tooltipContainer = document.createElement('div'); tooltipContainer.classList.add('tooltip-container'); document.body.appendChild(tooltipContainer); } } }; // 1-4. 페이지 변화 감시 시작 (URL 변경 감지) let lastLayoutMode = null; let isLayoutSyncScheduled = false; const syncLayoutByPage = () => { isLayoutSyncScheduled = false; const currentUrl = window.location.href; const isStation = currentUrl.includes('/station/') || currentUrl.includes('ch.sooplive.com'); const nextMode = isStation ? 'station' : 'default'; if (nextMode !== lastLayoutMode) { lastLayoutMode = nextMode; if (isStation) maintainStationLayout(); else restoreDefaultLayout(); return; } // URL 모드 변화가 없어도 기본 페이지에서 사이드바가 사라졌다면 복구 if (!isStation && !document.getElementById('sidebar')) { restoreDefaultLayout(); } }; const scheduleLayoutSync = () => { if (isLayoutSyncScheduled) return; isLayoutSyncScheduled = true; requestAnimationFrame(syncLayoutByPage); }; const observer = new MutationObserver(scheduleLayoutSync); observer.observe(document.body, { childList: true, subtree: true }); // 1-5. 최초 실행 (새로고침 직후) scheduleLayoutSync(); } // [2] 페이지별 사이드바 컨텐츠 생성 (최초 로딩 시 사용 - 원래 기능) const sidebarClass = isSidebarMinimized ? "min" : "max"; const existingSidebar = document.getElementById('sidebar'); if (!document.querySelector('.tooltip-container')) { const tooltipContainer = document.createElement('div'); tooltipContainer.classList.add('tooltip-container'); document.body.appendChild(tooltipContainer); } if (page === "main" && !existingSidebar) { const newHtml = ``; const serviceLnbElement = document.getElementById('soop-gnb'); if (serviceLnbElement) { serviceLnbElement.insertAdjacentHTML('afterend', newHtml); } } if (page === "player" && !existingSidebar) { const sidebarHtml = ``; document.body.insertAdjacentHTML('beforeend', sidebarHtml); } }; /** * 유저 UI 요소를 생성하는 함수 (addEventListener 방식으로 개선) * @param {object} channel - 채널 데이터 객체 * @param {string} is_mobile_push - 알림 설정 여부 ('Y'/'N') * @param {boolean} is_pin - 상단 고정 여부 * @returns {HTMLElement} 생성된 a 태그 요소 */ const createUserElement = (channel, is_mobile_push, is_pin) => { const { user_id, broad_no, total_view_cnt, broad_title, user_nick, broad_start, broad_cate_no, category_name, subscription_only } = channel; const isSubOnly = Number(subscription_only || 0) > 0; const playerLink = `https://play.sooplive.com/${user_id}/${broad_no}`; const userElement = document.createElement('a'); userElement.className = 'user'; if (isSmallUserLayoutEnabled) userElement.classList.add('small-user-layout'); userElement.href = playerLink; // 중요: 브라우저가 직접 제어하지 못하게 target을 제거하거나 _self로 설정합니다. userElement.target = '_self'; // 클릭 이벤트 가로채기 userElement.addEventListener('click', (event) => { //2026.02.19 추가(프로필 사진 누를 시 해당 스트리머 방송국으로 이동) if (event.target.closest('.profile-picture')) { return; } // 새 탭 옵션이 켜져 있을 때 (백그라운드 오픈) if (isOpenNewtabEnabled && !event.ctrlKey && !event.shiftKey && !event.metaKey) { event.preventDefault(); // 브라우저 이동 차단 event.stopImmediatePropagation(); // 다른 스크립트 방해 금지 // GM_openInTab을 사용하며 active: false를 주면 화면이 유지됩니다. GM_openInTab(playerLink, { active: !isOpenBackgroundTabEnabled, insert: true, setParent: true }); return false; } // 새 탭 옵션이 꺼져 있을 때 (기존의 새로고침 없는 전환 로직) if (isSendLoadBroadEnabled && !isOpenNewtabEnabled && !event.ctrlKey) { if (window.location.href.includes('play.sooplive.com') && unsafeWindow.liveView) { event.preventDefault(); unsafeWindow.liveView.playerController.sendLoadBroad(user_id, broad_no); } } }, true); // true를 넣어 이벤트 캡처링 단계에서 가장 먼저 가로챕니다. userElement.setAttribute('data-watchers', total_view_cnt); userElement.setAttribute('broad_thumbnail', `https://liveimg.sooplive.com/m/${broad_no}`); userElement.setAttribute('tooltip', broad_title); userElement.setAttribute('user_id', user_id); userElement.setAttribute('broad_start', broad_start); userElement.setAttribute('broad_cate_no', broad_cate_no); userElement.setAttribute('is_mobile_push', is_mobile_push || 'N'); userElement.setAttribute('is_pin', is_pin ? 'Y' : 'N'); if (isPreviewModalFromSidebarEnabled) { userElement.addEventListener('contextmenu', (event) => { previewModalManager.handleSidebarContextMenu(userElement, event); event.preventDefault(); }); } // --- 자식 요소 생성 --- const profilePicture = document.createElement('img'); profilePicture.className = 'profile-picture'; profilePicture.src = `https://stimg.sooplive.com/LOGO/${user_id.slice(0, 2)}/${user_id}/m/${user_id}.webp`; profilePicture.loading = 'lazy'; profilePicture.onerror = function () { this.onerror = null; this.src = `https://profile.img.sooplive.com/LOGO/${user_id.slice(0, 2)}/${user_id}/m/${user_id}.jpg`; }; // (개선) 프로필 사진 클릭 이벤트 핸들러 //2026.02.19 추가(프로필 사진 누를 시 해당 스트리머 방송국으로 이동) profilePicture.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); const sidebar = document.getElementById('sidebar'); const isSidebarMinimized = sidebar && sidebar.classList.contains('min'); const isPlayerPage = typeof unsafeWindow !== "undefined" && typeof unsafeWindow.liveView !== "undefined"; const channelUrl = `https://www.sooplive.com/station/${user_id}`; let targetUrl; // 🔹 1️⃣ 플레이어 페이지가 아닐 때 (메인/방송국) if (!isPlayerPage) { targetUrl = channelUrl; // 👉 무조건 새 탭 GM_openInTab(targetUrl, { active: !isOpenBackgroundTabEnabled, insert: true, setParent: true }); return; } // 🔹 2️⃣ 플레이어 페이지일 때 if (isSidebarMinimized) { // 👉 접힘 → 현재 탭 LIVE 전환 if (isSendLoadBroadEnabled) { unsafeWindow.liveView.playerController.sendLoadBroad(user_id, broad_no); } else { window.location.href = playerLink; } return; } // 🔹 3️⃣ 플레이어 + 펼침 → 새 탭 방송국 targetUrl = channelUrl; GM_openInTab(targetUrl, { active: !isOpenBackgroundTabEnabled, insert: true, setParent: true }); }); // 나머지 UI 요소 생성 (innerHTML을 사용하여 간결하게 처리) const usernameText = (is_pin || is_mobile_push === "Y") ? `🖈${user_nick}` : user_nick; const usernameTitle = is_pin ? '고정됨(상단 고정 켜짐)' : is_mobile_push === "Y" ? '고정됨(알림 받기 켜짐)' : ''; const descriptionText = category_name || getCategoryName(broad_cate_no); const dotSymbol = isSubOnly ? '★' : '●'; const dotTitle = isSubOnly ? '구독+ 전용' : ''; userElement.innerHTML = ` ${usernameText} ${descriptionText} ${dotSymbol}${addNumberSeparator(total_view_cnt)} `; userElement.prepend(profilePicture); // 조립된 요소 앞에 프로필 사진 추가 return userElement; }; const createUserElementVod = (channel) => { const { user_id, title_no, view_cnt, title, user_nick, vod_duration, reg_date, thumbnail } = channel; const playerLink = `https://vod.sooplive.com/player/${title_no}`; const channelPage = `https://www.sooplive.com/station/${user_id}`; const userElement = document.createElement('a'); userElement.className = 'user'; if (isSmallUserLayoutEnabled) userElement.classList.add('small-user-layout'); userElement.href = playerLink; // [수정] 브라우저가 화면을 강제로 넘기지 못하게 _self로 고정합니다. userElement.target = '_self'; // [추가] 클릭 시 백그라운드에서 탭을 여는 로직 userElement.addEventListener('click', (event) => { if (isOpenNewtabEnabled && !event.ctrlKey && !event.shiftKey && !event.metaKey) { event.preventDefault(); event.stopImmediatePropagation(); // 화면 유지(active: false) 옵션 사용 GM_openInTab(playerLink, { active: !isOpenBackgroundTabEnabled, insert: true, setParent: true }); return false; } }, true); userElement.setAttribute('data-watchers', view_cnt); userElement.setAttribute('broad_thumbnail', thumbnail.replace("http://", "https://")); userElement.setAttribute('tooltip', title); userElement.setAttribute('user_id', user_id); userElement.setAttribute('vod_duration', vod_duration); const profilePicture = document.createElement('img'); profilePicture.className = 'profile-picture profile-grayscale'; profilePicture.src = `https://stimg.sooplive.com/LOGO/${user_id.slice(0, 2)}/${user_id}/m/${user_id}.webp`; profilePicture.loading = 'lazy'; profilePicture.onerror = function () { this.onerror = null; this.src = `https://profile.img.sooplive.com/LOGO/${user_id.slice(0, 2)}/${user_id}/m/${user_id}.jpg`; }; profilePicture.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); const isSidebarMinimized = document.getElementById('sidebar')?.offsetWidth === 52; const targetUrl = isSidebarMinimized ? playerLink : channelPage; if (isOpenNewtabEnabled || !isSidebarMinimized) { window.open(targetUrl, '_blank'); } else { location.href = playerLink; } }); userElement.innerHTML = ` ${user_nick} ${vod_duration} ${timeSince(reg_date)} `; userElement.prepend(profilePicture); return userElement; }; const createUserElementOffline = (channel, isFeeditem) => { const { user_id, user_nick, is_mobile_push, is_pin } = channel; const originalLink = isFeeditem ? isFeeditem.url : `https://www.sooplive.com/station/${user_id}`; const userElement = document.createElement('a'); userElement.className = 'user user-offline'; if (isSmallUserLayoutEnabled) userElement.classList.add('small-user-layout'); userElement.href = originalLink; userElement.setAttribute('user_id', user_id); userElement.setAttribute('is_offline', 'Y'); userElement.setAttribute('is_mobile_push', is_mobile_push || 'N'); userElement.setAttribute('is_pin', is_pin ? 'Y' : 'N'); userElement.setAttribute('data-watchers', isFeeditem ? isFeeditem.reg_timestamp : (channel.total_view_cnt || 0)); if (isFeeditem && isFeeditem.photo_cnt > 0) { userElement.setAttribute('broad_thumbnail', `https:${isFeeditem.photos[0].url}`); userElement.setAttribute('tooltip', isFeeditem.title_name); } else if (isFeeditem) { userElement.setAttribute('tooltip', isFeeditem.title_name); userElement.setAttribute('data-tooltip-listener', 'false'); } else { userElement.setAttribute('data-tooltip-listener', 'false'); } const profilePicture = document.createElement('img'); profilePicture.className = 'profile-picture profile-grayscale'; profilePicture.src = `https://stimg.sooplive.com/LOGO/${user_id.slice(0, 2)}/${user_id}/m/${user_id}.webp`; profilePicture.loading = 'lazy'; profilePicture.onerror = function () { this.onerror = null; this.src = `https://profile.img.sooplive.com/LOGO/${user_id.slice(0, 2)}/${user_id}/m/${user_id}.jpg`; }; const usernameText = is_pin ? `🖈${user_nick}` : user_nick; const descriptionText = isFeeditem ? isFeeditem.title_name : ''; const watchersHTML = isFeeditem ? isFeeditem.reg_date_human : '오프라인'; const usernameSpan = document.createElement('span'); usernameSpan.className = 'username'; usernameSpan.title = usernameText; usernameSpan.textContent = usernameText; const descriptionSpan = document.createElement('span'); descriptionSpan.className = 'description'; descriptionSpan.title = descriptionText; descriptionSpan.textContent = descriptionText; const watchersSpan = document.createElement('span'); watchersSpan.className = 'watchers'; watchersSpan.innerHTML = watchersHTML; userElement.appendChild(profilePicture); userElement.appendChild(usernameSpan); userElement.appendChild(descriptionSpan); userElement.appendChild(watchersSpan); if (originalLink.includes('/post/')) { const iframeUrl = new URL(originalLink); iframeUrl.searchParams.set('iframe', 'true'); userElement.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); const title = userElement.querySelector('.username')?.textContent || '오프라인 채널'; offlineUserModal?.destroy(); const fixedWidth = 700; const fixedHeight = window.innerHeight * 0.9; const topPos = (window.innerHeight - fixedHeight) / 2; const leftPos = (window.innerWidth - fixedWidth) / 2; offlineUserModal = new DraggableResizableModal(`offline-user-modal-${user_id}`, title, { width: `${fixedWidth}px`, height: `${fixedHeight}px`, top: `${topPos}px`, left: `${leftPos}px` }, { ignoreSavedState: true }); const modalElement = offlineUserModal.getModalElement(); const resizeHandle = modalElement.querySelector(`.${scopedClass('modal-resize-handle')}`); if (resizeHandle) { resizeHandle.style.display = 'none'; } const headerElement = modalElement.querySelector(`.${scopedClass('modal-header')}`); if (headerElement) { headerElement.style.cursor = 'default'; } const contentArea = offlineUserModal.getContentElement(); contentArea.style.padding = '0'; contentArea.style.overflow = 'hidden'; contentArea.innerHTML = ` `; const handleOutsideClick = (e) => { if (modalElement && !modalElement.contains(e.target)) { offlineUserModal?.destroy(); offlineUserModal = null; document.body.removeEventListener('click', handleOutsideClick, true); } }; setTimeout(() => { document.body.addEventListener('click', handleOutsideClick, { capture: true, once: true }); }, 0); offlineUserModal.show(); }); } else { userElement.target = '_blank'; } return userElement; }; const insertFoldButton = () => { const foldButton = `
`; const serviceLnbElement = document.getElementById('sidebar'); if (serviceLnbElement) { // 이미 버튼이 있으면 중복 추가 방지 if (serviceLnbElement.querySelector('.button-fold-sidebar')) return; serviceLnbElement.insertAdjacentHTML('beforeend', foldButton); const toggleSidebar = () => { isSidebarMinimized = !isSidebarMinimized; if (serviceLnbElement.classList.toggle('max')) { serviceLnbElement.classList.remove('min'); } else { serviceLnbElement.classList.remove('max'); serviceLnbElement.classList.add('min'); } // 플레이어 페이지 전용 webplayer_scroll 처리 (없으면 그냥 넘어감) const webplayer_scroll = document.getElementById('webplayer_scroll') || document.getElementById('list-container'); if (webplayer_scroll) { webplayer_scroll.style.left = isSidebarMinimized ? '52px' : '240px'; } 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 extractFollowUserIds = (response) => { allFollowUserIds = response.data.map(item => item.user_id); // 모든 user_id를 추출하여 전역 배열에 저장 GM_setValue("allFollowUserIds", allFollowUserIds); }; const containsBlockedWord = (tagArray) => { return tagArray?.some(tag => blockedWords.some(word => tag.toLowerCase().includes(word.toLowerCase()))) ?? false; }; const makeThumbnailTooltip = () => { try { const sidebar = document.getElementById('sidebar'); const tooltipContainer = document.querySelector('.tooltip-container'); if (!sidebar || !tooltipContainer) return; // 1. NodeList를 처음에 한 번만 배열로 변환하여 재사용 const elements = sidebar.querySelectorAll('a.user'); 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); } }, 48); 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 targetUser = to?.closest?.('a.user'); const isGoingToAnotherElement = !!targetUser && targetUser !== element && targetUser.getAttribute('data-tooltip-listener') !== 'false'; if (!isGoingToAnotherElement) { tooltipContainer.classList.remove('visible'); tooltipContainer.removeAttribute('data-tooltip-id'); tooltipContainer.innerHTML = ''; // 초기화 } }); // 'window' 이벤트 리스너는 루프 안에서 제거 element.setAttribute('data-tooltip-listener', 'true'); } }); // 2. 'window' 이벤트 리스너는 루프 밖에서 한 번만 등록 if (!window.hasMyTooltipMouseOutListener) { // 중복 등록을 막기 위한 플래그 window.addEventListener('mouseout', (e) => { if (!e.relatedTarget && !e.toElement) { tooltipContainer.classList.remove('visible'); tooltipContainer.innerHTML = ''; } }); window.hasMyTooltipMouseOutListener = 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 (element.getAttribute('data-hover-tooltip-id') !== uniqueId) return; // 방송 시간 && 이미지 && !게시판이미지 if (isReplaceEmptyThumbnailEnabled && 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) { customLog.error('makeThumbnailTooltip 함수에서 오류가 발생했습니다:', error); } }; /** * 사이드바 순서 조정 UI를 생성하고 드래그 앤 드롭 이벤트를 설정하는 함수 */ const populateOrderUI = () => { const orderListContainer = document.getElementById('sidebar-order-list'); if (!orderListContainer) return; orderListContainer.innerHTML = ''; // 기존 목록 초기화 const allSectionsInfo = { 'follow': { name: '⭐ 즐겨찾기' }, 'top': { name: '🔥 인기' }, 'myplus': { name: '👍 추천 LIVE' }, 'myplusvod': { name: '🎞️ 추천 VOD' }, }; // 현재 저장된 순서대로 UI 아이템 생성 sidebarSectionOrder.forEach(sectionId => { const sectionInfo = allSectionsInfo[sectionId]; if (sectionInfo) { const item = document.createElement('div'); item.className = 'draggable-item_v8xK4z'; item.draggable = true; item.dataset.sectionId = sectionId; item.textContent = sectionInfo.name; orderListContainer.appendChild(item); } }); // 드래그 앤 드롭 이벤트 리스너 추가 const draggables = orderListContainer.querySelectorAll('.draggable-item_v8xK4z'); draggables.forEach(draggable => { draggable.addEventListener('dragstart', () => { draggable.classList.add('dragging_v8xK4z'); }); draggable.addEventListener('dragend', () => { draggable.classList.remove('dragging_v8xK4z'); }); }); orderListContainer.addEventListener('dragover', e => { e.preventDefault(); // 마우스의 X좌표(e.clientX)를 기준으로 위치 계산 const afterElement = getDragAfterElement(orderListContainer, e.clientX); const dragging = document.querySelector('.dragging_v8xK4z'); if (afterElement == null) { orderListContainer.appendChild(dragging); } else { orderListContainer.insertBefore(dragging, afterElement); } }); orderListContainer.addEventListener('drop', e => { e.preventDefault(); const newOrder = [...orderListContainer.querySelectorAll('.draggable-item_v8xK4z')].map(item => item.dataset.sectionId); sidebarSectionOrder = newOrder; GM_setValue('sidebarSectionOrder', newOrder); customLog.log('New sidebar order saved:', newOrder); }); // 가로 정렬을 위해 X축 기준으로 다음 요소를 찾는 함수로 수정 function getDragAfterElement(container, x) { const draggableElements = [...container.querySelectorAll('.draggable-item_v8xK4z:not(.dragging_v8xK4z)')]; return draggableElements.reduce((closest, child) => { const box = child.getBoundingClientRect(); // Y축(top, height) 대신 X축(left, width) 기준으로 offset 계산 const offset = x - box.left - box.width / 2; if (offset < 0 && offset > closest.offset) { return { offset: offset, element: child }; } else { return closest; } }, { offset: Number.NEGATIVE_INFINITY }).element; } }; /** * '더 보기' 버튼을 생성하고 관련 이벤트를 처리하는 함수 (모든 상태 처리) * @param {HTMLElement} container - 버튼이 추가될 부모 컨테이너 요소 * @param {string} buttonId - 버튼에 할당할 고유 ID * @param {number} hiddenCount - 현재 숨겨진 항목의 수 * @param {number} initialDisplayCount - 초기에 표시되는 항목의 수 (접기 시 기준) */ const createShowMoreButton = (container, buttonId, hiddenCount, initialDisplayCount) => { const existingButton = document.getElementById(buttonId); if (existingButton) existingButton.remove(); const toggleButton = document.createElement('button'); toggleButton.id = buttonId; toggleButton.title = "좌클릭: 더 보기/접기, 우클릭: 초기화"; // (핵심 수정) hiddenCount가 0이면 '접기'로 초기 텍스트 설정 if (hiddenCount > 0) { toggleButton.textContent = `더 보기 (${hiddenCount})`; } else { toggleButton.textContent = '접기'; } container.appendChild(toggleButton); const displayPerClick = 10; toggleButton.addEventListener('click', () => { if (toggleButton.textContent === '접기') { const allUsers = Array.from(container.querySelectorAll('.user')); allUsers.slice(initialDisplayCount).forEach(user => { user.classList.add('show-more'); }); const newHiddenCount = allUsers.length - initialDisplayCount; toggleButton.textContent = `더 보기 (${newHiddenCount})`; } else { const hiddenUsers = Array.from(container.querySelectorAll('.user.show-more')); hiddenUsers.slice(0, displayPerClick).forEach(user => user.classList.remove('show-more')); const remainingHiddenCount = hiddenUsers.length - displayPerClick; toggleButton.textContent = remainingHiddenCount > 0 ? `더 보기 (${remainingHiddenCount})` : '접기'; } }); toggleButton.addEventListener('contextmenu', event => { event.preventDefault(); const allUsers = Array.from(container.querySelectorAll('.user')); allUsers.slice(initialDisplayCount).forEach(user => user.classList.add('show-more')); toggleButton.textContent = `더 보기 (${allUsers.length - initialDisplayCount})`; }); }; // ===== 숨기기 관리 모달 ===== const createHideManagerModal = () => { if (document.getElementById('hideManagerModal')) return; const modal = document.createElement('div'); modal.id = 'hideManagerModal'; modal.innerHTML = `

🚫 숨기기 관리

🧑 스트리머 숨기기(ID 필요)
📁 카테고리 숨기기
🏷️ 태그 숨기기
📝 제목 키워드 숨기기
🚫 숨기기 방법: 직접 입력하여 추가하거나, 방송 목록 카드의 ⋮ 버튼을 이용하세요.
`; document.body.appendChild(modal); // 닫기 modal.querySelector('.hm-close').onclick = () => modal.style.display = 'none'; modal.addEventListener('click', (e) => { if (e.target === modal) modal.style.display = 'none'; }); window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.style.display === 'flex') modal.style.display = 'none'; }); // ★ JSON 내보내기 document.getElementById('hm-export-json').onclick = () => { const data = { blockedUsers, blockedCategories, blockedWords }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `soop_hidelist_${new Date().toISOString().slice(0,10)}.json`; a.click(); URL.revokeObjectURL(url); }; // ★ JSON 불러오기 document.getElementById('hm-import-json').onclick = () => { document.getElementById('hm-import-file').click(); }; document.getElementById('hm-import-file').onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { try { const imported = JSON.parse(ev.target.result); if (imported.blockedUsers) { // 중복 없이 병합 imported.blockedUsers.forEach(u => { if (!blockedUsers.some(x => x.userId === u.userId)) blockedUsers.push(u); }); GM_setValue('blockedUsers', blockedUsers); } if (imported.blockedCategories) { imported.blockedCategories.forEach(c => { if (!blockedCategories.some(x => x.categoryId === c.categoryId)) blockedCategories.push(c); }); GM_setValue('blockedCategories', blockedCategories); } if (imported.blockedWords) { imported.blockedWords.forEach(w => { if (!blockedWords.includes(w)) blockedWords.push(w); }); GM_setValue('blockedWords', blockedWords); } alert('불러오기 완료! 페이지를 새로고침하면 적용됩니다.'); // 화면 갱신 renderStreamers(); renderCategories(); renderTags(); renderKeywords(); e.target.value = ''; // 파일 input 초기화 } catch (err) { alert('JSON 파일을 읽는 중 오류가 발생했습니다: ' + err.message); } }; reader.readAsText(file); }; // ---- 렌더 함수들 ---- const renderTag = (text, onRemove) => { const tag = document.createElement('span'); tag.className = 'hm-tag'; tag.innerHTML = `${text}`; tag.querySelector('.hm-tag-remove').onclick = onRemove; return tag; }; const renderEmpty = (box) => { box.innerHTML = '등록된 항목이 없습니다.'; }; // ---- 스트리머 ---- const renderStreamers = () => { const box = document.getElementById('hm-list-streamer'); box.innerHTML = ''; if (!blockedUsers.length) { renderEmpty(box); return; } blockedUsers.forEach(user => { box.appendChild(renderTag(`${user.userName} (${user.userId})`, () => { unblockUser(user.userId); renderStreamers(); })); }); }; document.getElementById('hm-add-streamer').onclick = () => { const name = prompt('숨길 스트리머 닉네임:'); const id = name ? prompt(`${name}의 아이디:`) : null; if (name && id) { blockUser(name.trim(), id.trim()); renderStreamers(); } }; document.getElementById('hm-clear-streamer').onclick = () => { if (!confirm('스트리머 숨기기 목록을 전부 삭제할까요?')) return; [...blockedUsers].forEach(u => unblockUser(u.userId)); renderStreamers(); }; // ---- 카테고리 ---- const renderCategories = () => { const box = document.getElementById('hm-list-category'); box.innerHTML = ''; if (!blockedCategories.length) { renderEmpty(box); return; } blockedCategories.forEach(cat => { box.appendChild(renderTag(cat.categoryName, () => { unblockCategory(cat.categoryId); renderCategories(); })); }); }; document.getElementById('hm-add-category').onclick = () => { const name = prompt('숨길 카테고리 이름:'); if (!name) return; const id = getCategoryNo(name.trim()); if (!id) { alert(`'${name}'은(는) 유효하지 않은 카테고리입니다.`); return; } blockCategory(name.trim(), id); renderCategories(); }; document.getElementById('hm-clear-category').onclick = () => { if (!confirm('카테고리 숨기기 목록을 전부 삭제할까요?')) return; [...blockedCategories].forEach(c => unblockCategory(c.categoryId)); renderCategories(); }; // ---- 태그(단어) ---- // 기존 blockedWords를 태그 차단용으로 재활용 const renderTags = () => { const box = document.getElementById('hm-list-tag'); box.innerHTML = ''; // blockedWords 중 tag: 접두사를 가진 것만 (또는 전체를 태그로 간주) const tagWords = blockedWords.filter(w => w.startsWith('tag:')); if (!tagWords.length) { renderEmpty(box); return; } tagWords.forEach(w => { const display = w.replace(/^tag:/, ''); box.appendChild(renderTag(display, () => { unblockWord(w); renderTags(); })); }); }; document.getElementById('hm-add-tag').onclick = () => { const word = prompt('숨길 태그:'); if (!word) return; blockWord('tag:' + word.trim()); renderTags(); }; document.getElementById('hm-clear-tag').onclick = () => { if (!confirm('태그 숨기기 목록을 전부 삭제할까요?')) return; [...blockedWords.filter(w => w.startsWith('tag:'))].forEach(w => unblockWord(w)); renderTags(); }; // ---- 제목 키워드 ---- const renderKeywords = () => { const box = document.getElementById('hm-list-keyword'); box.innerHTML = ''; const kwWords = blockedWords.filter(w => !w.startsWith('tag:')); if (!kwWords.length) { renderEmpty(box); return; } kwWords.forEach(w => { box.appendChild(renderTag(w, () => { unblockWord(w); renderKeywords(); })); }); }; document.getElementById('hm-add-keyword').onclick = () => { const word = prompt('숨길 제목 키워드 (2자 이상):'); if (!word) return; blockWord(word.trim()); renderKeywords(); }; document.getElementById('hm-clear-keyword').onclick = () => { if (!confirm('제목 키워드 숨기기 목록을 전부 삭제할까요?')) return; [...blockedWords.filter(w => !w.startsWith('tag:'))].forEach(w => unblockWord(w)); renderKeywords(); }; // 열릴 때마다 목록 갱신 modal._refreshAll = () => { renderStreamers(); renderCategories(); renderTags(); renderKeywords(); }; }; const openHideManager = () => { createHideManagerModal(); const modal = document.getElementById('hideManagerModal'); modal._refreshAll(); modal.style.display = 'flex'; }; 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 = `
`; // 3. 모달 기능 구현 document.body.insertAdjacentHTML("beforeend", modalContentHTML); const modal = document.getElementById("myModal"); if (modal) { let isFirstOpen = true; const closeModal = () => { modal.style.display = "none"; document.body.style.overflow = ''; }; openModalBtn.addEventListener("click", () => { modal.style.display = "block"; document.body.style.overflow = 'hidden'; if (isFirstOpen) { // 숨기기 관리 버튼 (설정 UI 내부) const inlineHideBtn = document.getElementById('btn-open-hide-manager-inline'); if (inlineHideBtn && !inlineHideBtn._bound) { inlineHideBtn._bound = true; inlineHideBtn.addEventListener('click', () => { // 설정 모달을 닫지 않고 숨기기 관리 모달을 위에 띄움 openHideManager(); }); } updateSettingsData(); isFirstOpen = false; } populateOrderUI(); // 모달이 열릴 때마다 순서 UI 갱신 }); const closeBtn = modal.querySelector(".close-button_v8xK4z"); if (closeBtn) { closeBtn.addEventListener("click", closeModal); } modal.addEventListener("click", (event) => { if (event.target === modal) { closeModal(); } }); window.addEventListener('keydown', (event) => { if (event.key === 'Escape' && modal.style.display === 'block') { closeModal(); } }); // 인덱스 메뉴 및 스크롤 기능 const indexButtons = modal.querySelectorAll(".index-button_v8xK4z"); const optionsContainer = modal.querySelector(".modal-body_v8xK4z"); const sectionTitles = modal.querySelectorAll(".section-title_v8xK4z"); indexButtons.forEach(button => { button.addEventListener("click", () => { const targetId = button.getAttribute("data-target-id"); const targetElement = document.getElementById(targetId); if (targetElement && optionsContainer) { targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }); }); // 현재 활성화된 버튼을 추적합니다. let currentActiveButton = null; const observer = new IntersectionObserver(entries => { // 활성화 영역에 들어온(isIntersecting) 모든 항목을 필터링합니다. const intersectingEntries = entries.filter(entry => entry.isIntersecting); // 활성화 영역에 항목이 하나 이상 있는 경우 if (intersectingEntries.length > 0) { // 가장 마지막에 들어온 항목을 선택합니다 (일반적으로 배열의 마지막 요소). const lastEntry = intersectingEntries[intersectingEntries.length - 1]; const targetId = lastEntry.target.id; const newActiveButton = modal.querySelector(`.index-button_v8xK4z[data-target-id="${targetId}"]`); // 새로운 버튼이 있고, 현재 활성화된 버튼과 다른 경우에만 클래스를 변경합니다. if (newActiveButton && newActiveButton !== currentActiveButton) { // 이전에 활성화된 버튼이 있다면 'active' 클래스를 제거합니다. if (currentActiveButton) { currentActiveButton.classList.remove('active'); } // 새로운 버튼에 'active' 클래스를 추가하고 현재 활성 버튼으로 설정합니다. newActiveButton.classList.add('active'); currentActiveButton = newActiveButton; } } }, { root: optionsContainer, // 활성화 영역을 컨테이너 상단 10%로 좁혀 더 정밀하게 만듭니다. rootMargin: "0px 0px -90% 0px", threshold: 0 }); sectionTitles.forEach(title => { observer.observe(title); }); } }; 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 { customLog.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"); const qualitySelector = document.getElementById('qualitySelector'); if (qualitySelector) { qualitySelector.value = targetQuality; qualitySelector.addEventListener('change', (e) => { targetQuality = e.target.value; GM_setValue('targetQuality', targetQuality); }); } setCheckboxAndSaveValue("mpSortByViewers", myplusOrder, "myplusOrder"); setCheckboxAndSaveValue("removeDuplicates", isDuplicateRemovalEnabled, "isDuplicateRemovalEnabled"); setCheckboxAndSaveValue("openInNewTab", isOpenNewtabEnabled, "isOpenNewtabEnabled"); setCheckboxAndSaveValue("openBackgroundTab", isOpenBackgroundTabEnabled, "isOpenBackgroundTabEnabled"); 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("switchPreviewModal", isPreviewModalEnabled, "isPreviewModalEnabled"); setCheckboxAndSaveValue("switchPreviewModalRightClick", isPreviewModalRightClickEnabled, "isPreviewModalRightClickEnabled"); setCheckboxAndSaveValue("switchPreviewModalFromSidebar", isPreviewModalFromSidebarEnabled, "isPreviewModalFromSidebarEnabled"); 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("switchRemoveShadowsFromCatch", isRemoveShadowsFromCatchEnabled, "isRemoveShadowsFromCatchEnabled"); //2026.03.24 다음클립 자동재생 기능 setCheckboxAndSaveValue("switchCatchAutoNext", isCatchAutoNextEnabled, "isCatchAutoNextEnabled"); setCheckboxAndSaveValue("switchShowSelectedMessages", isShowSelectedMessagesEnabled, "isShowSelectedMessagesEnabled"); setCheckboxAndSaveValue("switchShowDeletedMessages", isShowDeletedMessagesEnabled, "isShowDeletedMessagesEnabled"); setCheckboxAndSaveValue("switchNoAutoVOD", isNoAutoVODEnabled, "isNoAutoVODEnabled"); //2026.02.24 VOD 및 클립 자동재생 삭제기능 setCheckboxAndSaveValue("switchBlockVODNext", GM_getValue("isBlockVODNextEnabled", false), "isBlockVODNextEnabled"); // VOD 다음 영상 차단 체크박스 상태 setCheckboxAndSaveValue("switchBlockClipNext", GM_getValue("isBlockClipNextEnabled", false), "isBlockClipNextEnabled"); // 클립 다음 영상 차단 체크박스 상태 setCheckboxAndSaveValue("switchRedirectLive", isRedirectLiveEnabled, "isRedirectLiveEnabled"); 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"); setCheckboxAndSaveValue("switchVODHighlight", isVODHighlightEnabled, "isVODHighlightEnabled"); setCheckboxAndSaveValue("switchCheckBestStreamersList", isCheckBestStreamersListEnabled, "isCheckBestStreamersListEnabled"); setCheckboxAndSaveValue("switchClickPlayerEventMapper", isClickPlayerEventMapperEnabled, "isClickPlayerEventMapperEnabled"); setCheckboxAndSaveValue("switchFavoriteGroups", isFavoriteGroupEnabled, "isFavoriteGroupEnabled"); setCheckboxAndSaveValue("switchCategoryGroups", isCategoryGroupEnabled, "isCategoryGroupEnabled"); setCheckboxAndSaveValue("switchShortenFavoriteGroupName", isShortenFavoriteGroupNameEnabled, "isShortenFavoriteGroupNameEnabled"); setCheckboxAndSaveValue("switchShortenCategoryName", isShortenCategoryNameEnabled, "isShortenCategoryNameEnabled"); setCheckboxAndSaveValue("switchPlayerAdvancedControlsLive", isPlayerAdvancedControlsLiveEnabled, "isPlayerAdvancedControlsLiveEnabled"); setCheckboxAndSaveValue("switchPlayerAdvancedControlsVOD", isPlayerAdvancedControlsVODEnabled, "isPlayerAdvancedControlsVODEnabled"); setCheckboxAndSaveValue("switchPlayerPanzoom", isPlayerPanzoomEnabled, "isPlayerPanzoomEnabled"); setCheckboxAndSaveValue("switchPlayerPanzoomVOD", isPlayerPanzoomVODEnabled, "isPlayerPanzoomVODEnabled"); setCheckboxAndSaveValue("switchHideDuplicateChat", isHideDuplicateChatEnabled, "isHideDuplicateChatEnabled"); setCheckboxAndSaveValue("switchChatResizable", isChatResizableEnabled, "isChatResizableEnabled"); setCheckboxAndSaveValue("switchHideLiveProgressBar", isHideLiveProgressBarEnabled, "isHideLiveProgressBarEnabled"); // ★ [추가] 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; REG_WORDS = parseRegisteredWords(); GM_setValue("registeredWords", inputValue); compileBlockRules(); }); // 유저 채팅 모아보기 입력 상자 설정 const selectedUsersinputBox = document.getElementById('selectedUsersInput'); selectedUsersinputBox.addEventListener('input', () => { const inputValue = selectedUsersinputBox.value.trim(); selectedUsers = inputValue; GM_setValue("selectedUsers", inputValue); }); // 1. Select 메뉴를 위한 새로운 헬퍼 함수를 정의합니다. const setSelectAndSaveValue = (elementId, storageKey, defaultValue) => { const select = document.getElementById(elementId); if (select) { // Greasemonkey에 저장된 값을 불러와 select 메뉴의 초기 값을 설정합니다. select.value = GM_getValue(storageKey, defaultValue); // select 메뉴의 값이 변경될 때마다 새로운 값을 저장합니다. select.addEventListener("change", (event) => { GM_setValue(storageKey, event.target.value); }); } else { customLog.warn(`Select element with id "${elementId}" not found.`); } }; // 2. 새로 만든 헬퍼 함수를 사용하여 각 Select 메뉴를 설정합니다. setSelectAndSaveValue("selectLeftClick", "livePlayerLeftClickFunction", "toggleMute"); setSelectAndSaveValue("selectRightClick", "livePlayerRightClickFunction", "toggleScreenMode"); setSelectAndSaveValue("redirectLiveSortOption", "redirectLiveSortOption", "custom"); // 하위 옵션 숨기기 setupDependentVisibility({ controllers: [document.getElementById('switchNoAutoVOD')], targets: [document.getElementById('redirectLiveOptionContainer')] }); setupDependentVisibility({ controllers: [document.getElementById('switchPreviewModal')], targets: [document.getElementById('switchPreviewModalRightClickContainer')] }); setupDependentVisibility({ controllers: [document.getElementById('selectBlockWords')], targets: [document.getElementById('blockWordsInputContainer')] }); setupDependentVisibility({ controllers: [ document.getElementById('switchHideDuplicateChat'), document.getElementById('switchShowSelectedMessages'), document.getElementById('switchVODChatScan') ], targets: [ document.getElementById('selectedUsersInputContainer'), document.getElementById('switchCheckBestStreamersListContainer'), document.getElementById('switchChatCounterContainer') ] }); setupDependentVisibility({ controllers: [ document.getElementById('switchCustomSidebar') ], targets: [ ...document.querySelectorAll('.customSidebarOptionsContainer') ] }); }; 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 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 반환 }); }; function replaceThumbnails() { // 1. 화면 내 '연령제한' 배지들을 모두 찾음 const adultBadges = document.querySelectorAll('.status.adult'); adultBadges.forEach(badge => { // 2. 배지가 속한 박스(.thumbs-box) 찾기 const container = badge.closest('.thumbs-box'); if (!container) return; const link = container.querySelector('a'); const img = link ? link.querySelector('img') : null; // 유효성 검사 & 이미 이벤트를 붙였는지 확인 (중복 방지) if (!link || !img || link.dataset.mouseEventAttached === 'true') return; // 3. "이벤트 리스너를 붙였다"는 표시를 남김 link.dataset.mouseEventAttached = 'true'; // 4. [핵심] 마우스가 올라갔을 때(mouseenter) 실행될 동작 정의 link.addEventListener('mouseenter', async () => { // 이미 이미지를 로드해서 바꿨다면 다시 실행하지 않음 if (link.dataset.imageLoaded === 'true') return; // 링크에서 아이디와 방송번호 추출 const href = link.getAttribute('href'); const matches = href && href.match(/play\.sooplive\.com\/([^/]+)\/(\d+)/); if (matches) { const broadcasterId = matches[1]; const broadNo = matches[2]; // (선택사항) 로딩 중임을 알리기 위해 투명도 조절 // img.style.opacity = '0.6'; try { // 방송 이미지 데이터 가져오기 const frameData = await getLatestFrameData(broadcasterId, broadNo); if (frameData) { img.src = frameData; img.style.objectFit = 'cover'; // (선택사항) 투명도 원상복구 // img.style.opacity = '1'; // 성공적으로 바꿨음을 표시 (다시 마우스 올려도 로드 안 함) link.dataset.imageLoaded = 'true'; } } catch (err) { console.error('썸네일 로드 실패:', err); } } }); }); } /** * ================================================================= * 프리뷰 모달 클래스 (PreviewModal Class) * 모달 관련 모든 기능(생성, 열기, 닫기, 이벤트 연결 등)을 캡슐화합니다. * ================================================================= */ class PreviewModal { /** * PreviewModal 클래스의 생성자 */ constructor() { this.elements = null; this.isOpenNewtabEnabled = isOpenNewtabEnabled; // '참여하기' 버튼 클릭 시 새 탭에서 열지 여부 this.isPreviewModalRightClickEnabled = isPreviewModalRightClickEnabled; // 우클릭으로 미리보기 열기 기능 사용 여부 this.hls = null; } /** * [내부 메서드] 모달에 필요한 DOM 요소를 생성하고 body에 추가합니다. * 이 메서드는 모달이 처음 열릴 때 한 번만 호출됩니다. */ _createModal() { const modal = document.createElement('div'); modal.className = 'preview-modal'; const modalContent = document.createElement('div'); modalContent.className = 'preview-modal-content'; const closeButton = document.createElement('span'); closeButton.className = 'preview-close'; closeButton.innerHTML = '×'; const videoPlayer = document.createElement('video'); videoPlayer.controls = true; const infoContainer = document.createElement('div'); infoContainer.className = 'info'; const streamerName = document.createElement('div'); streamerName.className = 'streamer-name'; const videoTitle = document.createElement('div'); videoTitle.className = 'video-title'; const tagsContainer = document.createElement('div'); tagsContainer.className = 'tags'; const startButton = document.createElement('a'); startButton.className = 'start-button'; startButton.textContent = '참여하기 >'; infoContainer.append(streamerName, tagsContainer, videoTitle, startButton); modalContent.append(closeButton, videoPlayer, infoContainer); modal.appendChild(modalContent); document.body.appendChild(modal); // 생성된 요소들을 클래스의 elements 속성에 저장합니다. this.elements = { modal, videoPlayer, streamerName, videoTitle, tagsContainer, startButton }; // 이벤트 핸들러를 연결합니다. 'this'가 클래스 인스턴스를 가리키도록 화살표 함수를 사용합니다. closeButton.onclick = () => this.close(); startButton.onclick = () => { setTimeout(() => this.close(), 1000); }; window.onclick = (event) => { if (event.target === this.elements.modal) { this.close(); } }; } /** * 모달을 닫고 비디오 재생을 중지합니다. */ close() { if (!this.elements) return; // 모달이 생성되지 않았으면 아무것도 하지 않음 this.elements.modal.style.display = 'none'; this.elements.videoPlayer.pause(); this.elements.videoPlayer.src = ''; // HLS 인스턴스가 있으면 파괴하여 메모리 누수를 방지합니다. if (this.hls) { this.hls.destroy(); this.hls = null; } } /** * [핵심] 방송 데이터를 기반으로 미리보기 모달을 엽니다. * @param {object} data - { id, broadNumber, streamerName, videoTitle, tags } */ async open(data) { // 모달 DOM이 아직 생성되지 않았다면, 이 시점에서 생성합니다. if (!this.elements) { this._createModal(); } // 모달이 이미 열려있으면 중단 if (this.elements.modal.style.display === 'block') { return; } // 필수 데이터 확인 if (!data.id || !data.broadNumber) { customLog.error("미리보기를 위한 필수 정보(id, broadNumber)가 부족합니다."); return; } const playerLink = `https://play.sooplive.com/${data.id}/${data.broadNumber}`; try { // `getM3u8url`은 외부에 정의된 함수라고 가정합니다. const m3u8url = await getM3u8url(data.id, data.broadNumber, 'hd'); const modalData = { ...data, m3u8url, playerLink }; this._updateContent(modalData); // 모달 내용 업데이트 this.elements.modal.style.display = 'block'; // 모달 보이기 } catch (error) { customLog.error('방송 정보를 가져오는 데 실패했습니다:', error); // 에러 발생 시 참여하기 버튼이라도 활성화되도록 처리할 수 있습니다. const errorData = { ...data, m3u8url: null, playerLink }; this._updateContent(errorData); this.elements.modal.style.display = 'block'; } } /** * [내부 메서드] 받은 데이터를 기반으로 모달의 내용을 업데이트합니다. * @param {object} data - 모달에 표시할 모든 정보 */ _updateContent(data) { const { videoPlayer, streamerName, videoTitle, tagsContainer, startButton } = this.elements; const { m3u8url, playerLink, streamerName: name, videoTitle: title, tags } = data; const hrefTarget = this.isOpenNewtabEnabled ? "_blank" : "_self"; streamerName.textContent = name; videoTitle.textContent = title; this._updateTags(tagsContainer, tags); startButton.setAttribute('href', playerLink); startButton.setAttribute('target', hrefTarget); // 비디오 플레이어 설정 if (m3u8url) { this._setupVideoPlayer(videoPlayer, m3u8url); } else { // M3U8 주소를 가져오지 못한 경우 비디오 플레이어를 숨김 처리할 수 있습니다. videoPlayer.style.display = 'none'; } } /** * [내부 메서드] 태그 목록을 업데이트합니다. * @param {HTMLElement} tagsContainer - 태그가 표시될 부모 요소 * @param {Array} tags - 태그 정보 배열 [{ text, href }] */ _updateTags(tagsContainer, tags = []) { tagsContainer.innerHTML = ''; // 이전 태그 모두 제거 tags.forEach(tag => { const tagElement = document.createElement('a'); tagElement.textContent = tag.text; tagElement.href = tag.href; tagsContainer.appendChild(tagElement); }); } /** * [내부 메서드] HLS.js를 사용하여 비디오 플레이어를 설정하고 재생합니다. * @param {HTMLVideoElement} videoPlayer - 비디오 플레이어 요소 * @param {string} m3u8url - 재생할 M3U8 주소 */ _setupVideoPlayer(videoPlayer, m3u8url) { const playVideo = () => { const savedVolume = localStorage.getItem('videoPlayerVolume'); videoPlayer.volume = (savedVolume !== null) ? parseFloat(savedVolume) : 0.5; videoPlayer.style.display = 'block'; videoPlayer.play(); }; videoPlayer.onvolumechange = () => { localStorage.setItem('videoPlayerVolume', videoPlayer.volume); }; if (unsafeWindow.Hls.isSupported()) { // 이전 HLS 인스턴스가 있다면 파괴 if (this.hls) { this.hls.destroy(); } this.hls = new unsafeWindow.Hls(); this.hls.loadSource(m3u8url); this.hls.attachMedia(videoPlayer); this.hls.on(unsafeWindow.Hls.Events.MANIFEST_PARSED, playVideo); } else if (videoPlayer.canPlayType('application/vnd.apple.mpegurl')) { videoPlayer.src = m3u8url; videoPlayer.addEventListener('loadedmetadata', playVideo, { once: true }); // 이벤트가 한 번만 실행되도록 설정 } else { alert('이 브라우저는 HLS 비디오를 지원하지 않습니다.'); } } /** * 썸네일 링크 목록에 미리보기 이벤트 리스너를 추가합니다. * @param {NodeListOf} thumbsBoxLinks - 썸네일 링크 요소 목록 */ attachToThumbnails(thumbsBoxLinks) { for (const thumbsBoxLink of thumbsBoxLinks) { if (thumbsBoxLink.classList.contains("preview-checked")) continue; thumbsBoxLink.classList.add("preview-checked"); const hrefValue = thumbsBoxLink.getAttribute('href'); if (!hrefValue?.includes("play.sooplive.com")) continue; const eventType = this.isPreviewModalRightClickEnabled ? "contextmenu" : "click"; thumbsBoxLink.addEventListener(eventType, async (event) => { event.preventDefault(); event.stopPropagation(); const [, , , id, broadNumber] = hrefValue.split('/'); const parent = thumbsBoxLink.parentNode.parentNode; const streamerName = parent.querySelector('.nick').innerText; const videoTitle = parent.querySelector('.title a').innerText; const tagNodes = parent.querySelectorAll('.tag_wrap a'); const tags = Array.from(tagNodes).map(tag => ({ text: tag.innerText, href: tag.getAttribute("class") === "category" ? `https://www.sooplive.com/directory/category/${encodeURIComponent(tag.innerText)}/live` : `https://www.sooplive.com/search?hash=hashtag&tagname=${encodeURIComponent(tag.innerText)}&hashtype=live&stype=hash&acttype=live&location=live_main&inflow_tab=` })); const broadcastData = { id, broadNumber, streamerName, videoTitle, tags }; await this.open(broadcastData); // 클래스의 open 메서드 호출 }); } } /** * 사이드바 링크의 oncontextmenu 속성에서 호출될 헬퍼 함수 * @param {HTMLElement} element - 우클릭된 요소 * @param {Event} event - contextmenu 이벤트 객체 */ async handleSidebarContextMenu(element, event) { event.preventDefault(); event.stopPropagation(); const href = element.getAttribute('href'); const parts = href.split('/'); const id = element.dataset.userId || parts[3]; const broadNumber = parts[4]; if (!id || !broadNumber) { customLog.error("ID 또는 방송 번호를 추출할 수 없습니다.", element); return; } const streamerName = element.querySelector('.username')?.innerText || id; const videoTitle = element.getAttribute('tooltip') || element.querySelector('.description')?.innerText; const categorySpan = element.querySelector('.description'); const tags = []; if (categorySpan) { const categoryText = categorySpan.innerText; tags.push({ text: categoryText, href: `https://www.sooplive.com/directory/category/${encodeURIComponent(categoryText)}/live` }); } const broadcastData = { id, broadNumber, streamerName, videoTitle, tags }; await this.open(broadcastData); } } 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 (!timeShiftButton || 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) { customLog.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) { customLog.log(error); } }; 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 { customLog.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.com/") ? '#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'); if (!writeArea) return; // 복사 기능 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 setupChatResizable = () => { if (!isChatResizableEnabled) return; if (document.getElementById('chatWidthSlider')) return; waitForElementAsync('#chatting_area .chat_title').then((chatTitleDiv) => { if (!chatTitleDiv) return; if (document.getElementById('chatWidthSlider')) return; const chattingArea = document.querySelector("#chatting_area"); if (!chattingArea) return; let ul = chatTitleDiv.querySelector("ul"); if (!ul) { ul = document.createElement("ul"); chatTitleDiv.appendChild(ul); } let insertBeforeElement = ul.querySelector("#setbox_viewer") || ul.querySelector("#setbox_set"); if (!insertBeforeElement) { insertBeforeElement = ul.querySelector("li.set"); } if (!insertBeforeElement) { insertBeforeElement = ul.firstChild; } const sliderLi = document.createElement("li"); sliderLi.style.padding = "0 10px"; sliderLi.style.display = "flex"; sliderLi.style.alignItems = "center"; const rangeInput = document.createElement("input"); rangeInput.type = "range"; rangeInput.min = 300; rangeInput.max = 450; rangeInput.step = 5; rangeInput.value = localStorage.getItem("customChattingAreaWidth") ? localStorage.getItem("customChattingAreaWidth") : 380; rangeInput.style.width = "80px"; rangeInput.style.marginRight = "1px"; rangeInput.id = 'chatWidthSlider'; const rangeLabel = document.createElement("span"); rangeLabel.style.color = "#fff"; rangeLabel.style.fontSize = "12px"; const chatStyleEl = document.createElement("style"); chatStyleEl.id = 'custom-chat-width-style'; document.head.append(chatStyleEl); function changeChatAreaWidth(width) { chatStyleEl.textContent = ` #webplayer { --chatting_W: ${width}px; } /* 일반/스크린 모드 채팅창 너비 - fullScreen_mode 제외 */ body:not(.fullScreen_mode) #webplayer .chatting_area, body:not(.fullScreen_mode) #webplayer.chat_open .chatting_area, body:not(.fullScreen_mode) .screen_mode .chatting_area { width: var(--chatting_W) !important; flex: none !important; min-width: 0 !important; max-width: none !important; } /* VOD 일반/스크린 모드 채팅창 - fullScreen_mode 제외 */ body:not(.fullScreen_mode):not(.bottomChat) .vod #chatting_area { width: var(--chatting_W) !important; position: fixed; right: 0; left: auto; } /* --- 리스트 영역: 버튼 우측 강제 고정 패치 --- */ #list_viewer, #list_area { max-width: ${width}px !important; width: ${width}px !important; min-width: 0 !important; right: 0 !important; left: auto !important; bottom: auto !important; transform: none !important; box-sizing: border-box !important; overflow: hidden !important; } /* 1. 헤더: 버튼들의 기준점이 되도록 상대 좌표(relative) 설정 */ #list_viewer .area_header, #list_area .area_header { position: relative !important; display: block !important; width: 100% !important; height: 45px !important; /* SOOP 헤더 높이 보정 */ box-sizing: border-box !important; overflow: hidden !important; margin: 0 !important; padding: 0 !important; } /* 2. 우측 버튼 그룹: 헤더 우측 끝에 절대 좌표(absolute)로 강제 고정 📌 */ #list_viewer .area_header ul, #list_area .area_header ul { position: absolute !important; right: 15px !important; /* 우측 여백 */ top: 50% !important; transform: translateY(-50%) !important; display: flex !important; margin: 0 !important; padding: 0 !important; z-index: 10 !important; /* 무조건 맨 위에 보이게 */ } #list_viewer .area_header ul li, #list_area .area_header ul li { list-style: none !important; margin-left: 10px !important; /* 버튼 사이 간격 */ } /* 3. 좌측 타이틀: 버튼 공간(약 70px)을 침범하지 못하게 우측에 안전지대(padding) 설정 */ #list_viewer .area_header h2, #list_area .area_header h2 { display: block !important; width: 100% !important; box-sizing: border-box !important; padding: 0 70px 0 15px !important; /* 우측 70px(버튼자리 보호), 좌측 15px */ margin: 0 !important; line-height: 45px !important; overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; } #list_viewer .area_header h2 span, #list_area .area_header h2 span { display: inline-block !important; max-width: 100% !important; overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; vertical-align: middle !important; } /* 하단 리스트 영역들도 폭 100%로 묶어줌 */ #list_viewer .contents, #list_viewer .effective_box, #list_area .list_contents { width: 100% !important; min-width: 0 !important; } /* --- 기존 설정창 / 이모티콘 창 유지 --- */ .chat_layer.setting_chat { max-width: 280px !important; width: 100% !important; min-width: 0 !important; right: 0 !important; left: auto !important; top: 45px !important; bottom: auto !important; } .chat_layer.setting_chat .contents { width: 100% !important; box-sizing: border-box !important; } #emoticonContainer { max-width: ${width}px !important; width: 100% !important; min-width: 0 !important; left: 0 !important; right: auto !important; bottom: 100px !important; top: auto !important; transform: none !important; } #emoticonContainer .emoticon_area, #emoticonContainer .tab_area { width: 100% !important; min-width: 0 !important; } `; } rangeInput.addEventListener("input", () => { changeChatAreaWidth(rangeInput.value); localStorage.setItem("customChattingAreaWidth", rangeInput.value); }); sliderLi.appendChild(rangeInput); sliderLi.appendChild(rangeLabel); if (insertBeforeElement && insertBeforeElement.nextSibling) { ul.insertBefore(sliderLi, insertBeforeElement.nextSibling); } else { ul.appendChild(sliderLi); } const storedWidth = localStorage.getItem("customChattingAreaWidth") || 380; changeChatAreaWidth(storedWidth); rangeInput.value = storedWidth; // URL 변경 시 재삽입 observeUrlChanges(() => { setTimeout(() => { if (!document.getElementById('chatWidthSlider')) { setupChatResizable(); } }, 2000); }); }); // waitForElementAsync .then() 닫기 }; 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) { customLog.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) { customLog.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) { customLog.error('캡처 실패:', err); } }); // 우클릭: 즉시 다운로드 btn.addEventListener('contextmenu', (event) => { event.preventDefault(); // 기본 컨텍스트 메뉴 방지 try { // 인자를 true로 전달하여 호출 captureVideoFrame(true); } catch (err) { customLog.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) { customLog.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, .expandVODChat:not(.screen_mode,.fullScreen_mode) #sidebar { top: 0 !important; margin-top: 0 !important; min-height: 100vh !important; } .expandVODChat:not(.screen_mode,.fullScreen_mode) #webplayer #webplayer_contents .wrapping.side { padding: 0 !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초 타임아웃 if (!element) return; addStyleExpandVODChat(); makeExpandChatButton(element, 'expandVODChat'); // `await`로 받은 element를 사용 toggleExpandChatShortcut(); updateBodyClass('expandVODChat'); window.addEventListener('resize', debounce(() => updateBodyClass('expandVODChat'), 500)); } catch (error) { customLog.error("VOD 채팅 확장 기능 설정에 실패했습니다:", error.message); } }; const setupExpandLiveChatFeature = async () => { try { // 1. 첫 번째 조건: 채팅창 헤더가 나타날 때까지 기다립니다. const element = await waitForElementAsync('#chatting_area div.area_header > div.chat_title > ul'); if (!element) return; // 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) { customLog.error("setupExpandLiveChatFeature 실패:", error.message); } } const setupSettingButtonTopbar = async () => { const serviceUtilDiv = await waitForElementAsync('div.serviceUtil'); if (!serviceUtilDiv) return; addModalSettings(serviceUtilDiv); const openModalBtnDiv = await waitForElementAsync('#openModalBtn > button'); if (openModalBtnDiv) manageRedDot(openModalBtnDiv); }; /** * 컨트롤러 상태에 따라 타겟 요소의 가시성을 제어하는 함수. * 타겟의 원래 display 속성을 기억하여 복원합니다. * @param {object} options * @param {HTMLInputElement[]} options.controllers - 상태를 제어할 체크박스 요소들의 배열 * @param {HTMLElement[]} options.targets - 가시성이 제어될 요소들의 배열 */ function setupDependentVisibility(options) { const { controllers, targets } = options; if (!Array.isArray(controllers) || controllers.length === 0 || !Array.isArray(targets) || targets.length === 0) { customLog.error("필수 요소(controllers 배열, targets 배열)가 올바르게 전달되지 않았습니다."); return; } // 1. 함수가 처음 실행될 때 각 타겟의 원래 display 값을 data 속성에 저장 targets.forEach(target => { if (!target) return; // getComputedStyle로 CSS 파일에 정의된 display 값까지 가져옴 const originalDisplay = window.getComputedStyle(target).display; // 만약 처음부터 display: none; 이었다면, 보여줄 때를 대비해 'block'을 기본값으로 저장 target.dataset.originalDisplay = originalDisplay === 'none' ? 'block' : originalDisplay; }); const updateVisibility = () => { const isAnyControllerChecked = controllers.some(controller => controller.checked); targets.forEach(target => { if (!target) return; // 2. 보여줄 때는 저장해둔 원래 display 값을 사용하고, 숨길 때는 'none'으로 설정 target.style.display = isAnyControllerChecked ? target.dataset.originalDisplay : 'none'; }); }; controllers.forEach(controller => { controller.addEventListener('change', updateVisibility); }); // 초기 가시성 설정 updateVisibility(); }; // --- 리캡 관련 유틸리티 함수 --- // --- 데이터 공유 및 인증 관련 함수 --- /** * 인증 카드 UI를 생성하여 화면에 표시합니다. * @param {object} data - 공유받은 인증 데이터 객체 */ function renderShareCard(data) { const wrapper = document.getElementById('recap-content-wrapper'); const verifyContainer = document.getElementById('recap-verify-container'); // streamer의 'n'은 닉네임, 't'는 시청 시간(초)을 의미합니다. const streamerListHTML = data.s.map((streamer, index) => `
  • ${index + 1} ${streamer.n} ${formatSecondsToHM(streamer.t)}
  • `).join(''); const cardHTML = ` `; wrapper.innerHTML = cardHTML; // 인증 컨테이너는 숨기고, 결과 래퍼를 보여줌 verifyContainer.style.display = 'none'; wrapper.style.display = 'block'; } /** * '인증 데이터 공유' 버튼 클릭 이벤트 핸들러 */ async function handleShareClick() { const proofMessage = prompt("공유 시 본인 증명을 위해 사용할 '증명 메시지'를 입력하세요.", ""); if (proofMessage === null || proofMessage.trim() === '') { alert("증명 메시지가 입력되지 않아 취소되었습니다."); return; } const shareButton = document.getElementById('recap-share-button'); const originalText = shareButton.innerHTML; shareButton.innerHTML = '...'; shareButton.disabled = true; 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); let startDate, endDate; const today = new Date(); if (year === today.getFullYear() && month === today.getMonth() + 1) { startDate = new Date(year, month - 1, 1); endDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1); } 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' }, vod: { streamer: 'UserVodWatchTimeData' } }; let streamerData; if (selectedType === 'live' || selectedType === 'vod') { streamerData = await fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules[selectedType].streamer); } else { const [liveStreamer, vodStreamer] = await Promise.all([fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules.live.streamer), fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules.vod.streamer)]); streamerData = mergeData(liveStreamer, vodStreamer, 'streamer'); } if (streamerData.result !== 1) throw new Error("데이터를 가져올 수 없습니다."); const totalWatchTime = streamerData.data.broad_cast_info.data.cumulative_watch_time; const top4Streamers = (streamerData.data.chart.data_stack?.map(s => ({ n: s.bj_nick, t: s.data.reduce((a, b) => a + b, 0) })).filter(s => s.n !== '기타').sort((a, b) => b.t - a.t) || []).slice(0, 4); const shareablePayload = { v: 1, m: `${year}-${month}`, t: selectedType, w: totalWatchTime, s: top4Streamers, p: proofMessage }; const signature = await generateSignature(JSON.stringify(shareablePayload)); const finalData = { ...shareablePayload, h: signature }; const protectedString = protectData(finalData); if (!protectedString) throw new Error("인증 데이터 생성에 실패했습니다."); prompt("아래 문자열을 복사하여 공유하세요.", protectedString); } catch (error) { alert(`오류: ${error.message}`); } finally { shareButton.innerHTML = originalText; shareButton.disabled = false; } } /** * '인증 확인' 버튼 클릭 이벤트 핸들러 */ async function handleVerifyClick() { const input = document.getElementById('recap-verify-input'); const sharedString = input.value.trim(); if (!sharedString) { alert("인증 문자열을 붙여넣어 주세요."); return; } const restored = restoreData(sharedString); if (!restored || !restored.h) { alert("올바른 인증 데이터가 아닙니다."); return; } const { h, ...payload } = restored; const isValid = await verifySignature(h, JSON.stringify(payload)); if (!isValid) { alert("데이터가 변조되었거나 손상되었습니다. 인증에 실패했습니다."); return; } renderShareCard(payload); input.value = ''; } // --- 데이터 암호화/복호화 및 파일 처리 함수 --- // 데이터 보호를 위한 비밀 키 (이 값은 스크립트 내부에 고정됩니다) const RECAP_SECRET_KEY = "SoopRecapBackupKey"; /** * 데이터를 보호(난독화) 처리합니다. (UTF-8 호환 버전) * @param {object} data - 보호할 데이터 객체 * @returns {string} Base64로 인코딩된 보호된 문자열 */ function protectData(data) { try { const jsonString = JSON.stringify(data); const encoder = new TextEncoder(); // 문자열을 UTF-8 바이트로 변환 const dataBytes = encoder.encode(jsonString); const secretKeyBytes = encoder.encode(RECAP_SECRET_KEY); // 각 바이트에 대해 XOR 암호화 수행 const protectedBytes = new Uint8Array(dataBytes.length); for (let i = 0; i < dataBytes.length; i++) { protectedBytes[i] = dataBytes[i] ^ secretKeyBytes[i % secretKeyBytes.length]; } // 바이트 배열을 btoa가 처리할 수 있는 바이너리 문자열로 변환 let binaryString = ''; protectedBytes.forEach((byte) => { binaryString += String.fromCharCode(byte); }); // Base64 인코딩으로 마무리 return btoa(binaryString); } catch (e) { customLog.error("데이터 보호 처리 실패:", e); return null; } } /** * 보호된 데이터를 원래 객체로 복원합니다. (UTF-8 호환 버전) * @param {string} protectedText - 보호된 텍스트 * @returns {object|null} 복원된 데이터 객체 또는 실패 시 null */ function restoreData(protectedText) { try { // Base64 디코딩으로 바이너리 문자열을 얻음 const binaryString = atob(protectedText); if (binaryString.length === 0) return null; const encoder = new TextEncoder(); const secretKeyBytes = encoder.encode(RECAP_SECRET_KEY); // 바이너리 문자열을 바이트 배열로 변환하며 동시에 XOR 복호화 수행 const restoredBytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { const charCode = binaryString.charCodeAt(i); restoredBytes[i] = charCode ^ secretKeyBytes[i % secretKeyBytes.length]; } // 복호화된 UTF-8 바이트 배열을 다시 문자열로 변환 const decoder = new TextDecoder(); const jsonString = decoder.decode(restoredBytes); // JSON 객체로 파싱 return JSON.parse(jsonString); } catch (e) { customLog.error("데이터 복원 실패. 파일이 손상되었거나 잘못된 파일일 수 있습니다.", e); return null; } } const RECAP_SIGNING_KEY = "Soop-Recap-Verification-Secret-Key-!@#$"; async function generateSignature(dataString) { const encoder = new TextEncoder(); const keyData = encoder.encode(RECAP_SIGNING_KEY); const data = encoder.encode(dataString); const key = await crypto.subtle.importKey("raw", keyData, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const signatureBuffer = await crypto.subtle.sign("HMAC", key, data); const hashArray = Array.from(new Uint8Array(signatureBuffer)); return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); } async function verifySignature(signatureHex, dataString) { const encoder = new TextEncoder(); const keyData = encoder.encode(RECAP_SIGNING_KEY); const data = encoder.encode(dataString); const signatureBytes = new Uint8Array(signatureHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16))); const key = await crypto.subtle.importKey("raw", keyData, { name: "HMAC", hash: "SHA-256" }, false, ["verify"]); return await crypto.subtle.verify("HMAC", key, signatureBytes, data); } /** * 데이터 내보내기 버튼 클릭 이벤트 핸들러 */ async function handleExportClick() { const exportButton = document.getElementById('recap-export-button'); const originalText = exportButton.innerHTML; exportButton.innerHTML = '...'; exportButton.disabled = true; try { // 현재 선택된 조건으로 데이터를 새로 가져옴 const userInfo = await getUserInfo(); 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 [year, month] = monthSelector.value.split('-').map(Number); // (handleFetchButtonClick의 데이터 가져오는 로직과 동일) let startDate, endDate; const today = new Date(); if (year === today.getFullYear() && month === today.getMonth() + 1) { 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); // ❗❗ [오류 수정] 누락되었던 modules 객체를 여기에 정의합니다. const modules = { live: { streamer: 'UserLiveWatchTimeData', category: 'UserLiveSearchKeywordData' }, vod: { streamer: 'UserVodWatchTimeData', category: 'UserVodSearchKeywordData' } }; let streamerData, categoryData; if (selectedType === 'live' || selectedType === 'vod') { [streamerData, categoryData] = await Promise.all([ fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules[selectedType].streamer), fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules[selectedType].category), ]); } else { // combined const [liveStreamer, liveCategory, vodStreamer, vodCategory] = 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), ]); streamerData = mergeData(liveStreamer, vodStreamer, 'streamer'); categoryData = mergeData(liveCategory, vodCategory, 'category'); } if (streamerData.result !== 1) throw new Error("데이터를 가져올 수 없습니다."); const dataToProtect = { streamerData, categoryData, source: { year, month, type: selectedType, typeText: selectedTypeText, user: userInfo.nick } }; const protectedContent = protectData(dataToProtect); if (!protectedContent) throw new Error("데이터 암호화에 실패했습니다."); // 파일 다운로드 실행 const blob = new Blob([protectedContent], { type: 'text/plain' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `soop-recap-backup-${year}-${month}-${selectedType}.txt`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(link.href); } catch (error) { alert(`내보내기 오류: ${error.message}`); } finally { exportButton.innerHTML = originalText; exportButton.disabled = false; } } /** * 파일 가져오기(input) 변경 이벤트 핸들러 * @param {Event} event */ async function handleImportChange(event) { const file = event.target.files[0]; if (!file) return; const loader = document.getElementById('recap-loader'); const wrapper = document.getElementById('recap-content-wrapper'); loader.style.display = 'block'; wrapper.innerHTML = ''; const reader = new FileReader(); reader.onload = async (e) => { const restored = restoreData(e.target.result); if (restored && restored.streamerData && restored.categoryData) { try { const userInfo = await getUserInfo(); if (userInfo.nick !== restored.source.user) { if (!confirm(`이 파일은 '${restored.source.user}' 님의 데이터입니다. 현재 로그인된 '${userInfo.nick}' 님과 다릅니다. 계속 진행하시겠습니까?`)) { throw new Error("사용자 정보가 일치하지 않아 취소되었습니다."); } } const monthSelector = document.getElementById('recap-month-selector'); const typeSelector = document.getElementById('recap-type-selector'); // --- ❗ [수정] 이전 백업 선택지(찌꺼기) 완벽히 정리 --- // 1. ID로 추가된 임시 백업 옵션 제거 const tempOption = document.getElementById('recap-backup-option-temp'); if (tempOption) tempOption.remove(); // 2. 기존 옵션에 추가됐던 (백업) 표시와 클래스 제거 const modifiedOption = monthSelector.querySelector('option.backup-option'); if (modifiedOption) { modifiedOption.textContent = modifiedOption.textContent.replace(' (백업)', ''); modifiedOption.classList.remove('backup-option'); } // --- 정리 끝 --- const importedValue = `${restored.source.year}-${restored.source.month}`; const optionExists = Array.from(monthSelector.options).find(opt => opt.value === importedValue); if (optionExists) { // 원래 목록에 있는 월이면, 텍스트와 클래스만 수정 optionExists.textContent += ' (백업)'; optionExists.classList.add('backup-option'); } else { // 원래 목록에 없는 월이면, 고유 ID를 가진 새 옵션으로 추가 const newOption = document.createElement('option'); newOption.id = 'recap-backup-option-temp'; // 나중에 쉽게 찾아서 제거하기 위한 ID newOption.value = importedValue; newOption.textContent = `${restored.source.year}년 ${restored.source.month}월 (백업)`; newOption.classList.add('backup-option'); monthSelector.prepend(newOption); } monthSelector.value = importedValue; typeSelector.value = restored.source.type; const categoryImages = await getCategoryImageMap(); await renderAll(restored.streamerData, restored.categoryData, userInfo, categoryImages); document.getElementById('recap-verify-container').style.display = 'none'; // [추가] wrapper.style.display = 'block'; } catch (renderError) { wrapper.innerHTML = `

    가져온 데이터 렌더링 오류: ${renderError.message}

    `; } } else { wrapper.innerHTML = `

    파일을 처리할 수 없습니다. 파일이 손상되었거나 올바른 리캡 백업 파일이 아닙니다.

    `; } loader.style.display = 'none'; wrapper.style.display = 'block'; }; reader.onerror = () => { wrapper.innerHTML = `

    파일을 읽는 중 오류가 발생했습니다.

    `; loader.style.display = 'none'; wrapper.style.display = 'block'; }; reader.readAsText(file); event.target.value = ''; } /** * 드롭다운 메뉴에 추가된 백업 관련 옵션을 모두 정리하여 초기 상태로 되돌립니다. * @param {HTMLElement} monthSelector - 월 선택 select 요소 */ function cleanupBackupOptions(monthSelector) { const backupOption = monthSelector.querySelector('option.backup-option'); if (!backupOption) return; // 정리할 옵션이 없으면 종료 const tempOption = document.getElementById('recap-backup-option-temp'); if (tempOption) tempOption.remove(); const modifiedOption = monthSelector.querySelector('option.backup-option'); if (modifiedOption) { modifiedOption.textContent = modifiedOption.textContent.replace(' (백업)', ''); modifiedOption.classList.remove('backup-option'); } } /** * '인증 확인 UI'를 보여주는 이벤트 핸들러 */ function handleShowVerifyClick() { const verifyContainer = document.getElementById('recap-verify-container'); const contentWrapper = document.getElementById('recap-content-wrapper'); const monthSelector = document.getElementById('recap-month-selector'); // 1. 기존에 표시되던 데이터(리캡, 인증 카드 등)를 숨기고 내용 비우기 contentWrapper.style.display = 'none'; contentWrapper.innerHTML = ''; // 2. 인증 확인 UI를 표시 verifyContainer.style.display = 'block'; document.getElementById('recap-verify-input').focus(); // 입력창에 바로 포커스 // 3. 다른 작업을 하기 전에 드롭다운 메뉴를 초기 상태로 정리 cleanupBackupOptions(monthSelector); } /** * 1등 카드 중앙에서 축포 애니메이션을 실행합니다. (z-index 및 전체 화면 방식 적용) * @param {HTMLElement} targetCard - 애니메이션의 기준이 될 1등 카드 요소 * @param {string} baseColorHex - 축포의 기본 색상 (Hex 코드) */ function triggerVictoryConfetti(targetCard, baseColorHex) { if (typeof confetti !== 'function') return; // 카드의 위치를 계산하여 축포 발사 원점을 설정 const rect = targetCard.getBoundingClientRect(); const origin = { x: (rect.left + rect.width / 2) / window.innerWidth, y: (rect.top + rect.height / 2) / window.innerHeight }; const colors = [baseColorHex, '#fff']; confetti({ particleCount: 150, spread: 100, origin: origin, colors: colors, scalar: 1.2, ticks: 400, zIndex: 10001 }); setTimeout(() => { confetti({ particleCount: 200, spread: 30, origin: origin, colors: colors, angle: 90, startVelocity: 65, scalar: 1.3, ticks: 400, zIndex: 10001 }); }, 300); } /** * '개근 달성' 카드 중앙에서 축포 애니메이션을 실행합니다. (위치 유효성 검사 추가) * @param {HTMLElement} targetElement - 애니메이션의 기준이 될 '개근 달성' 카드 요소 */ function triggerAttendanceConfetti(targetElement) { if (typeof confetti !== 'function') return; const rect = targetElement.getBoundingClientRect(); // ✨ 위치 정보가 유효하지 않으면(너비가 0이면) 함수를 즉시 종료하여 오류를 방지합니다. if (!rect || rect.width === 0) { customLog.error("Confetti: Target element's position could not be determined."); return; } const origin = { x: (rect.left + rect.width / 2) / window.innerWidth, y: (rect.top + rect.height / 2) / window.innerHeight }; const colors = ['#FFD700', '#C0C0C0', '#FFFFFF']; confetti({ particleCount: 250, spread: 90, origin: origin, colors: colors, startVelocity: 30, gravity: 0.8, ticks: 500, zIndex: 10001 }); } /** * 클릭 시 1등 카드 아바타 중앙에서 3가지 효과 중 하나를 랜덤으로 발사합니다. * @param {HTMLElement} targetElement - 발사 원점이 될 아바타 요소 * @param {string} baseColorHex - 축포의 기본 색상 (Hex 코드) */ function triggerClickConfetti(targetElement, baseColorHex) { if (typeof confetti !== 'function') return; const rect = targetElement.getBoundingClientRect(); const origin = { x: (rect.left + rect.width / 2) / window.innerWidth, y: (rect.top + rect.height / 2) / window.innerHeight }; const colors = [baseColorHex, '#FFFFFF']; // --- 3가지 축포 효과 프리셋 정의 --- // 1. 넓게 퍼지는 효과 (360도 폭발) const wideBurst = { particleCount: 150, spread: 360, startVelocity: 40, scalar: 1.1, ticks: 300 }; // 2. 높이 솟구치는 효과 (기존 효과) const highFountain = { particleCount: 200, spread: 30, angle: 90, startVelocity: 75, scalar: 1.3, ticks: 400 }; // 3. 중간 부채꼴 효과 const mediumFan = { particleCount: 150, spread: 120, angle: 90, startVelocity: 60, scalar: 1.2, ticks: 350 }; // 프리셋 배열 const effects = [wideBurst, highFountain, mediumFan]; // 0, 1, 2 중 하나의 숫자를 무작위로 선택 const randomIndex = Math.floor(Math.random() * effects.length); // 선택된 효과를 가져옴 const randomEffect = effects[randomIndex]; // 공통 옵션과 선택된 효과 옵션을 합쳐서 실행 confetti({ origin: origin, colors: colors, zIndex: 10001, ...randomEffect // 선택된 효과의 옵션을 여기에 적용 }); } /** * RGB 문자열 "rgb(r, g, b)"를 {r, g, b} 객체로 파싱합니다. * @param {string} rgbString - 파싱할 RGB 문자열 * @returns {{r: number, g: number, b: number}|null} */ function parseRgb(rgbString) { const match = rgbString.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); if (!match) return null; return { r: parseInt(match[1], 10), g: parseInt(match[2], 10), b: parseInt(match[3], 10) }; } /** * RGB 색상을 HSL 색상으로 변환합니다. * @param {number} r - Red (0-255) * @param {number} g - Green (0-255) * @param {number} b - Blue (0-255) * @returns {{h: number, s: number, l: number}} - Hue(0-360), Saturation(0-1), Lightness(0-1) */ function rgbToHsl(r, g, b) { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); let h = 0, s, l = (max + min) / 2; if (max === min) { h = s = 0; // achromatic } else { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h /= 6; } return { h: h * 360, s: s, l: l }; } /** * HSL 색상을 RGB 색상으로 변환합니다. * @param {number} h - Hue (0-360) * @param {number} s - Saturation (0-1) * @param {number} l - Lightness (0-1) * @returns {string} - "rgb(r, g, b)" 형식의 문자열 */ function hslToRgbString(h, s, l) { let r, g, b; if (s === 0) { r = g = b = l; // achromatic } else { const hue2rgb = (p, q, t) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; }; const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; r = hue2rgb(p, q, h / 360 + 1 / 3); g = hue2rgb(p, q, h / 360); b = hue2rgb(p, q, h / 360 - 1 / 3); } return `rgb(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)})`; } /** * 단일 RGB 색상 문자열로부터 '강제 보정된' 밝은 유사색 그라디언트를 생성합니다. * @param {string} rgbString - "rgb(r, g, b)" 형식의 색상 문자열 * @returns {string} - CSS linear-gradient 문자열 */ function createGradientFromRgb(rgbString) { const rgb = parseRgb(rgbString); if (!rgb) return 'linear-gradient(135deg, #888, #777)'; const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); // --- 색상 보정 로직 --- // 1. 채도(Saturation)가 너무 낮으면(0.6 미만) 0.6으로 강제 보정합니다. const vividSaturation = Math.max(hsl.s, 0.65); // 2. 밝기(Lightness)를 고정하여 항상 밝은 느낌을 유지합니다. const vividLightness = 0.65; // 보정된 채도와 밝기를 사용하여 첫 번째 색상을 다시 만듭니다. const color1 = hslToRgbString(hsl.h, vividSaturation, vividLightness); // 유사색의 색조를 계산합니다. const analogousHue = (hsl.h + 30) % 360; // 보정된 채도와 밝기를 사용하여 두 번째 색상도 만듭니다. const color2 = hslToRgbString(analogousHue, vividSaturation, vividLightness); return `linear-gradient(135deg, ${color1}, ${color2})`; } /** * 이미지 URL에서 평균 색상 코드를 추출하는 함수 (비동기) * @param {string} imageUrl - 분석할 이미지의 URL * @returns {Promise} - 평균 색상의 16진수 코드 (예: '#RRGGBB') 또는 실패 시 null */ function getAverageColor(imageUrl) { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'Anonymous'; // CORS 이슈 방지를 위해 필수 img.src = imageUrl; img.onload = () => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); try { const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imageData.data; let r = 0, g = 0, b = 0; for (let i = 0; i < data.length; i += 4) { r += data[i]; g += data[i + 1]; b += data[i + 2]; } const pixelCount = data.length / 4; const avgR = Math.round(r / pixelCount); const avgG = Math.round(g / pixelCount); const avgB = Math.round(b / pixelCount); // 16진수 코드로 변환 const hexCode = `#${(1 << 24 | avgR << 16 | avgG << 8 | avgB).toString(16).slice(1)}`; resolve(hexCode); } catch (e) { // getImageData에서 CORS 오류 발생 시 customLog.error("평균 색상 추출 실패 (CORS 가능성):", e); reject(null); } }; img.onerror = () => { customLog.error("이미지 로드 실패:", imageUrl); reject(null); // 이미지 로드 실패 시 }; }); } /** * 16진수 색상 코드를 RGB 객체로 변환하는 함수 * @param {string} hex - 16진수 색상 코드 (예: '#ffaa00') * @returns {{r: number, g: number, b: number}|null} - RGB 값 객체 또는 변환 실패 시 null */ function hexToRgb(hex) { if (!hex || hex.length < 4) return null; const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) } : null; } 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; let isPerfectAttendance = false; const tableDataAttendance = streamerData?.table1?.data; // 출석 데이터가 있을 경우에만 개근 여부 계산 if (tableDataAttendance && tableDataAttendance.length > 0) { // 데이터의 첫 번째 날짜를 기준으로 Date 객체 생성 (예: "2024-06-01") const dataDate = new Date(tableDataAttendance[0].day); const year = dataDate.getFullYear(); const month = dataDate.getMonth(); // 0부터 시작 (e.g., 6월은 5) // 데이터가 속한 월의 마지막 날짜를 가져와 총일수 계산 const daysInMonth = new Date(year, month + 1, 0).getDate(); isPerfectAttendance = visitedDays >= daysInMonth; } 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.com/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'; const attendanceCardClass = isPerfectAttendance ? 'stat-card days perfect-attendance' : 'stat-card days'; const attendanceLabel = isPerfectAttendance ? '🎉 개근 달성' : '이 달의 출석'; keyStatsGrid.innerHTML = `
    평균 ${formatSecondsToHM(stats.average_watch_time)}
    ${formatSecondsToHM(stats.cumulative_watch_time).replace(/(\d+)([가-힣]+)/g, '$1$2')}
    ${attendanceLabel}
    ${visitedDays}
    `; wrapper.appendChild(keyStatsGrid); if (isPerfectAttendance) { setTimeout(() => { const attendanceCard = wrapper.querySelector('.perfect-attendance'); if (attendanceCard) { triggerAttendanceConfetti(attendanceCard); } }, 2000); } // --- 추가 끝 --- 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 (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])}
    ` : ''}
    `; // ✨ --- 각 스트리머 카드에 평균 색상 데이터 저장 및 테두리/효과 적용 --- for (let i = 0; i < top4Streamers.length; i++) { const streamer = top4Streamers[i]; const avatarUri = avatarDataUris[i]; if (!streamer || !avatarUri) continue; const rank = i + 1; const card = wrapper.querySelector(`.streamer-card[data-rank="${rank}"]`); if (!card) continue; try { const avgColorHex = await getAverageColor(avatarUri); if (!avgColorHex) continue; const rgb = hexToRgb(avgColorHex); if (rgb) { // 1. 스크린샷을 위해 평균 색상 정보를 data 속성으로 저장 (모든 카드) card.dataset.avgColor = `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`; // 2. 프로필 아바타를 찾아 테두리 색상 변경 (모든 카드) const avatar = card.querySelector('.streamer-card-avatar'); if (avatar) { avatar.style.borderColor = avgColorHex; } // 3. 1등 스트리머에게만 특별 시각 효과(그림자, 글로우) 추가 if (rank === 1) { card.style.cursor = 'pointer'; card.addEventListener('click', () => { if (avatar) { triggerClickConfetti(avatar, avgColorHex); } }); if (avatar) { const glowColorStart = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.5)`; const glowColorEnd = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.7)`; avatar.style.setProperty('--rank1-glow-color-start', glowColorStart); avatar.style.setProperty('--rank1-glow-color-end', glowColorEnd); } card.style.setProperty('--shine-color-solid', `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`); card.style.setProperty('--shine-color-glow', `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`); setTimeout(() => { if (avatar) { triggerVictoryConfetti(avatar, avgColorHex); } }, 1000); } } } catch (error) { customLog.error(`${rank}등 스트리머 평균 색상 적용 실패:`, error); } } 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 = ['#a95abf', '#5dade2', '#e74c3c', '#1abc9c', '#f1c40f', '#95a5a6', '#e67e22', '#e74c3c', '#2ecc71', '#f39c12']; 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 = 'block'; const tableData = streamerData?.table1; // --- 캘린더 히트맵 시작 --- if (tableData?.data) { // 1. 캘린더 전용 CSS 주입 if (!document.getElementById('recap-advanced-calendar-style')) { const style = document.createElement('style'); style.id = 'recap-advanced-calendar-style'; style.textContent = ` .calendar-wrapper { margin-top: 15px; padding: 15px; background: #2e2e33; border-radius: 8px; } .calendar-header { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; text-align: center; font-size: 12px; color: #aaa; margin-bottom: 10px; } .calendar-header .sunday { color: #ff6b6b; } .calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; } .calendar-day { aspect-ratio: 1 / 1; background: #3a3a40; border-radius: 6px; position: relative; overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; transition: transform 0.1s ease; } .calendar-day.empty { background: transparent; box-shadow: none; border: none; } .calendar-day .date { position: absolute; top: 4px; left: 6px; font-size: 14px; font-weight: bold; color: #efeff1; z-index: 3; } .calendar-day.no-data { background: #26262a; } .calendar-day.no-data .date { color: #555; font-weight: normal; } .calendar-avatar-container { position: absolute; top: 0; bottom: 0; left: 0; right: 0; /* 🌟 상하좌우를 0으로 잡고 */ margin: auto; /* 🌟 margin: auto를 주면 transform 없이 완벽히 중앙에 옵니다 */ width: 46px; height: 46px; /* (사용자님이 원하시는 크기로 맞추세요) */ border-radius: 50%; border: 1px solid #ffffff; overflow: hidden; z-index: 2; background: #1f1f23; box-shadow: 0 2px 4px rgba(0,0,0,0.5); } .calendar-avatar { width: 100%; height: 100%; object-fit: cover; } .calendar-bar { width: 100%; background: linear-gradient(to top, #3498db, #2980b9); opacity: 0.85; z-index: 1; } .calendar-day.has-data:hover { transform: scale(1.1); z-index: 10; cursor: pointer; box-shadow: 0 0 10px rgba(52, 152, 219, 0.5); border: 1px solid #3498db; } `; document.head.appendChild(style); } const monthSelector = document.getElementById('recap-month-selector'); let year, month; if (monthSelector && monthSelector.value) { const [yearStr, monthStr] = monthSelector.value.split('-'); year = parseInt(yearStr, 10); month = parseInt(monthStr, 10); } else { const dataDate = new Date(tableData.data[0].day); year = dataDate.getFullYear(); month = dataDate.getMonth() + 1; } const calendarWrapper = document.createElement('div'); calendarWrapper.className = 'calendar-wrapper'; const daysOfWeek = ['일', '월', '화', '수', '목', '금', '토']; const headerRow = document.createElement('div'); headerRow.className = 'calendar-header'; daysOfWeek.forEach(day => { const dayEl = document.createElement('div'); dayEl.textContent = day; if (day === '일') dayEl.className = 'sunday'; headerRow.appendChild(dayEl); }); calendarWrapper.appendChild(headerRow); const grid = document.createElement('div'); grid.className = 'calendar-grid'; const firstDayOfMonth = new Date(year, month - 1, 1).getDay(); const daysInMonth = new Date(year, month, 0).getDate(); const dataMap = new Map(); let maxSeconds = 0; tableData.data.forEach(row => { const dayNum = parseInt(row.day.split('-')[2], 10); const seconds = parseHMSToSeconds(row.total_watch_time); if (seconds > maxSeconds) maxSeconds = seconds; dataMap.set(dayNum, { timeStr: row.total_watch_time, seconds: seconds }); }); maxSeconds = Math.max(maxSeconds, 1); const dayTopStreamers = new Map(); if (streamerData.chart && streamerData.chart.data_stack) { for (let d = 1; d <= daysInMonth; d++) { dayTopStreamers.set(d, { nick: null, seconds: 0 }); } streamerData.chart.data_stack.forEach(streamer => { if (streamer.bj_nick === '기타') return; streamer.data.forEach((seconds, idx) => { const dayNum = idx + 1; if (dayNum > daysInMonth) return; const currentMax = dayTopStreamers.get(dayNum); if (seconds > currentMax.seconds && seconds > 60) { dayTopStreamers.set(dayNum, { nick: streamer.bj_nick, seconds: seconds }); } }); }); } for (let i = 0; i < firstDayOfMonth; i++) { const emptyCell = document.createElement('div'); emptyCell.className = 'calendar-day empty'; grid.appendChild(emptyCell); } for (let d = 1; d <= daysInMonth; d++) { const cell = document.createElement('div'); cell.className = 'calendar-day'; const data = dataMap.get(d); if (data && data.seconds > 0) { cell.classList.add('has-data'); const [h, m, s] = data.timeStr.split(':').map(Number); let percent = (data.seconds / maxSeconds) * 100; if (percent < 8) percent = 8; let tooltipPart = []; if (h > 0) tooltipPart.push(`${h}시간`); if (m > 0) tooltipPart.push(`${m}분`); if (s > 0 || tooltipPart.length === 0) tooltipPart.push(`${s}초`); const topStreamer = dayTopStreamers.get(d); let topStreamerInfo = ''; if (topStreamer && topStreamer.nick) { topStreamerInfo = `\n최다 시청: ${topStreamer.nick} (${formatSecondsToHM(topStreamer.seconds)})`; } cell.title = `${d}일 시청 시간: ${tooltipPart.join(' ')}${topStreamerInfo}`; let avatarHtml = ''; if (topStreamer && topStreamer.nick) { avatarHtml = `
    `; // 메인 스크립트의 이미지 로드 함수 호환 getStreamerProfileUrl(topStreamer.nick).then(url => { if (url) { imageToDataUri(url).then(base64 => { if (base64) { const container = cell.querySelector(`.calendar-avatar-container[data-nick="${topStreamer.nick}"]`); if (container) { container.innerHTML = `${topStreamer.nick}`; } } }); } }); } cell.innerHTML = `
    ${d}
    ${avatarHtml}
    `; } else { cell.innerHTML = `
    ${d}
    `; cell.classList.add('no-data'); cell.title = `${d}일 시청 기록 없음`; } grid.appendChild(cell); } calendarWrapper.appendChild(grid); dailyStatsContainer.appendChild(calendarWrapper); } else { dailyStatsContainer.innerHTML = '

    일별 통계 데이터가 없습니다.

    '; } // --- 캘린더 히트맵 끝 --- 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' } } } } })); } 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' } } } } })); } } 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)); if (type === 'streamer') { const lInfo = liveData.data.broad_cast_info?.data || { cumulative_watch_time: 0, top_watch_time: 0 }; const vInfo = vodData.data.broad_cast_info?.data || { cumulative_watch_time: 0, top_watch_time: 0 }; if (merged.data.broad_cast_info && merged.data.broad_cast_info.data) { merged.data.broad_cast_info.data.cumulative_watch_time = (lInfo.cumulative_watch_time || 0) + (vInfo.cumulative_watch_time || 0); merged.data.broad_cast_info.data.top_watch_time = Math.max(lInfo.top_watch_time || 0, vInfo.top_watch_time || 0); } // 🌟 버그 수정: Live와 VOD 배열 길이가 다를 때 꼬리가 잘려나가지 않도록 완벽 병합 const sMap = new Map(); const mergeStack = (stack) => { stack?.forEach(s => { if (s.bj_nick !== '기타') { if (!sMap.has(s.bj_nick)) { sMap.set(s.bj_nick, [...s.data]); // 배열 복사 } else { const existing = sMap.get(s.bj_nick); // 두 배열 중 더 긴 길이를 기준으로 잡음! const maxLen = Math.max(s.data.length, existing.length); const newData = Array.from({ length: maxLen }, (_, idx) => { return (s.data[idx] || 0) + (existing[idx] || 0); }); sMap.set(s.bj_nick, newData); } } }); }; mergeStack(liveData.data.chart?.data_stack); mergeStack(vodData.data.chart?.data_stack); if (merged.data.chart) { merged.data.chart.data_stack = Array.from(sMap.entries()).map(([n, d]) => ({ bj_nick: n, data: d })); } // 날짜별 데이터(table1) 병합 const dMap = new Map(); liveData.data.table1?.data?.forEach(row => { dMap.set(row.day, parseHMSToSeconds(row.total_watch_time)); }); vodData.data.table1?.data?.forEach(row => { dMap.set(row.day, (dMap.get(row.day) || 0) + parseHMSToSeconds(row.total_watch_time)); }); if (merged.data.table1) { merged.data.table1.data = Array.from(dMap.entries()) .sort((a, b) => a[0].localeCompare(b[0])) .map(([d, t]) => ({ day: d, total_watch_time: formatSecondsToHHMMSS(t) })); } } else if (type === 'category') { const cMap = new Map(); liveData.data.table2?.data?.forEach(c => cMap.set(c.skey, parseInt(c.cnt))); vodData.data.table2?.data?.forEach(c => cMap.set(c.skey, (cMap.get(c.skey) || 0) + parseInt(c.cnt))); if (merged.data.table2) { merged.data.table2.data = Array.from(cMap.entries()) .sort((a, b) => b[1] - a[1]) .map(([k, v], i) => ({ rank: i + 1, skey: k, cnt: String(v) })); } } return merged; } // --- 이벤트 핸들러 함수 --- async function handleFetchButtonClick() { const monthSelector = document.getElementById('recap-month-selector'); const selectedOption = monthSelector.options[monthSelector.selectedIndex]; if (selectedOption && selectedOption.classList.contains('backup-option')) { alert('백업 데이터가 선택된 상태에서는 서버에 데이터를 조회할 수 없습니다.\n다른 월을 선택한 후 다시 시도해 주세요.'); return; } // --- ❗ [수정] 백업 선택지(찌꺼기) 완벽히 정리 --- // 1. ID로 추가된 임시 백업 옵션 제거 const tempOption = document.getElementById('recap-backup-option-temp'); if (tempOption) tempOption.remove(); // 2. 기존 옵션에 추가됐던 (백업) 표시와 클래스 제거 const modifiedOption = monthSelector.querySelector('option.backup-option'); if (modifiedOption) { modifiedOption.textContent = modifiedOption.textContent.replace(' (백업)', ''); modifiedOption.classList.remove('backup-option'); } // --- 정리 끝 --- 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 selectedType = typeSelector.value; const [year, month] = monthSelector.value.split('-').map(Number); // (이하 기존 코드와 동일) let startDate, endDate; const today = new Date(); if (year === today.getFullYear() && month === today.getMonth() + 1) { 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' } }; const categoryImages = await getCategoryImageMap(); let streamerData, categoryData; if (selectedType === 'live' || selectedType === 'vod') { [streamerData, categoryData] = await Promise.all([ fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules[selectedType].streamer), fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules[selectedType].category), ]); } else { // combined const [liveStreamer, liveCategory, vodStreamer, vodCategory] = 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), ]); streamerData = mergeData(liveStreamer, vodStreamer, 'streamer'); categoryData = mergeData(liveCategory, vodCategory, 'category'); } if (streamerData.result === 1 && categoryData.result === 1) { await renderAll(streamerData, categoryData, userInfo, categoryImages); document.getElementById('recap-verify-container').style.display = 'none'; // [추가] } else { wrapper.innerHTML = `

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

    `; } } catch (error) { customLog.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 selectedTypeText = typeSelector.options[typeSelector.selectedIndex].text; const screenshotTitle = document.createElement('div'); screenshotTitle.id = 'screenshot-title-temp'; screenshotTitle.textContent = `${monthSelector.options[monthSelector.selectedIndex].text} 시청 요약 (${selectedTypeText})`; // --- 스크린샷 전처리 --- const fallbackGradient = 'linear-gradient(135deg, #6e45e2, #88d3ce)'; // 폴백 그라디언트 cardElements.forEach((el) => { const bgChild = el.querySelector('.streamer-card-bg'); originalCardStyles.push({ el, background: el.style.background, bgChild, childDisplay: bgChild?.style.display }); // ✨ 각 카드에 저장된 data-avg-color 속성을 읽어옵니다. const avgColor = el.dataset.avgColor; // ✨ 해당 색상으로 그라디언트를 생성하거나, 실패 시 폴백 그라디언트를 적용합니다. el.style.background = avgColor ? createGradientFromRgb(avgColor) : fallbackGradient; 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')}`; const selectedType = typeSelector.value; link.download = `recap-${selectedType}-${timestamp}.png`; link.href = canvas.toDataURL("image/png"); link.click(); } catch (err) { customLog.error("스크린샷 생성 오류:", err); alert("스크린샷 생성에 실패했습니다."); } finally { // --- 모든 변경사항 원래대로 복구 --- button.innerHTML = originalButtonContent; button.disabled = false; modalPanel.style.height = originalPanelHeight; modalBody.style.overflowY = originalBodyOverflow; originalCardStyles.forEach(item => { item.el.style.background = item.background; if (item.bgChild) item.bgChild.style.display = item.childDisplay; }); if (options.hideProfile && profileHeader) { profileHeader.style.display = originalProfileDisplay; } screenshotTitle.remove(); } } function createRecapModule() { // 이미 UI가 생성되었다면 함수를 즉시 종료하여 중복 생성을 방지합니다. if (recapModalBackdrop) { customLog.warn("Recap module UI is already created. Skipping creation."); return; } // --- 1. 스타일(CSS) 주입 --- GM_addStyle(` /* ================================================================= 모달 (Modal) 기본 스타일 ================================================================= */ #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-modal-header-buttons { display: flex; align-items: center; gap: 10px; } /* 헤더 아이콘 버튼 공통 스타일 */ .recap-modal-header-buttons button, .recap-modal-header-buttons label { background: none; border: none; color: #efeff1; font-size: 22px; cursor: pointer; width: 36px; height: 36px; display: grid; place-items: center; border-radius: 50%; padding: 0; } .recap-modal-header-buttons button:hover, .recap-modal-header-buttons label:hover { background-color: #2e2e33; } #recap-import-input { display: none; } .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; } #screenshot-title-temp { font-size: 24px; font-weight: bold; text-align: center; margin-bottom: 20px; color: #efeff1; } /* ================================================================= 콘텐츠 공통 스타일 ================================================================= */ .section-title { font-size: 20px; font-weight: bold; margin-bottom: 15px; } .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; } .recap-profile-header { display: flex; align-items: center; gap: 20px; margin-bottom: 20px; } .recap-profile-header .profile-pic { width: 70px; height: 70px; border-radius: 50%; border: 3px solid #5dade2; } .recap-profile-header .profile-name { font-size: 24px; font-weight: bold; } /* ================================================================= ✨ 재사용 가능한 효과 (Shine Effect) ================================================================= */ @keyframes shine { 100% { left: 200%; } } /* ================================================================= ❓ 도움말 모달 애니메이션 ================================================================= */ @keyframes recapFadeIn { from { opacity: 0; transform: translate(-50%, -55%); } to { opacity: 1; transform: translate(-50%, -50%); } } /* 그림자 효과가 필요한 요소에 이 클래스를 추가합니다. */ .shine-effect { /* CSS 변수를 사용하여 그림자 색상 정의 */ --shine-color-solid: rgb(181, 164, 46); --shine-color-glow: rgb(255, 202, 97); filter: drop-shadow(0px 0px 6px var(--shine-color-solid)) drop-shadow(0px 0px 6px var(--shine-color-glow)); } /* 반짝이는 애니메이션 효과 (::before 가상요소 사용) */ .stat-card.days.perfect-attendance::before, .shine-effect .streamer-card-bg::before { content: ''; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; background: linear-gradient( 120deg, rgba(255,255,255,0) 20%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0) 80% ); z-index: 1; animation: shine 3s infinite linear; transform: skewX(-25deg); } /* ================================================================= 핵심 통계 카드 (Key Stats) ================================================================= */ .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%); position: relative; overflow: hidden; } .stat-card.days.perfect-attendance { filter: drop-shadow(0px 0px 4px rgba(54, 127, 162, 0.99)) drop-shadow(0px 0px 4px rgba(44, 206, 255, 0.74)); } .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; } /* ================================================================= 많이 본 방송 (Top Streamers) ================================================================= */ .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; } .streamer-card-bg { position: absolute; top: -10%; left: -10%; width: 120%; height: 120%; background-size: cover; background-position: center; filter: blur(10px) brightness(0.7); z-index: 1; overflow: hidden; } .streamer-card-content { position: relative; /* z-index가 적용되도록 */ z-index: 2; color: white; text-align: center; } .streamer-card-avatar { border-radius: 50%; border: 2px solid white; object-fit: cover; } .streamer-card-name, .streamer-card-time { text-shadow: 1px 1px 4px rgba(0,0,0,0.8); } .streamer-card-name { font-weight: bold; } .streamer-card-time { opacity: 0.9; } .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 { width: 130px; height: 130px; margin-bottom: 10px; } .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: 30px; 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; } /* 1등 카드 아바타에 글로우 효과 적용 */ .streamer-card[data-rank="1"] .streamer-card-avatar { --rank1-border-color: #ffd760; /* ❗ 애니메이션을 위한 기본 색상 변수 추가 (rgba 사용) */ --rank1-glow-color-start: rgba(255, 215, 96, 0.5); --rank1-glow-color-end: rgba(255, 215, 96, 0.7); border-color: var(--rank1-border-color); } /* ================================================================= 카테고리 (Category) ================================================================= */ .category-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 15px; } .category-card { 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; aspect-ratio: 3 / 4; } .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; } /* ================================================================= 기타 정보 및 차트 (Other Info & Charts) ================================================================= */ #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: repeat(auto-fit, minmax(300px, 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; } /* 헤더 아이콘 버튼에 공유 버튼 추가 */ .recap-modal-header-buttons button, .recap-modal-header-buttons label { font-size: 20px; /* 아이콘 크기 통일 */ } /* 인증 확인 UI 스타일 */ #recap-verify-container { padding: 30px; text-align: center; } #recap-verify-container h3 { margin-top: 0; color: #ccc; font-weight: normal; } #recap-verify-input { width: 100%; height: 100px; background-color: #2e2e33; border: 1px solid #4f4f54; color: #efeff1; border-radius: 6px; padding: 10px; margin-bottom: 15px; resize: vertical; } #recap-verify-button { padding: 10px 20px; font-size: 16px; background-color: #1abc9c; border-color: #1abc9c; } #recap-verify-button:hover { background-color: #16a085; } /* 공유 카드 스타일 */ .share-card { border: 1px solid #4f4f54; border-radius: 8px; margin: 10px; } .share-card-header { background-color: #3a3a40; padding: 15px; font-size: 18px; font-weight: bold; border-bottom: 1px solid #4f4f54; border-radius: 8px 8px 0 0;} .share-card-body { padding: 20px; display: flex; flex-direction: column; gap: 20px; } .share-info-item { display: flex; justify-content: space-between; align-items: center; } .share-info-item.column { flex-direction: column; align-items: flex-start; gap: 10px; } .share-info-item .label { color: #aaa; } .share-info-item .value { font-weight: bold; font-size: 18px; } .share-info-item .value.total-time { color: #5dade2; } .share-info-item.proof { background-color: rgba(255,255,255,0.05); padding: 15px; border-radius: 6px; flex-direction: column; align-items: flex-start; gap: 8px;} .share-info-item .value.proof-msg { font-size: 16px; font-weight: normal; color: #efeff1; } .share-card-footer { background-color: #27ae60; color: white; text-align: center; padding: 10px; font-size: 14px; border-radius: 0 0 8px 8px;} .shared-streamer-list { list-style: none; padding: 0; width: 100%; margin: 0; } .shared-streamer { display: flex; align-items: center; padding: 8px 0; border-bottom: 1px solid #2e2e33; } .shared-streamer:last-child { border-bottom: none; } .shared-rank { color: #aaa; width: 30px; font-style: italic; } .shared-name { flex-grow: 1; } .shared-time { color: #ccc; } `); // --- 2. UI 요소 생성 및 DOM에 추가 --- recapModalBackdrop = document.createElement('div'); recapModalBackdrop.id = 'recap-modal-backdrop'; 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); document.getElementById('recap-export-button').addEventListener('click', handleExportClick); document.getElementById('recap-import-input').addEventListener('change', handleImportChange); document.getElementById('recap-share-button').addEventListener('click', handleShareClick); document.getElementById('recap-verify-button').addEventListener('click', handleVerifyClick); document.getElementById('recap-show-verify-button').addEventListener('click', handleShowVerifyClick); // ========================================== // 🌟 2. 연말 결산 & 백업 관리 UI (상태 표시 분리 및 월별 통계 추가) // ========================================== const controlsContainer = document.querySelector('.recap-modal-controls'); const yearlySelect = document.createElement('select'); yearlySelect.id = 'recap-yearly-selector'; yearlySelect.style.cssText = 'padding: 8px; border-radius: 4px; border: 1px solid #444; background: #1f1f23; color: #efeff1; margin-left: 15px; font-size: 14px; outline: none;'; try { const backups = JSON.parse(localStorage.getItem('soop_recap_backup_data') || '{}'); const availableYears = new Set(); Object.keys(backups).forEach(k => availableYears.add(k.split('-')[0])); const thisYear = new Date().getFullYear().toString(); if (availableYears.size === 0) availableYears.add(thisYear); [...availableYears].sort().reverse().forEach(year => { const opt = document.createElement('option'); opt.value = year; opt.textContent = `${year}년`; yearlySelect.appendChild(opt); }); } catch (e) { console.error(e); } const yearlyButton = document.createElement('button'); yearlyButton.textContent = '🌟 연말 결산 보기'; yearlyButton.style.cssText = 'padding: 9px 15px; background: #f39c12; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; margin-left: 5px; transition: 0.2s; font-size: 14px;'; const backupManageBtn = document.createElement('button'); backupManageBtn.innerHTML = '💾 리캡 백업 관리'; backupManageBtn.style.cssText = 'padding: 8px 12px; background: #34495e; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; margin-left: auto; transition: 0.2s; font-size: 13px;'; controlsContainer.appendChild(yearlySelect); controlsContainer.appendChild(yearlyButton); controlsContainer.appendChild(backupManageBtn); // ❓ 도움말 버튼 생성 const recapHelpBtn = document.createElement('button'); recapHelpBtn.innerHTML = '❓ 도움말'; recapHelpBtn.style.cssText = ` padding: 6px 10px; background: #3a3a3d; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; margin-left: 6px; font-size: 12px; height: 32px; transition: 0.2s; `; recapHelpBtn.onmouseover = () => recapHelpBtn.style.background = '#505055'; recapHelpBtn.onmouseout = () => recapHelpBtn.style.background = '#3a3a3d'; controlsContainer.appendChild(recapHelpBtn); // --- 백업 관리 모달 로직 (서버/파일 상태 분리) --- const openBackupManager = () => { const existingManager = document.getElementById('recap-backup-manager-modal'); if (existingManager) existingManager.remove(); const targetYear = yearlySelect.value || new Date().getFullYear().toString(); const managerModal = document.createElement('div'); managerModal.id = 'recap-backup-manager-modal'; managerModal.style.cssText = 'position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 420px; background: #1f1f23; border: 1px solid #444; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.8); z-index: 10001; padding: 20px; color: #efeff1; display: flex; flex-direction: column;'; const backups = JSON.parse(localStorage.getItem('soop_recap_backup_data') || '{}'); const downloadedMonths = JSON.parse(localStorage.getItem('soop_recap_downloaded_months') || '[]'); let statusHTML = '
    '; for (let m = 1; m <= 12; m++) { const monthKey = `${targetYear}-${String(m).padStart(2, '0')}`; const isBackedUp = !!backups[monthKey]; const isDownloaded = downloadedMonths.includes(monthKey); statusHTML += `
    ${m}월
    서버 ${isBackedUp ? '✅' : '❌'}
    파일 ${isDownloaded ? '✅' : '❌'}
    `; } statusHTML += '
    '; const autoDownloadEnabled = localStorage.getItem('soop_recap_auto_download') === 'true'; managerModal.innerHTML = `

    💾 리캡 데이터 백업 관리 (${targetYear}년)

    서버는 브라우저 로컬 저장, 파일은 PC 물리적 다운로드를 의미합니다.

    ${statusHTML}
    `; document.getElementById('recap-modal-panel').appendChild(managerModal); document.getElementById('toggle-auto-download').onchange = (e) => { localStorage.setItem('soop_recap_auto_download', e.target.checked); }; document.getElementById('btn-export-backup').onclick = () => { const blob = new Blob([JSON.stringify(backups, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `SOOP_리캡수동백업_${targetYear}.json`; a.click(); URL.revokeObjectURL(url); // 수동 다운로드 시, 현재 있는 모든 백업을 다운로드 완료 처리 Object.keys(backups).forEach(k => { if (!downloadedMonths.includes(k)) downloadedMonths.push(k); }); localStorage.setItem('soop_recap_downloaded_months', JSON.stringify(downloadedMonths)); alert("백업 파일이 다운로드되었습니다. UI가 갱신됩니다."); openBackupManager(); // UI 갱신 (파일 ✅ 켜짐) }; document.getElementById('btn-import-backup').onclick = () => document.getElementById('backup-file-input').click(); document.getElementById('backup-file-input').onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { try { const importedData = JSON.parse(event.target.result); const mergedData = { ...backups, ...importedData }; localStorage.setItem('soop_recap_backup_data', JSON.stringify(mergedData)); // 가져온 파일의 연월도 다운로드 목록에 동기화 Object.keys(importedData).forEach(k => { if (!downloadedMonths.includes(k)) downloadedMonths.push(k); }); localStorage.setItem('soop_recap_downloaded_months', JSON.stringify(downloadedMonths)); alert("백업 데이터를 성공적으로 불러왔습니다!"); openBackupManager(); } catch (err) { alert("파일 형식이 잘못되었습니다."); } }; reader.readAsText(file); }; document.getElementById('btn-close-backup').onclick = () => managerModal.remove(); document.getElementById('btn-x-close-backup').onclick = () => managerModal.remove(); // (openBackupManager 함수 끝) }; backupManageBtn.addEventListener('click', openBackupManager); // ========================================== // ❓ 도움말 모달 팝업 // ========================================== recapHelpBtn.addEventListener('click', () => { // 기존 도움말 모달이 있으면 제거 const existing = document.getElementById('recap-help-modal'); if (existing) existing.remove(); const helpModal = document.createElement('div'); helpModal.id = 'recap-help-modal'; helpModal.style.cssText = ` position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 700px; max-width: 90%; max-height: 80vh; overflow-y: auto; background: #1f1f23; border: 1px solid #444; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.8); z-index: 10002; padding: 35px 30px 30px 30px; /* 상단 여백 확보 */ color: #efeff1; display: flex; flex-direction: column; animation: recapFadeIn 0.2s ease; position: absolute; `; helpModal.innerHTML = `

    📖 월별 리캡 도움말

    • 🌟 연말 결산 보기 → 해당 연도 전체 통계를 보여줍니다.
    • 💾 리캡 백업 관리 → 월별 저장 상태 및 파일 내보내기/불러오기를 할수있습니다.
    • 🖼️ → 현재 화면을 이미지로 저장하여 다운로드합니다.

    • LIVE / VOD / LIVE + VOD 선택 기능 중 1가지를 선택 후 월을 선택하세요.
    데이터 조회 → 해당 월 통계를 표시합니다.
    • 일별 통계 보기에 나오는 캘린더에 마우스 오버시 툴팁을 표시합니다.
    → 해당 날짜의 총 시청 시간 및 최다 시청 스트리머와 시간을 표시합니다.

    📌 연말 결산 주의사항

    🛠️ 월별 리캡 연말 정산 기능은 연 단위 테스트입니다."데이터 내보내기/가져오기" 기능을 활용하세요.

    🛠️ 사이드바 확장프로그램을 설치한 시점에서의 과거 90일 간의 데이터부터 저장됩니다.

    🛡️ 데이터가 완벽하게 안전한 경우 (유지됨)

    1. 확장프로그램(탬퍼몽키)이 고장 나거나 삭제했을 때
    → 탬퍼몽키 앱 자체를 지우고 다시 깔아도 데이터는 브라우저 저장소에 남아있기 때문에 절대 날아가지 않습니다.

    2. 스크립트 코드를 지웠다 다시 깔거나 업데이트할 때
    → 코드를 싹 지우고 새 코드를 복사해 넣어도 기존 데이터는 그대로 연동됩니다.

    3. 컴퓨터를 껐다 켜거나 브라우저를 종료할 때
    → 영구 보존 속성을 가지고 있어 컴퓨터를 껐다 켜도 사라지지 않습니다.

    4. 일반적인 방문 기록(인터넷 사용 기록)을 삭제할 때
    → 단순한 방문 내역이나 다운로드 내역만 지우는 것은 데이터에 영향을 주지 않습니다.

    ⚠️ 데이터가 영구적으로 사라지는 경우 (삭제됨)

    데이터는 결국 '현재 사용 중인 컴퓨터의 웹 브라우저'에 종속되어 있기 때문에 아래와 같은 상황에서는 데이터가 날아갑니다.

    1. 브라우저의 '쿠키 및 기타 사이트 데이터'를 삭제할 때
    → 캐시 청소 시 '쿠키 및 기타 사이트 데이터'를 체크하면 저장소가 통째로 초기화됩니다.
    (대부분의 사이트 자동 로그인이 다 풀려버리는 그 강력한 청소 옵션입니다.)

    2. 컴퓨터를 포맷하거나 윈도우를 재설치할 때
    → 물리적인 하드디스크가 초기화되므로 브라우저 저장소도 함께 사라집니다.

    3. 다른 브라우저나 다른 컴퓨터를 사용할 때
    → 크롬에 저장된 데이터는 엣지, 스마트폰, PC방 컴퓨터와 자동으로 연동되지 않습니다.

    4. 시크릿 모드(인코그니토 모드)로 접속할 때(시크릿 모드사용 권장하지 않습니다.)
    → 시크릿 모드에서는 창을 닫는 순간 모든 데이터가 함께 사라집니다.
    (애초에 저장이 되지 않습니다.)
    `; document.getElementById('recap-modal-panel').appendChild(helpModal); document.getElementById('recap-help-close-x').onclick = () => helpModal.remove(); }); // --- 연말 결산 로직 (월별 4x3 통계 추가) --- yearlyButton.addEventListener('click', async () => { const wrapper = document.getElementById('recap-content-wrapper'); const loader = document.getElementById('recap-loader'); wrapper.style.display = 'none'; loader.style.display = 'block'; activeCharts.forEach(chart => chart.destroy()); activeCharts = []; try { const userInfo = await getUserInfo(); const typeSelector = document.getElementById('recap-type-selector'); const selectedType = typeSelector.value; const categoryImages = await getCategoryImageMap(); const selectedYear = yearlySelect.value; const thisYear = new Date().getFullYear().toString(); const backups = JSON.parse(localStorage.getItem('soop_recap_backup_data') || '{}'); let mergedLiveStreamer = null, mergedLiveCategory = null; let mergedVodStreamer = null, mergedVodCategory = null; const visitedSet = new Set(); // 🌟 월별 통계를 담을 배열 (1~12월) let monthlyStats = Array.from({ length: 12 }, () => ({ seconds: 0, topNick: null })); for (let m = 1; m <= 12; m++) { const mk = `${selectedYear}-${String(m).padStart(2, '0')}`; let mData = backups[mk]; // 올해 현재 진행형 달 데이터 라이브 스크랩 if (!mData && selectedYear === thisYear && m === new Date().getMonth() + 1) { const today = new Date(); const startDate = new Date(selectedYear, m - 1, 1); const yesterday = new Date(); yesterday.setDate(today.getDate() - 1); const [ls, lc, vs, vc] = await Promise.all([ fetchData(userInfo.id, formatDate(startDate), formatDate(yesterday), 'UserLiveWatchTimeData'), fetchData(userInfo.id, formatDate(startDate), formatDate(yesterday), 'UserLiveSearchKeywordData'), fetchData(userInfo.id, formatDate(startDate), formatDate(yesterday), 'UserVodWatchTimeData'), fetchData(userInfo.id, formatDate(startDate), formatDate(yesterday), 'UserVodSearchKeywordData') ]); if (ls?.result === 1) mData = { liveStreamer: ls, liveCategory: lc, vodStreamer: vs, vodCategory: vc }; } if (mData) { // 기존 병합 로직 mergedLiveStreamer = mergedLiveStreamer ? mergeData(mergedLiveStreamer, mData.liveStreamer, 'streamer') : mData.liveStreamer; mergedLiveCategory = mergedLiveCategory ? mergeData(mergedLiveCategory, mData.liveCategory, 'category') : mData.liveCategory; mergedVodStreamer = mergedVodStreamer ? mergeData(mergedVodStreamer, mData.vodStreamer, 'streamer') : mData.vodStreamer; mergedVodCategory = mergedVodCategory ? mergeData(mergedVodCategory, mData.vodCategory, 'category') : mData.vodCategory; // 🌟 월별 통계 수집 로직 let mTotalSec = 0; let mStreamerMap = new Map(); const processMData = (sData) => { if (!sData || sData.result !== 1) return; mTotalSec += sData.data?.broad_cast_info?.data?.cumulative_watch_time || 0; sData.data?.chart?.data_stack?.forEach(st => { if (st.bj_nick !== '기타') { const sum = st.data.reduce((a, b) => a + b, 0); mStreamerMap.set(st.bj_nick, (mStreamerMap.get(st.bj_nick) || 0) + sum); } }); }; processMData(mData.liveStreamer); processMData(mData.vodStreamer); let maxSec = 0, topNick = null; mStreamerMap.forEach((sec, nick) => { if (sec > maxSec) { maxSec = sec; topNick = nick; } }); // 🌟 최다 시청 시간(topSeconds)도 같이 저장하도록 추가 monthlyStats[m - 1] = { seconds: mTotalSec, topNick: topNick, topSeconds: maxSec }; } } if (!mergedLiveStreamer && !mergedVodStreamer) { wrapper.innerHTML = `

    ${selectedYear}년 데이터가 부족합니다! 😥

    `; return; } if (mergedLiveStreamer?.data?.table1?.data) mergedLiveStreamer.data.table1.data.forEach(d => { if (d.total_watch_time !== '00:00:00') visitedSet.add(d.day); }); if (mergedVodStreamer?.data?.table1?.data) mergedVodStreamer.data.table1.data.forEach(d => { if (d.total_watch_time !== '00:00:00') visitedSet.add(d.day); }); const totalVisitedDays = visitedSet.size; let streamerData, categoryData; if (selectedType === 'live') { streamerData = mergedLiveStreamer; categoryData = mergedLiveCategory; } else if (selectedType === 'vod') { streamerData = mergedVodStreamer; categoryData = mergedVodCategory; } else { streamerData = mergeData(mergedLiveStreamer, mergedVodStreamer, 'streamer'); categoryData = mergeData(mergedLiveCategory, mergedVodCategory, 'category'); } if (streamerData?.data?.table1) streamerData.data.table1 = null; if (streamerData && categoryData) { await renderAll(streamerData, categoryData, userInfo, categoryImages); // 기존 일별 캘린더 숨기기 const expandBtns = Array.from(wrapper.querySelectorAll('.expand-button')); const dailyBtn = expandBtns.find(btn => btn.textContent.includes('일별')); if (dailyBtn) dailyBtn.style.display = 'none'; const dailyContainer = document.getElementById('daily-stats-container'); if (dailyContainer) dailyContainer.style.display = 'none'; // 🌟 4x3 월별 시청 통계 렌더링 const monthlySection = document.createElement('div'); monthlySection.innerHTML = `
    월별 시청 통계
    `; const monthlyGrid = document.createElement('div'); monthlyGrid.style.cssText = 'display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; margin-top: 15px;'; const maxMonthlySec = Math.max(...monthlyStats.map(s => s.seconds), 1); monthlyStats.forEach((stat, idx) => { const m = idx + 1; const card = document.createElement('div'); card.style.cssText = 'aspect-ratio: 1 / 1; background: #3a3a40; border-radius: 8px; position: relative; overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; transition: transform 0.2s ease; cursor: default;'; if (stat.seconds > 0) { let percent = (stat.seconds / maxMonthlySec) * 100; if (percent < 8) percent = 8; let avatarHtml = ''; if (stat.topNick) { avatarHtml = `
    `; // 프로필 비동기 처리 (사이드바 함수로 수정 완료) getStreamerProfileUrl(stat.topNick).then(url => { if (url) { imageToDataUri(url).then(base64 => { if (base64) { const c = card.querySelector(`.monthly-avatar-container[data-nick="${stat.topNick}"]`); if (c) c.innerHTML = `
    `; } }); } }); } // 툴팁 및 호버 효과 (최다 시청 시간 추가) let topStreamerInfo = stat.topNick ? `\n최다 시청: ${stat.topNick} (${formatSecondsToHM(stat.topSeconds)})` : '\n최다 시청: 없음'; card.title = `${m}월 총 시청: ${formatSecondsToHM(stat.seconds)}${topStreamerInfo}`; card.onmouseover = () => { card.style.transform = 'scale(1.05)'; card.style.zIndex = '10'; card.style.boxShadow = '0 0 10px rgba(52,152,219,0.5)'; card.style.border = '1px solid #3498db'; }; card.onmouseout = () => { card.style.transform = 'scale(1)'; card.style.zIndex = '1'; card.style.boxShadow = 'none'; card.style.border = 'none'; }; card.innerHTML = `
    ${m}월
    ${avatarHtml}
    `; } else { card.style.background = '#26262a'; card.innerHTML = `
    ${m}월
    `; } monthlyGrid.appendChild(card); }); monthlySection.appendChild(monthlyGrid); if (dailyContainer) dailyContainer.parentNode.insertBefore(monthlySection, dailyContainer); const yNum = parseInt(selectedYear, 10); const isLeapYear = (yNum % 4 === 0 && yNum % 100 !== 0) || (yNum % 400 === 0); const daysInYear = isLeapYear ? 366 : 365; const isPerfect = totalVisitedDays >= daysInYear; const daysCard = wrapper.querySelector('.stat-card.days'); if (daysCard) { const labelEl = daysCard.querySelector('.label'); const valueEl = daysCard.querySelector('.value'); if (isPerfect) { daysCard.classList.add('perfect-attendance'); labelEl.textContent = '🎉 1년 개근 달성!'; if (typeof triggerAttendanceConfetti === 'function') setTimeout(() => triggerAttendanceConfetti(daysCard), 500); } else { daysCard.classList.remove('perfect-attendance'); labelEl.textContent = `${selectedYear}년 총 출석`; } valueEl.innerHTML = `${totalVisitedDays}`; } const titleDiv = document.createElement('div'); titleDiv.style.cssText = 'text-align: center; font-size: 26px; font-weight: bold; margin-bottom: 20px; color: #ffd760; text-shadow: 0 0 10px rgba(255, 215, 96, 0.5);'; titleDiv.textContent = `🏆 ${selectedYear}년 SOOP 연말 결산 🏆`; wrapper.prepend(titleDiv); } else { wrapper.innerHTML = `

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

    `; } } catch (error) { console.error("[연말 결산 에러]", error); wrapper.innerHTML = `

    오류 발생: ${error.message}

    `; } finally { loader.style.display = 'none'; wrapper.style.display = 'block'; } }); // 모달을 기본적으로 숨김 상태로 시작 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); } }; // ========================================== // 🌟 1. 스텔스 백업 & 물리적 다운로드 추적 엔진 // ========================================== const runStealthAutoBackup = async () => { try { let gmBackups = {}; try { gmBackups = await GM_getValue('recap_backup_data', {}); } catch (e) { } let localBackups = JSON.parse(localStorage.getItem('soop_recap_backup_data') || '{}'); let backups = { ...gmBackups, ...localBackups }; let isUpdated = false; const userInfo = await getUserInfo().catch(() => null); if (!userInfo) return; const today = new Date(); for (let i = 1; i <= 3; i++) { const targetDate = new Date(today.getFullYear(), today.getMonth() - i, 1); const y = targetDate.getFullYear(); const m = targetDate.getMonth() + 1; const monthKey = `${y}-${String(m).padStart(2, '0')}`; if (!backups[monthKey]) { const startDate = new Date(y, m - 1, 1); const endDate = new Date(y, m, 0); const fStart = formatDate(startDate); const fEnd = formatDate(endDate); const [ls, lc, vs, vc] = await Promise.all([ fetchData(userInfo.id, fStart, fEnd, 'UserLiveWatchTimeData'), fetchData(userInfo.id, fStart, fEnd, 'UserLiveSearchKeywordData'), fetchData(userInfo.id, fStart, fEnd, 'UserVodWatchTimeData'), fetchData(userInfo.id, fStart, fEnd, 'UserVodSearchKeywordData') ]); if (ls && ls.result === 1) { backups[monthKey] = { liveStreamer: ls, liveCategory: lc, vodStreamer: vs, vodCategory: vc }; isUpdated = true; console.log(`[리캡] ${monthKey} 데이터 서버(로컬) 저장 완료 ✅`); } } } if (isUpdated || Object.keys(localBackups).length === 0) { localStorage.setItem('soop_recap_backup_data', JSON.stringify(backups)); } // --- 다운로드 이력 추적 로직 --- const autoDownloadEnabled = localStorage.getItem('soop_recap_auto_download') === 'true'; let downloadedMonths = JSON.parse(localStorage.getItem('soop_recap_downloaded_months') || '[]'); if (autoDownloadEnabled) { const targetDate = new Date(today.getFullYear(), today.getMonth() - 1, 1); const targetMonthKey = `${targetDate.getFullYear()}-${String(targetDate.getMonth() + 1).padStart(2, '0')}`; if (backups[targetMonthKey] && !downloadedMonths.includes(targetMonthKey)) { const blob = new Blob([JSON.stringify(backups, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `SOOP_리캡_안전백업_${targetMonthKey}.json`; a.click(); URL.revokeObjectURL(url); // 다운로드 이력에 추가 (체크 표시용) downloadedMonths.push(targetMonthKey); localStorage.setItem('soop_recap_downloaded_months', JSON.stringify(downloadedMonths)); console.log(`[리캡] ${targetMonthKey} 백업 파일 자동 다운로드 완료 📥`); } } } catch (error) { console.error("[스텔스 백업 에러]", error); } }; setTimeout(() => { runStealthAutoBackup(); }, 3000); function toggleRecapModule(forceShow = false) { // UI가 아직 생성되지 않았다면 안전하게 먼저 생성합니다. if (!recapModalBackdrop) { customLog.log("Recap module UI not found, creating it now."); createRecapModule(); } // 모달의 현재 표시 상태를 확인합니다. const isModalVisible = recapModalBackdrop.style.display === 'flex'; if (forceShow || !isModalVisible) { // 모달을 열거나 강제로 열어야 하는 경우: recapModalBackdrop.style.display = 'flex'; // 모달 표시 // 🌟 이 줄을 추가하세요! (모달이 열릴 때 백업 엔진 실행) autoBackupMissingMonths(); // 컨텐츠 영역 초기화 및 로더 숨김 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 = []; customLog.log("Recap module opened and initialized."); } else { // 모달을 닫아야 하는 경우: recapModalBackdrop.style.display = 'none'; customLog.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'); await loadScript('https://cdn.jsdelivr.net/npm/canvas-confetti@1/dist/confetti.browser.js'); createRecapModule(); recapInitialized = true; toggleRecapModule(); } catch (error) { //alert('리캡 기능에 필요한 라이브러리를 로드하는 데 실패했습니다.'); customLog.error('Recap Script Load Error:', error); } finally { span.textContent = originalText; } }, true); // --- 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) { customLog.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) class PlayerEventMapper { constructor(playerElement, videoElement, buttonSelectors) { this.player = playerElement; this.video = videoElement; this.buttons = {}; this.actions = {}; this._initializeButtons(buttonSelectors); } async _initializeButtons(selectors) { const buttonEntries = await Promise.all( Object.entries(selectors).map(async ([key, selector]) => { const element = await waitForElementAsync(selector); if (!element) customLog.error(`[EventMapper] '${key}' 버튼을 찾을 수 없습니다. (셀렉터: ${selector})`); return [key, element]; }) ); this.buttons = Object.fromEntries(buttonEntries.filter(entry => entry[1])); this._defineActions(); customLog.log("[EventMapper] 모든 버튼이 준비되었습니다.", this.buttons); this.player.dispatchEvent(new Event('mapper-ready')); } _defineActions() { this.actions = { none: () => { return; }, toggleMute: () => { if (!this.buttons.mute) return; this.buttons.mute.click(); setTimeout(() => { displayCenterVolume(this.video.muted, this.video.volume); }, 50); }, togglePause: () => { if (!this.buttons.pause) return; const computedStyle = window.getComputedStyle(this.buttons.pause); if (computedStyle.display === 'none') { return; } this.buttons.pause.click(); }, toggleStop: () => { if (!this.buttons.stop) return; this.buttons.stop.click(); }, toggleScreenMode: () => { if (!this.buttons.screenMode) return; this.buttons.screenMode.click(); }, toggleFullscreen: () => { if (!this.buttons.fullscreen) return; this.buttons.fullscreen.click(); }, }; } // 이벤트를 특정 액션에 매핑하는 핵심 메소드 (키보드 관련 로직 제거됨) map(eventType, actionName) { if (typeof this.actions[actionName] !== 'function') { customLog.error(`[EventMapper] '${actionName}'은(는) 유효한 액션이 아닙니다.`); return; } const listener = (event) => { // 비디오 영역 클릭 시에만 동작하도록 제한 if (event.target.id !== 'videoLayerCover' && event.target !== this.player) { return; } event.preventDefault(); // 우클릭 메뉴, 더블클릭 선택 등 기본 동작 방지 // 매핑된 액션 실행 this.actions[actionName](); }; // 플레이어에 마우스 이벤트 리스너 추가 this.player.addEventListener(eventType, listener); } // 설정 객체를 받아와서 모든 매핑을 한 번에 적용 (키보드 관련 로직 제거됨) applyConfiguration(config) { for (const eventType in config) { const actionName = config[eventType]; this.map(eventType, actionName); } customLog.log("[EventMapper] 사용자 설정이 적용되었습니다.", config); } } let disposeSidebarMouseOverHandlers = null; const clearScreenModeSidebarLayout = () => { ['webplayer', 'webplayer_contents'] .map((id) => document.getElementById(id)) .filter(Boolean) .forEach((element) => { element.style.removeProperty('left'); element.style.removeProperty('width'); }); }; const setScreenModeSidebarOpen = (shouldOpen) => { const body = document.body; if (!body) return; const isScreenmode = body.classList.contains('screen_mode'); const isFullScreenmode = body.classList.contains('fullScreen_mode'); if (!isScreenmode || isFullScreenmode) { body.classList.remove('showSidebar'); clearScreenModeSidebarLayout(); return; } body.classList.toggle('showSidebar', shouldOpen); clearScreenModeSidebarLayout(); // ★ 추가: 사이드바가 열릴 때 webplayer를 사이드바 너비만큼 오른쪽으로 밀기 if (shouldOpen) { const sidebar = document.getElementById('sidebar'); const webplayer = document.getElementById('webplayer'); if (sidebar && webplayer) { const sbWidth = sidebar.offsetWidth; webplayer.style.setProperty('left', `${sbWidth}px`, 'important'); webplayer.style.setProperty('width', `calc(100vw - ${sbWidth}px)`, 'important'); } } }; const checkSidebarVisibility = () => { let intervalId = null; let lastExecutionTime = Date.now(); // 마지막 실행 시점 기록 const handleVisibilityChange = () => { const body = document.body; const isScreenmode = body.classList.contains('screen_mode'); let isShowSidebar = body.classList.contains('showSidebar'); const isFullScreenmode = body.classList.contains('fullScreen_mode'); // 전체화면 진입 시 showSidebar 강제 제거 if (isFullScreenmode && isShowSidebar) { body.classList.remove('showSidebar'); isShowSidebar = false; clearScreenModeSidebarLayout(); } // 스크린 모드에서 사이드바 항상 보이는 옵션 if (isScreenmode && showSidebarOnScreenModeAlways && !isShowSidebar) { setScreenModeSidebarOpen(true); isShowSidebar = true; } // 사이드바가 보이는 상태에서 스크린 모드 종료할 때 if (!isScreenmode && isShowSidebar) { setScreenModeSidebarOpen(false); isShowSidebar = false; } const isSidebarHidden = (isScreenmode ? !isShowSidebar : false) || isFullScreenmode; if (document.visibilityState === 'visible' && isSidebarHidden) { customLog.log('#sidebar는 숨겨져 있음'); return; } const currentTime = Date.now(); const timeSinceLastExecution = (currentTime - lastExecutionTime) / 1000; // 초 단위로 변환 if (document.visibilityState === 'visible' && timeSinceLastExecution >= 60) { customLog.log('탭 활성화됨'); generateBroadcastElements(1); lastExecutionTime = currentTime; // 갱신 시점 기록 restartInterval(); // 인터벌 재시작 } else if (document.visibilityState === 'visible') { customLog.log('60초 미만 경과: 방송 목록 갱신하지 않음'); } else { customLog.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 setupScreenModePageScroll = () => { let wheelHandler = null; const onWheel = (e) => { if (!document.body.classList.contains('screen_mode')) return; // 사이트가 인라인 스타일로 스크롤을 막는 경우 해제 document.documentElement.style.overflow = ''; document.body.style.overflow = ''; window.scrollBy({ top: e.deltaY, behavior: 'auto' }); }; const attach = () => { if (wheelHandler) return; const webplayerEl = document.getElementById('webplayer'); if (!webplayerEl) return; wheelHandler = onWheel; webplayerEl.addEventListener('wheel', wheelHandler, { passive: true }); }; const detach = () => { const webplayerEl = document.getElementById('webplayer'); if (webplayerEl && wheelHandler) { webplayerEl.removeEventListener('wheel', wheelHandler); } wheelHandler = null; document.documentElement.style.overflow = ''; document.body.style.overflow = ''; }; const handleClassChange = () => { const isScreenMode = document.body.classList.contains('screen_mode'); if (isScreenMode) { waitForElementAsync('#webplayer').then(attach); } else { detach(); } }; observeClassChanges('body', handleClassChange); handleClassChange(); // 초기 실행 (이미 screen_mode 상태인 경우 대비) }; const processStreamers = () => { const processedLayers = new Set(); // 처리된 레이어를 추적 const pendingListItems = new Set(); const pendingThumbLinks = new Set(); let shouldCheckOptionsLayer = false; let shouldCheckAdultBadges = false; let shouldNormalizeTargets = false; let removeTargetTimerId = null; let isDomChangeScheduled = false; // [수정 1] 숨기기 버튼 생성 (기존 유지) 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('/')[4]; // 사용자 ID 추출 const userName = userNameElement.innerText; // 사용자 이름 추출 customLog.log(`Blocking user: ${userName}, ID: ${userId}`); // 로그 추가 if (userId && userName) { blockUser(userName, userId); // 사용자 차단 함수 호출 listItem.style.display = 'none'; } } else { customLog.log("User elements not found."); // 요소가 없을 경우 로그 추가 } }); optionsLayer.appendChild(hideButton); // 옵션 레이어에 버튼 추가 }; // [수정 2] 카테고리 숨기기 버튼 (기능 끄기 위해 내용 비움 -> 에러 방지) const createCategoryHideButton = (listItem, optionsLayer) => { return; }; // [수정 3] 핀 버튼 (기능 끄기 위해 내용 비움 -> 에러 방지) const createCategoryPinButton = (listItem, optionsLayer) => { return; }; // [수정 4] DOM 변경 감지 (렉 방지 최적화 적용됨) const addPendingListItemsFromNode = (node) => { if (!node || node.nodeType !== Node.ELEMENT_NODE) return; if (node.matches('li[data-type="cBox"]:not(.hide-checked)')) { pendingListItems.add(node); } node.querySelectorAll('div.cBox-list li[data-type="cBox"]:not(.hide-checked)').forEach((item) => { pendingListItems.add(item); }); }; const collectPendingNodes = (node) => { if (!node || node.nodeType !== Node.ELEMENT_NODE) return; addPendingListItemsFromNode(node); if (isPreviewModalEnabled && previewModalManager) { if (node.matches('[data-type=cBox] .thumbs-box > a[href]:not([href^="https://vod.sooplive.com"])')) { pendingThumbLinks.add(node); } node.querySelectorAll('[data-type=cBox] .thumbs-box > a[href]:not([href^="https://vod.sooplive.com"])').forEach((link) => { pendingThumbLinks.add(link); }); } if (isReplaceEmptyThumbnailEnabled) { if (node.matches('.status.adult') || node.querySelector('.status.adult')) { shouldCheckAdultBadges = true; } } if (!isOpenNewtabEnabled) { if (node.matches('#container a[target], .side_list a[target]') || node.querySelector('#container a[target], .side_list a[target]')) { shouldNormalizeTargets = true; } } }; const processListItem = (listItem, lowerBlockedWords) => { listItem.classList.add('hide-checked'); const userIdElement = listItem.querySelector('.cBox-info > a'); // 사용자 ID 요소 const categoryElements = listItem.querySelectorAll('.cBox-info .tag_wrap a.category'); // 다중 카테고리 const tagElements = listItem.querySelectorAll('.cBox-info .tag_wrap a:not(.category)'); const titleElement = listItem.querySelector('.cBox-info .title a'); // 유저 차단 if (userIdElement) { const userId = userIdElement.href.split('/')[4]; if (isUserBlocked(userId)) { listItem.style.display = 'none'; customLog.log(`Removed blocked user with ID: ${userId}`); // 로그 추가 } } // 카테고리 차단 (다중) if (categoryElements) { for (const categoryElement of categoryElements) { const categoryName = categoryElement.textContent; if (isCategoryBlocked(getCategoryNo(categoryName))) { listItem.style.display = 'none'; customLog.log(`Removed blocked category with Name: ${categoryName}`); // 로그 추가 break; } } } // 제목 차단 if (titleElement) { const broadTitle = titleElement.textContent; const broadTitleLower = broadTitle.toLowerCase(); for (const word of lowerBlockedWords) { if (broadTitleLower.includes(word)) { listItem.style.display = 'none'; customLog.log(`Removed item with blocked word in title: ${broadTitle}`); // 로그 추가 break; } } } // 태그 차단 if (tagElements) { for (const tagElement of tagElements) { const tagTitle = tagElement.textContent; const tagTitleLower = tagTitle.toLowerCase(); for (const word of lowerBlockedWords) { if (tagTitleLower.includes(word)) { listItem.style.display = 'none'; customLog.log(`Removed item with blocked word in tag: ${tagTitle}`); // 로그 추가 break; } } if (listItem.style.display === 'none') break; } } }; const flushDOMChange = () => { isDomChangeScheduled = false; const lowerBlockedWords = blockedWords.map((word) => word.toLowerCase()); // (A) 더보기 메뉴 버튼 처리 if (shouldCheckOptionsLayer) { // 2. 변화가 있을 때만 딱 한 번 실행 (렉 해결 핵심) // (A) 더보기 메뉴 버튼 처리 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 ? activeButton.closest('li[data-type="cBox"]') : null; if (listItem) { createHideButton(listItem, optionsLayer); // 숨기기 버튼 생성 createCategoryHideButton(listItem, optionsLayer); createCategoryPinButton(listItem, optionsLayer); // Add the pin button processedLayers.add(optionsLayer); // 이미 처리된 레이어로 추가 } } else if (!optionsLayer) { processedLayers.clear(); // 요소가 없을 때 처리된 레이어 초기화 } shouldCheckOptionsLayer = false; } // (B) 신규/변경 목록 필터링 if (pendingListItems.size > 0) { for (const listItem of pendingListItems) { if (!listItem.isConnected) continue; processListItem(listItem, lowerBlockedWords); } pendingListItems.clear(); } // (C) 썸네일 처리 (프리뷰, 19금 등) if (isPreviewModalEnabled && previewModalManager && pendingThumbLinks.size > 0) { previewModalManager.attachToThumbnails(Array.from(pendingThumbLinks)); pendingThumbLinks.clear(); } if (isReplaceEmptyThumbnailEnabled && shouldCheckAdultBadges) { replaceThumbnails(); shouldCheckAdultBadges = false; } // 새 탭 방지 if (!isOpenNewtabEnabled && shouldNormalizeTargets) { clearTimeout(removeTargetTimerId); removeTargetTimerId = setTimeout(removeTargetFromLinks, 100); shouldNormalizeTargets = false; } }; // 카테고리 차단 (다중) const scheduleDOMChange = () => { if (isDomChangeScheduled) return; isDomChangeScheduled = true; requestAnimationFrame(flushDOMChange); }; // 제목 차단 const handleDOMChange = (mutationsList = []) => { shouldCheckOptionsLayer = true; // 태그 차단 for (const mutation of mutationsList) { if (mutation.type !== 'childList') continue; mutation.addedNodes.forEach(collectPendingNodes); } // (C) 썸네일 처리 (프리뷰, 19금 등) scheduleDOMChange(); }; const observer = new MutationObserver(handleDOMChange); // DOM 변경 감지기 const config = { childList: true, subtree: true }; observer.observe(document.body, config); // 초기 스캔 shouldCheckOptionsLayer = true; if (!isOpenNewtabEnabled) shouldNormalizeTargets = true; if (isReplaceEmptyThumbnailEnabled) shouldCheckAdultBadges = true; collectPendingNodes(document.body); scheduleDOMChange(); }; /** * 채팅 메시지를 추적하고, 강퇴/지정 유저 메시지를 모달에 표시하는 함수 */ const setupChatMessageTrackers = (element) => { const CHAT_TRACKER_FLAG = '__soopChatTrackerInstalled'; if (unsafeWindow[CHAT_TRACKER_FLAG]) return; unsafeWindow[CHAT_TRACKER_FLAG] = true; const OriginalWebSocket = window.WebSocket; const targetUrlPattern = /^wss:\/\/chat-[\w\d]+\.sooplive\.com/; const MAX_MESSAGES = 500; const messageHistory = []; const bannedMessages = []; const targetUserMessages = []; const recentChatContentQueue = []; const recentChatContentSet = new Set(); const RECENT_CHAT_LIMIT = 10; const utf8Decoder = new TextDecoder("utf-8"); let bannedModal = null; let targetModal = null; let banIcon = null; let highlightIcon = null; 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(` /* 모달 내부 메시지 리스트 스타일 (수정됨) */ .message-list_23423 { list-style: none; padding: 4px; margin: 0; } .message-list_23423 li { display: grid; /* [변경] flex에서 grid로 변경 */ grid-template-columns: 65px 24px 1fr; /* [추가] 시간, 프사, 내용 영역 분할 */ gap: 0 8px; /* 열 사이 간격 */ align-items: flex-start; padding: 4px 4px; border-radius: 4px; line-height: 1.5; } .message-list_23423 li:hover { background-color: #3a3a3d; } .message-list_23423 .no-message { display: block; color: #888; text-align: center; padding: 20px; background-color: transparent; } .message-list_23423 .timestamp { color: #a9a9b3; font-size: 15px; margin-top: 2px; } /* [추가] 프로필 사진 스타일 */ .message-list_23423 .profile-pic { width: 24px; height: 24px; border-radius: 50%; object-fit: cover; } .message-list_23423 .content-wrap { word-break: break-all; color: #dcdcdc; font-size: 16px; } .message-list_23423 .username-link { text-decoration: none; color: inherit; margin-right: 6px; } .message-list_23423 .username-link:hover .username { text-decoration: underline; } .message-list_23423 .username { font-weight: bold; font-size: 16px; } .message-list_23423 .message { font-size: 16px; } /* 채팅 삭제 버튼 래퍼 */ .message-list_23423 li { position: relative; grid-template-columns: 65px 24px 1fr 20px; } .message-list_23423 li.special-activity { grid-template-columns: unset; } /* 채팅 삭제 버튼 */ .message-list_23423 .msg-delete-btn { grid-column: 4; align-self: center; position: static; transform: none; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0); border: 1px solid transparent; border-radius: 3px; color: transparent; font-size: 14px; cursor: pointer; line-height: 1; padding: 0; transition: color 0.15s, border-color 0.15s, background 0.15s; flex-shrink: 0; } .message-list_23423 li:hover .msg-delete-btn { background: rgba(255, 255, 255, 0.08); border-color: #555; } .message-list_23423 li:hover .msg-delete-btn:hover { color: #ff6b6b; background: rgba(255, 107, 107, 0.15); border-color: #ff6b6b; } /* [변경] 강퇴 메시지는 grid를 사용하지 않도록 별도 처리 */ .message-list_23423 li.special-activity { display: flex; /* grid 대신 flex 유지 */ gap: 0 12px; } .message-list_23423 li.special-activity .content-wrap { font-style: italic; } `); if (isShowDeletedMessagesEnabled) { bannedModal = new DraggableResizableModal('banned-messages-modal', '강제퇴장된 유저의 채팅', { top: '100px', right: '100px' }); bannedModal.getContentElement().innerHTML = '
    • 메시지가 없습니다.
    '; } if (isShowSelectedMessagesEnabled) { const initialTitle = `채팅 모아보기 (즐찾 ${allFollowUserIds.length}명, 수동 ${selectedUsersArray.length}명${isCheckBestStreamersListEnabled ? `, 베스 ${bestStreamersList.length}명` : ''})`; targetModal = new DraggableResizableModal('target-messages-modal', initialTitle, { top: '150px', right: '150px' }); targetModal.getContentElement().innerHTML = '
    • 메시지가 없습니다.
    '; } const toggleRedDot = (icon, shouldShow) => { if (!icon) return; let redDot = icon.querySelector(".red-dot"); if (shouldShow && !redDot) { redDot = document.createElement("div"); redDot.className = "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 && redDot) { redDot.remove(); } }; /** * 사용자 ID를 기반으로 일관된 HSL 색상을 생성합니다. * @param {string} str - 사용자 ID * @param {number} s - 채도 (Saturation) * @param {number} l - 명도 (Lightness) * @returns {string} HSL 색상 문자열 */ const stringToHslColor = (str, s = 70, l = 75) => { let hash = 0; for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash); } const h = hash % 360; return `hsl(${h}, ${s}%, ${l}%)`; }; const messageRenderQueues = new WeakMap(); const createMessageListItem = (msg) => { /** * [수정됨] 메시지를 DOM에 추가하고 지능적으로 스크롤합니다. * @param {HTMLElement} messageList - 메시지가 추가될
      요소 * @param {object} msg - 메시지 객체 * @param {HTMLElement} scrollContainer - 스크롤이 있는 부모 컨테이너 * @param {boolean} isBannedList - 강퇴 리스트 여부 */ const listItem = document.createElement("li"); const timestampText = `[${msg.timestamp}]`.replace(/[\[\]]/g, ''); const systemMessage = msg.message === `[강제퇴장 됨]`; if (systemMessage) { // 강퇴 메시지는 grid 레이아웃을 사용하지 않도록 special-activity 클래스 추가 listItem.classList.add('special-activity'); listItem.innerHTML = ` ${timestampText}
      ${msg.userName} (${msg.userId}) 님이 강제 퇴장 되었습니다.
      `; const deleteBtnBan = document.createElement('button'); deleteBtnBan.className = 'msg-delete-btn'; deleteBtnBan.title = '이 메시지 삭제'; deleteBtnBan.textContent = '×'; deleteBtnBan.addEventListener('click', (e) => { e.stopPropagation(); listItem.remove(); }); listItem.appendChild(deleteBtnBan); return listItem; } const userColor = stringToHslColor(msg.userId); const profileImgUrl = `https://profile.img.sooplive.com/LOGO/${msg.userId.substring(0, 2)}/${msg.userId}/${msg.userId}.jpg`; listItem.innerHTML = ` ${timestampText} profile
      ${msg.userName} ${msg.message}
      `; // 개별 메시지 삭제 버튼 const deleteBtn = document.createElement('button'); deleteBtn.className = 'msg-delete-btn'; deleteBtn.title = '이 메시지 삭제'; deleteBtn.textContent = '×'; deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); listItem.remove(); }); listItem.appendChild(deleteBtn); return listItem; }; const clearMessageRenderQueue = (messageList) => { const queueState = messageRenderQueues.get(messageList); if (!queueState) return; if (queueState.frameId !== null) { cancelAnimationFrame(queueState.frameId); } queueState.frameId = null; queueState.items.length = 0; }; const flushMessageRenderQueue = (messageList) => { const queueState = messageRenderQueues.get(messageList); if (!queueState) return; queueState.frameId = null; if (queueState.items.length === 0) return; const { scrollContainer } = queueState; const threshold = 20; const shouldStickToBottom = scrollContainer.scrollHeight - scrollContainer.clientHeight <= scrollContainer.scrollTop + threshold; const noMessageItem = messageList.querySelector('.no-message'); if (noMessageItem) noMessageItem.remove(); const fragment = document.createDocumentFragment(); for (const item of queueState.items) { fragment.appendChild(createMessageListItem(item.msg)); } queueState.items.length = 0; messageList.appendChild(fragment); if (shouldStickToBottom) { scrollContainer.scrollTop = scrollContainer.scrollHeight; } }; /** * 메시지를 큐에 쌓아 다음 animation frame에서 한 번에 렌더링합니다. * @param {HTMLElement} messageList - 메시지가 추가될
        요소 * @param {object} msg - 메시지 객체 * @param {HTMLElement} scrollContainer - 스크롤이 있는 부모 컨테이너 */ const addMessageToDOM = (messageList, msg, scrollContainer) => { let queueState = messageRenderQueues.get(messageList); if (!queueState) { queueState = { items: [], frameId: null, scrollContainer }; messageRenderQueues.set(messageList, queueState); } queueState.scrollContainer = scrollContainer; queueState.items.push({ msg }); if (queueState.frameId === null) { queueState.frameId = requestAnimationFrame(() => flushMessageRenderQueue(messageList)); } }; 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); if (targetModal) { const scrollContainer = targetModal.getContentElement(); const messageList = scrollContainer.querySelector("#targetUserMessagesList"); addMessageToDOM(messageList, msgData, scrollContainer); } if (!targetModal?.isVisible()) toggleRedDot(highlightIcon, true); } }; const parseChatPacket = (data) => { try { const decodedText = (typeof data === 'string') ? data : utf8Decoder.decode(data); const parts = decodedText.split("\x0c"); const partHeader = parts[0]?.split('\u001b\t')[1] || ''; return { parts, partHeader }; } catch (e) { customLog.warn("메시지 디코딩 실패:", e); return null; } }; const decodeMessage = (packet) => { if (!packet) return; const { parts, partHeader } = packet; 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 < 20) { // 일반 채팅 if (partHeader.startsWith('0005000')) { const userId = parts[2].split('(')[0]; const userName = parts[6]; const message = parts[1]; recordMessage(userId, userName, message, timestamp); //customLog.log(partHeader, parts); //customLog.log('일반채팅',userId, userName, message, timestamp); } else if (partHeader.startsWith('001800')) { // 별풍선 const userId = parts[2].split('(')[0]; const userName = parts[3]; const message = `🎈 별풍선 ${parts[4]}개`; recordMessage(userId, userName, message, timestamp); //customLog.log(partHeader, parts); customLog.log(parts.length, '별풍선', userId, userName, message, timestamp); } else if (partHeader.startsWith('0105000')) { // 영상풍선 const userId = parts[3].split('(')[0]; const userName = parts[4]; const message = `🎈 영상풍선 ${parts[5]}개`; recordMessage(userId, userName, message, timestamp); //customLog.log(partHeader, parts); customLog.log(parts.length, '영상풍선', userId, userName, message, timestamp); } else if (partHeader.startsWith('008700')) { // 애드벌룬 const userId = parts[3].split('(')[0]; const userName = parts[4]; const message = `🎈 애드벌룬 ${parts[10]}개`; recordMessage(userId, userName, message, timestamp); //customLog.log(partHeader, parts); customLog.log(parts.length, '애드벌룬', userId, userName, message, timestamp); } else if (partHeader.startsWith('012100')) { // 대결미션, 도전미션 const jsonResponse = JSON.parse(parts[1]); const userId = jsonResponse?.user_id; const userName = jsonResponse?.user_nick; const message = `🎈 미션풍선 ${jsonResponse?.gift_count}개`; recordMessage(userId, userName, message, timestamp); //customLog.log(partHeader, parts); customLog.log(parts.length, '미션풍선', userId, userName, message, timestamp); } else if (partHeader.startsWith('000400') && parts[1] === '-1' && parts[4] === '2') { const userId = parts[2].split('(')[0], 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 }; const messagesToAdd = [...userMessages, banNotice]; bannedMessages.push(...messagesToAdd); //customLog.log(partHeader, parts); customLog.log(parts.length, partHeader, banNotice); if (bannedModal) { const scrollContainer = bannedModal.getContentElement(); const messageList = scrollContainer.querySelector("#bannedMessagesList"); messagesToAdd.forEach(msg => { addMessageToDOM(messageList, msg, scrollContainer); }); } if (!bannedModal?.isVisible()) toggleRedDot(banIcon, true); } } else { customLog.log(partHeader, parts); } if (isChatCounterEnabled) totalChatCount++; } }; const shouldBlockMessage = (packet) => { if (!isBlockWordsEnabled && !isHideDuplicateChatEnabled) return false; if (!packet) return false; const { parts, partHeader } = packet; if (parts.length < 20) { if (partHeader.startsWith('0005000')) { // 일반 채팅 const messageText = parts[1]; if (!messageText) return false; if (isBlockWordsEnabled && checkMessageForBlocking(messageText)) { customLog.log('차단 단어 포함:', messageText); return true; } if (isHideDuplicateChatEnabled) { if (recentChatContentSet.has(messageText)) { customLog.log('중복 채팅 감지:', messageText); return true; } recentChatContentQueue.push(messageText); recentChatContentSet.add(messageText); if (recentChatContentQueue.length > RECENT_CHAT_LIMIT) { const removedMessage = recentChatContentQueue.shift(); if (removedMessage !== undefined) { recentChatContentSet.delete(removedMessage); } } } } } return false; }; unsafeWindow.WebSocket = function (url, protocols) { const ws = new OriginalWebSocket(url, protocols); if (targetUrlPattern.test(url)) { ws.addEventListener("message", (event) => { const packet = parseChatPacket(event.data); if (!packet) return; decodeMessage(packet); if (shouldBlockMessage(packet)) { event.stopImmediatePropagation(); } }, true); } return ws; }; unsafeWindow.WebSocket.prototype = OriginalWebSocket.prototype; const createIcon = (type, onClick) => { const icon = document.createElement("div"); icon.className = `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 (!bannedModal) return; if (bannedModal.isVisible()) { bannedModal.hide(); } else { toggleRedDot(banIcon, false); bannedModal.show(); } }; const showTargetMessages = () => { if (!targetModal) return; if (targetModal.isVisible()) { targetModal.hide(); } else { toggleRedDot(highlightIcon, false); const newTitle = `채팅 모아보기 (즐찾 ${allFollowUserIds.length}명, 수동 ${selectedUsersArray.length}명${isCheckBestStreamersListEnabled ? `, 베스 ${bestStreamersList.length}명` : ''})`; targetModal.setTitle(newTitle); targetModal.show(); } }; const resetChatData = () => { messageHistory.length = bannedMessages.length = targetUserMessages.length = 0; recentChatContentQueue.length = 0; recentChatContentSet.clear(); if (bannedModal) { const bannedList = bannedModal.getContentElement().querySelector("#bannedMessagesList"); if (bannedList) { clearMessageRenderQueue(bannedList); bannedList.innerHTML = '
      • 메시지가 없습니다.
      • '; } } if (targetModal) { const targetList = targetModal.getContentElement().querySelector("#targetUserMessagesList"); if (targetList) { clearMessageRenderQueue(targetList); targetList.innerHTML = '
      • 메시지가 없습니다.
      • '; } } toggleRedDot(banIcon, false); toggleRedDot(highlightIcon, false); }; unsafeWindow.resetChatData = resetChatData; if (isShowDeletedMessagesEnabled) banIcon = createIcon("trash", showBannedMessages); if (isShowSelectedMessagesEnabled) highlightIcon = createIcon("highlight", showTargetMessages); }; // VOD 전용 채팅 단어 차단 const observeChatForBlockingWords = (elementSelector, elem) => { if (!isBlockWordsEnabled || !REG_WORDS || REG_WORDS.length === 0) { return; } const observer = new MutationObserver((mutations) => { // 발생한 모든 변화(mutation)를 순회합니다. mutations.forEach(({ addedNodes }) => { // 각 변화에서 추가된 노드(addedNodes)들을 순회합니다. addedNodes.forEach(node => { // 추가된 노드가 HTML 요소가 아니면 건너뜁니다. if (node.nodeType !== Node.ELEMENT_NODE) return; const messages = node.querySelectorAll('div.message-text > p.msg'); if (messages.length === 0) return; // 찾은 모든 메시지(NodeList)에 대해 차단 여부를 확인합니다. messages.forEach(messageElement => { const messageText = messageElement.textContent.trim(); if (checkMessageForBlocking(messageText)) { const listItem = messageElement.closest('.chatting-list-item'); if (listItem) { customLog.log(messageElement.innerText); listItem.remove(); } } }); }); }); }); // 지정된 요소(elem)에 대해 자식 노드 추가/제거 및 하위 트리 전체를 감시합니다. observer.observe(elem, { childList: true, subtree: true }); }; // 전역 변수 영역에 추가 let blockWordsRegex = null; let exactBlockWords = new Set(); // 채팅 단어 차단 관련 로직이 활성화 될 때 아래 함수를 호출하여 정규식을 미리 생성합니다. const compileBlockRules = () => { if (!isBlockWordsEnabled || REG_WORDS.length === 0) { blockWordsRegex = null; exactBlockWords.clear(); return; } const containWords = []; exactBlockWords.clear(); // 'e:' 접두사에 따라 단어를 분리 REG_WORDS.forEach(word => { if (word.startsWith("e:")) { exactBlockWords.add(word.slice(2)); } else if (word) { // 정규식에 안전한 형태로 단어 변환 containWords.push(word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); } }); // '포함' 단어들에 대한 정규식 생성 (하나라도 있으면) if (containWords.length > 0) { blockWordsRegex = new RegExp(containWords.join('|'), 'i'); // i 플래그로 대소문자 무시 } else { blockWordsRegex = null; } }; // shouldBlockMessage 또는 deleteMessages 함수 내부의 확인 로직을 아래와 같이 변경 const checkMessageForBlocking = (messageText) => { if (!isBlockWordsEnabled) return false; // 1. 정확히 일치하는 단어 확인 (Set을 사용해 더 빠름) if (exactBlockWords.has(messageText)) { return true; } // 2. 포함하는 단어 확인 (정규식 사용) if (blockWordsRegex && blockWordsRegex.test(messageText)) { return true; } return false; }; const showSidebarOnMouseOver = () => { disposeSidebarMouseOverHandlers?.(); const body = document.body; if (!body) return; clearScreenModeSidebarLayout(); const isPointInsideRect = (x, y, rect) => x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; const getTriggerRect = () => { // ★ 트리거 영역 크기 조절 const TRIGGER_RIGHT = 50; // 좌측 기준 우측 끝까지 너비 (px) // LIVE 플레이어 영역 높이의 2/3 지점을 동적으로 계산 const playerContents = document.getElementById('webplayer_contents'); let triggerBottom; if (playerContents) { const rect = playerContents.getBoundingClientRect(); triggerBottom = rect.top + rect.height * (2 / 3); } else { triggerBottom = window.innerHeight * (2 / 3); } return { left: 0, top: 0, right: TRIGGER_RIGHT, bottom: triggerBottom }; }; const isInsideTriggerZone = (event) => { return isPointInsideRect(event.clientX, event.clientY, getTriggerRect()); }; const isInsideSidebar = (event) => { const sidebar = document.getElementById('sidebar'); if (!sidebar || !body.classList.contains('showSidebar')) return false; return isPointInsideRect(event.clientX, event.clientY, sidebar.getBoundingClientRect()); }; const mouseMoveHandler = (event) => { if (!body.classList.contains('screen_mode') || body.classList.contains('fullScreen_mode')) { setScreenModeSidebarOpen(false); return; } if (isInsideTriggerZone(event) || isInsideSidebar(event)) { setScreenModeSidebarOpen(true); } else if (body.classList.contains('showSidebar')) { setScreenModeSidebarOpen(false); } }; const windowMouseOutHandler = (event) => { if (!event.relatedTarget && !event.toElement) { setScreenModeSidebarOpen(false); } }; document.addEventListener('mousemove', mouseMoveHandler); window.addEventListener('mouseout', windowMouseOutHandler); // 창 벗어남 감지 // 사이드바 요소에 직접 mouseleave 등록 (더 빠르고 확실한 숨김 처리) const sidebarEl = document.getElementById('sidebar'); const sidebarMouseLeaveHandler = (event) => { if (!body.classList.contains('screen_mode') || body.classList.contains('fullScreen_mode')) return; if (isInsideTriggerZone(event)) return; // trigger zone으로 이동하면 유지 setScreenModeSidebarOpen(false); }; if (sidebarEl) { sidebarEl.addEventListener('mouseleave', sidebarMouseLeaveHandler); } disposeSidebarMouseOverHandlers = () => { document.removeEventListener('mousemove', mouseMoveHandler); window.removeEventListener('mouseout', windowMouseOutHandler); if (sidebarEl) sidebarEl.removeEventListener('mouseleave', sidebarMouseLeaveHandler); }; }; 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 { customLog.error('Label element not found.'); } }; const toggleExpandChat = async () => { if (!isElementVisible('.expand-toggle-li')) return; try { const toggleLink = await waitForElementAsync('.expand-toggle-li a'); if (toggleLink) toggleLink.click(); } catch (error) { customLog.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); customLog.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 () => { const headerAd = await waitForElementAsync('#header_ad', 5000); if (headerAd) { headerAd.remove(); } else { customLog.log("헤더 광고가 없습니다. (정상)"); } }; 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(); customLog.log("탭이 비활성화됨, 음소거"); } } else { // 탭이 활성화됨 if (button.classList.contains("mute")) { button.click(); customLog.log("탭이 활성화됨, 음소거 해제"); } } }; const registerVisibilityChangeHandler = () => { document.removeEventListener('visibilitychange', handleMuteByVisibility, true); document.addEventListener('visibilitychange', handleMuteByVisibility, true); }; const handleVisibilityChangeForQuality = async () => { if (!isAutoChangeQualityEnabled || isVideoInPiPMode()) return; if (document.hidden) { customLog.log("[탭 상태] 비활성화됨"); previousQualityBeforeDowngrade = getCurrentInternalQuality(); previousIsAutoMode = getIsAutoQualityMode(); if (!previousQualityBeforeDowngrade) { customLog.warn("[현재 화질] 정보를 가져오지 못함"); } else { customLog.log(`[현재 화질 저장] ${previousQualityBeforeDowngrade} (자동모드: ${previousIsAutoMode})`); } qualityChangeTimeout = setTimeout(async () => { await changeQualityLivePlayer('LOW'); // LOW = 최저화질 didChangeToLowest = true; customLog.log("[타이머 실행] 최저화질로 전환됨"); }, 6500); customLog.log("[타이머] 몇 초 후 최저화질로 변경 예약됨"); } else { customLog.log("[탭 상태] 활성화됨"); if (qualityChangeTimeout) { clearTimeout(qualityChangeTimeout); qualityChangeTimeout = null; customLog.log("[타이머] 예약된 최저화질 변경 취소됨"); } if (didChangeToLowest && previousQualityBeforeDowngrade) { const current = getCurrentInternalQuality(); if (previousIsAutoMode) { if (getIsAutoQualityMode()) { customLog.log("[복귀] 이미 자동 모드이므로 변경 생략"); } else { await changeQualityLivePlayer('AUTO'); customLog.log("[복귀] 자동 모드 복원됨"); } } else { if (current === previousQualityBeforeDowngrade) { customLog.log(`[복귀] 현재 화질(${current})과 동일하여 복원 생략`); } else { await changeQualityLivePlayer(previousQualityBeforeDowngrade); customLog.log(`[복귀] 수동 화질 복원됨 → ${previousQualityBeforeDowngrade}`); } } } else { customLog.log("[복귀] 화질 변경 없었으므로 복원 생략"); } // 상태 초기화 didChangeToLowest = false; previousQualityBeforeDowngrade = null; previousIsAutoMode = null; } }; const registerVisibilityChangeHandlerForQuality = () => { document.removeEventListener('visibilitychange', handleVisibilityChangeForQuality, true); 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'); if (logoLink) logoLink.removeAttribute("target"); } catch (error) { customLog.error("로고 링크 처리 실패:", error); } }; const useBottomChat = () => { const toggleBottomChat = () => { const isPortrait = window.innerHeight *1.02 > window.innerWidth; const isScreenMode = document.body.classList.contains('screen_mode'); // screen_mode + 세로가 가로보다 긴 경우에만 bottomChat 클래스 적용 document.body.classList.toggle('bottomChat', isPortrait && isScreenMode); }; // resize 감지 window.addEventListener('resize', debounce(toggleBottomChat, 500)); // body 클래스 변화 감지 (screen_mode 진입/해제 시 재평가) observeClassChanges('body', toggleBottomChat); // #player_area가 생긴 뒤 초기 실행 waitForElementAsync('#player_area').then(() => { toggleBottomChat(); // screen_mode 진입이 늦을 경우를 대비해 500ms 후 재실행 setTimeout(toggleBottomChat, 500); }); }; const getViewersNumber = (raw = false) => { const element = document.querySelector('#nAllViewer'); if (!element) return raw ? 0 : '0'; const rawNumber = Number.parseInt(element.innerText.replace(/,/g, '').trim(), 10) || 0; 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 checkMediaInfo = async (mediaName, isAutoLevelEnabled) => { if (mediaName !== 'original' || isAutoLevelEnabled) { // 원본 화질로 설정되지 않은 경우 or 자동 화질 선택인 경우 const player = await waitForElementAsync('#player'); if (!player) return; player.className = 'video mouseover ctrl_output'; // 설정 버튼 클릭 const settingButton = await waitForElementAsync('#player > div.player_ctrlBox > div.ctrlBox > div.right_ctrl .setting_box > button.btn_setting'); if (!settingButton) return; settingButton.click(); // 화질 변경 리스트 대기 const settingList = await waitForElementAsync('#player > div.player_ctrlBox > div.ctrlBox > div.right_ctrl .setting_box.on .setting_list'); if (!settingList) return; const spanElement = Array.from(settingList.querySelectorAll('span')).find(el => el.textContent.includes("화질 변경")); if (!spanElement) return; const buttonElement = spanElement.closest('button'); if (!buttonElement) return; 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'); if (!resolutionButton) return; resolutionButton.click(); resolutionButton.className = 'video'; } }; const getCurrentInternalQuality = () => { try { const playerInfo = unsafeWindow.LivePlayer.getPlayerInfo(); return playerInfo?.quality || null; } catch (e) { customLog.warn("[getCurrentInternalQuality] 오류 발생:", e); return null; } }; const getIsAutoQualityMode = () => { try { const playerInfo = unsafeWindow.LivePlayer.getPlayerInfo(); return !!playerInfo?.qualityInfo?.isAuto; } catch (e) { customLog.warn("[getIsAutoQualityMode] 오류 발생:", e); return false; } }; const changeQualityLivePlayer = async (qualityName) => { const current = getCurrentInternalQuality(); if (current === qualityName) { customLog.log(`[화질 변경 스킵] 현재(${current}) = 요청(${qualityName})`); return; } try { unsafeWindow.livePlayer.changeQuality(qualityName); customLog.log(`[화질 변경] → ${qualityName}`); } catch (e) { customLog.warn("[changeQualityLivePlayer] 변경 실패:", e); } }; // [기능 수정] 화질 고정 및 1440p 차단 통합 함수(03.25 수정) const downgradeFrom1440p = async (retryCount = 0) => { const MAX_RETRY = 10; const RETRY_DELAY = 3000; try { const livePlayer = await waitForLivePlayer(); const info = await livePlayer.getLiveInfo(); // VIEWPRESET이 아직 없으면 재시도 if (!info?.CHANNEL?.VIEWPRESET || info.CHANNEL.VIEWPRESET.length === 0) { if (retryCount < MAX_RETRY) { customLog.warn(`[화질 고정] VIEWPRESET 없음. ${RETRY_DELAY/1000}초 후 재시도 (${retryCount + 1}/${MAX_RETRY})`); setTimeout(() => downgradeFrom1440p(retryCount + 1), RETRY_DELAY); } return; } const presets = info.CHANNEL.VIEWPRESET.filter(p => p.name !== 'auto' && p.bps); if (presets.length === 0) { if (retryCount < MAX_RETRY) { customLog.warn(`[화질 고정] 유효한 화질 없음. 재시도 (${retryCount + 1}/${MAX_RETRY})`); setTimeout(() => downgradeFrom1440p(retryCount + 1), RETRY_DELAY); } return; } const targetQ = GM_getValue("targetQuality", "원본"); const isNo1440p = GM_getValue("isNo1440pEnabled", 0); let targetPreset = null; if (targetQ === "원본") { const sorted = presets.sort((a, b) => b.bps - a.bps); const bestPreset = sorted[0]; if (isNo1440p && bestPreset.label_resolution === '1440') { targetPreset = sorted[1] || bestPreset; customLog.log(`[화질 고정] 1440p 차단으로 ${targetPreset.label_resolution} 선택`); } else { targetPreset = bestPreset; } } else { targetPreset = presets.find(p => p.label_resolution === targetQ); if (!targetPreset) { customLog.warn(`[화질 고정] 설정 화질(${targetQ}) 없음. 최고화질로 대체`); targetPreset = presets.reduce((prev, curr) => prev.bps > curr.bps ? prev : curr); } } if (!targetPreset) { if (retryCount < MAX_RETRY) { setTimeout(() => downgradeFrom1440p(retryCount + 1), RETRY_DELAY); } return; } const targetName = qualityNameToInternalType[targetPreset.name]; if (!targetName) { customLog.warn(`[화질 고정] 화질 매핑 실패: ${targetPreset.name}`); return; } // 현재 화질 확인 후 동일하면 스킵 const currentQuality = getCurrentInternalQuality(); if (currentQuality === targetName) { customLog.log(`[화질 고정] 이미 목표 화질(${targetName}) 적용 중. 스킵`); return; } // 탭 전환 화질 낮추기가 켜져있고 현재 비활성화 탭인 경우 if (isAutoChangeQualityEnabled && document.hidden) { customLog.log(`[화질 고정] 백그라운드 탭. 복구 목표: ${targetName}, 즉시 최저화질 적용`); previousQualityBeforeDowngrade = targetName; previousIsAutoMode = false; didChangeToLowest = true; livePlayer.changeQuality('LOW'); } else { customLog.log(`[화질 고정] 화질 변경: ${targetPreset.label_resolution} (${targetName})`); livePlayer.changeQuality(targetName); // 적용 확인 후 실패 시 재시도 setTimeout(async () => { const applied = getCurrentInternalQuality(); if (applied !== targetName && retryCount < MAX_RETRY) { customLog.warn(`[화질 고정] 적용 실패(현재: ${applied}, 목표: ${targetName}). 재시도 (${retryCount + 1}/${MAX_RETRY})`); downgradeFrom1440p(retryCount + 1); } else { customLog.log(`[화질 고정] 적용 확인: ${applied}`); } }, 2000); } } catch (e) { customLog.error('[화질 고정] 오류: ' + e.message); if (retryCount < MAX_RETRY) { customLog.warn(`[화질 고정] ${RETRY_DELAY/1000}초 후 재시도 (${retryCount + 1}/${MAX_RETRY})`); setTimeout(() => downgradeFrom1440p(retryCount + 1), RETRY_DELAY); } } }; 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')) { customLog.log('화질 목록을 찾을 수 없어 단축키 설정을 건너뜁니다.'); return; } customLog.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) { customLog.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) { customLog.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); }); } const updateBestStreamersList = () => { const sharedDataKey = 'sharedBestStreamersData'; const publicDataString = localStorage.getItem(sharedDataKey); if (!publicDataString) { customLog.log('[스크립트 B] 공유된 데이터가 아직 없습니다. 실행을 종료합니다.'); return; } const myPrivateData = GM_getValue('bestStreamersList', []); const myPrivateDataString = JSON.stringify(myPrivateData); if (publicDataString !== myPrivateDataString) { customLog.log('[스크립트 B] 새로운 데이터를 발견했습니다! 저장소를 업데이트합니다.'); const newPublicDataArray = JSON.parse(publicDataString); GM_setValue('bestStreamersList', newPublicDataArray); customLog.log('[스크립트 B] GM_setValue로 새 데이터를 저장했습니다:', newPublicDataArray); } else { customLog.log('[스크립트 B] 이미 최신 데이터를 가지고 있습니다. 업데이트가 불필요합니다.'); } } /** * 탭 동기화 기능을 관리하는 매니저 객체를 생성하고 반환합니다. * @param {object} options - 설정 객체 * @param {function(string[]): void} [options.onUpdate] - 탭 목록이 변경될 때마다 호출될 콜백 함수. URL 배열을 인자로 받습니다. * @param {string} [options.urlPattern] - 유저 ID와 방송 ID를 감지할 URL 패턴. 예: "/play/{userId}/{broadcastId}" * @param {number} [options.heartbeatIntervalMs=5000] - Heartbeat 주기 (밀리초) * @param {number} [options.timeoutMs=10000] - 탭 만료 시간 (밀리초) * @returns {{isTargetTabOpen: (function(string, string): boolean), getActiveTabs: (function(): string[]), destroy: (function(): void)}} */ function createTabSyncManager(options = {}) { // --- 1. 설정 및 내부 상태 변수 --- const { onUpdate, urlPattern = "/{userId}/{broadcastId}", // 기본 URL 패턴 정의 heartbeatIntervalMs = 5000, timeoutMs = 10000 } = options; const channel = new BroadcastChannel("sooplive_tab_tracker"); const tabId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const activeTabs = {}; // 다른 탭들의 정보 let currentUrls = []; // 자기 자신을 포함한 전체 URL 목록 (내부 상태) // --- 2. 내부 헬퍼 함수 --- const now = () => Date.now(); const debounce = (func, delay) => { let timeoutId; return (...args) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => func(...args), delay); }; }; const broadcast = (type) => { // 현재 URL을 항상 최신으로 유지 channel.postMessage({ type, tabId, url: location.href, timestamp: now() }); }; const _updateListeners = () => { // 만료된 탭 정리 const cutoff = now() - timeoutMs; for (const id in activeTabs) { if (activeTabs[id].lastSeen < cutoff) delete activeTabs[id]; } // 최신 URL 목록 생성 const allUrls = [location.href, ...Object.values(activeTabs).map(({ url }) => url)]; currentUrls = [...new Set(allUrls)]; // 내부 상태 업데이트 // 외부 콜백 호출 if (typeof onUpdate === 'function') { onUpdate(currentUrls); } }; const updateListeners = debounce(_updateListeners, 100); // --- 3. 이벤트 핸들러 및 초기화 --- channel.onmessage = (e) => { const { type, tabId: senderId, url, timestamp } = e.data || {}; if (!senderId || !url || senderId === tabId) return; if (type === "join" || type === "heartbeat") activeTabs[senderId] = { url, lastSeen: timestamp }; else if (type === "leave") delete activeTabs[senderId]; updateListeners(); }; const intervalId = setInterval(() => broadcast("heartbeat"), heartbeatIntervalMs); const handleBeforeUnload = () => destroy(); window.addEventListener("beforeunload", handleBeforeUnload); let isDestroyed = false; // 초기 진입 메시지 및 상태 업데이트 broadcast("join"); updateListeners(); // --- 4. 외부로 공개될 API 메소드 --- /** * 특정 방송 탭이 열려 있는지 확인합니다. * @param {string} userId - 확인할 유저 아이디 * @param {string} broadcastId - 확인할 방송 번호 * @returns {boolean} */ function isTargetTabOpen(userId, broadcastId) { if (!userId || !broadcastId) return false; // urlPattern을 기반으로 실제 찾을 경로 조각을 만듭니다. const targetPath = urlPattern .replace('{userId}', userId) .replace('{broadcastId}', broadcastId); return currentUrls.some(url => url.includes(targetPath)); } /** * 현재 활성화된 모든 탭의 URL 목록을 반환합니다. * @returns {string[]} */ function getActiveTabs() { return [...currentUrls]; // 외부에서 수정하지 못하도록 복사본 반환 } /** * 모든 동기화 작업을 중지하고 리소스를 정리합니다. */ function destroy() { if (isDestroyed) return; isDestroyed = true; window.removeEventListener("beforeunload", handleBeforeUnload); broadcast("leave"); clearInterval(intervalId); channel.close(); // 필요하다면 onUpdate 콜백도 null 처리 customLog.log("TabSyncManager가 종료되었습니다."); } // --- 5. API 객체 반환 --- return { isTargetTabOpen, getActiveTabs, destroy, }; } const getSharedTabSyncManager = (urlPattern = "play.sooplive.com/{userId}/{broadcastId}") => { if (!sharedTabSyncManager) { sharedTabSyncManager = createTabSyncManager({ urlPattern }); } return sharedTabSyncManager; }; // 3.6. 스크립트 실행 관리 함수 (Execution Management) const runCommonFunctions = () => { if (isCustomSidebarEnabled) { hideUsersSection(); generateBroadcastElements(0); checkSidebarVisibility(); } setupSettingButtonTopbar(); if (isMonthlyRecapEnabled) observeAndAppendRecapButton(); pinnedCategories.forEach(function (category) { registerCategoryUnpinMenu(category); }); updateBestStreamersList(); }; 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) { customLog.error('target 속성 제거 중 오류 발생:', error); } }; class StreamerActivityScanner { #STREAMER_ID_LIST; #vodCore; #streamerActivityLog = []; #isScanCompleted = false; #controlButton = null; #abortController = null; #modal = null; constructor(vodCore, targetIds) { if (!vodCore) throw new Error("vodCore 객체가 필요합니다."); if (!targetIds) throw new Error("타겟 ID 목록이 필요합니다."); this.#vodCore = vodCore; this.#STREAMER_ID_LIST = targetIds; this.#modal = new DraggableResizableModal('streamer-activity-scanner', '채팅 로그'); this.#setupControlButton(); } 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; } // [수정] 버튼 클릭 시 토글 동작을 하도록 onclick 핸들러 변경 #setupControlButton() { const chatWrap = document.querySelector('.chatting-item-wrap'); if (chatWrap) { this.#controlButton = document.createElement("button"); this.#controlButton.id = "sa-control-btn"; this.#controlButton.className = "chat-icon highlight-icon"; this.#controlButton.onclick = () => { if (this.#isScanCompleted) { // 스캔 완료 후: 모달이 보이면 숨기고, 아니면 보여줌 this.#modal.isVisible() ? this.hidePanel() : this.showPanel(); } else { // 스캔 전: 스캔 시작 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"; this.#controlButton.style.fontSize = "8px"; } #showNotification(message, isError = false) { this.#modal?.showNotification(message, isError); } async startScan() { this.#abortController?.abort(); this.#abortController = new AbortController(); const signal = this.#abortController.signal; try { const streamerCount = this.#STREAMER_ID_LIST.size; this.#updateButton(`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(`${progress}`, true); const url = item.fileInfoKey.includes("clip_") ? `https://vod-normal-kr-cdn-z01.sooplive.com/${item.fileInfoKey.split("_").join("/")}_c.xml?type=clip&rowKey=${item.fileInfoKey}_c` : `https://videoimg.sooplive.com/php/ChatLoadSplit.php?rowKey=${item.fileInfoKey}_c`; for (let cs = 0; cs <= item.duration; cs += 300) { 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) { if (error.name === 'AbortError') { return; } this.#updateButton("", false); this.#showNotification("오류가 발생했습니다.", true); } } populatePanel() { const contentElement = this.#modal.getContentElement(); if (!contentElement) return; this.#modal.setTitle(`채팅 모아보기 (즐찾 ${allFollowUserIds.length}명, 수동 ${selectedUsersArray.length}명${isCheckBestStreamersListEnabled ? `, 베스 ${bestStreamersList.length}명` : ''}) (${this.#streamerActivityLog.length}개)`); if (this.#streamerActivityLog.length === 0) { contentElement.innerHTML = `
        검색된 스트리머 활동이 없습니다.
        `; return; } const list = document.createElement("ul"); list.style.cssText = 'list-style:none; padding:5px; margin:0;'; 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.com/LOGO/${userId.substring(0, 2)}/${userId}/${userId}.jpg`; const messageContent = type !== 'chat' ? ('🎈 ' + message) : message; item.style.cssText = '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;'; if (type !== 'chat') item.style.fontStyle = 'italic'; 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); }); contentElement.innerHTML = ''; contentElement.appendChild(list); contentElement.scrollTop = contentElement.scrollHeight; } showPanel() { if (!this.#isScanCompleted) return; this.populatePanel(); this.#modal.show(); } hidePanel() { this.#modal.hide(); } destroy() { this.#abortController?.abort('인스턴스 파괴'); this.#modal?.destroy(); this.#controlButton?.remove(); } }; class VODHighlightScanner { #API_URL = 'https://apisabana.sooplive.com/service/vod_star2_stats.php'; #CHAPTER_API_URL = 'https://stbbs.sooplive.com/api/chapter/Controllers/ChapterListController.php'; #vodCore; #videoInfo = {}; #highlights = []; #isScanCompleted = false; #modal = null; #controlButton = null; constructor(vodCore, bbsNo) { if (!vodCore || !bbsNo) throw new Error("vodCore 또는 bbsNo 객체가 누락되었습니다."); this.#vodCore = vodCore; this.#videoInfo = { nTitleNo: vodCore.config.titleNo || vodCore.config.title_no, nStationNo: vodCore.config.stationNo || vodCore.config.station_no, nBbsNo: bbsNo, szLoginId: vodCore.config.loginId || '' }; if (!this.#videoInfo.nTitleNo || !this.#videoInfo.nStationNo || !this.#videoInfo.nBbsNo) { throw new Error(`필수 파라미터가 누락되었습니다: ${JSON.stringify(this.#videoInfo)}`); } this.#modal = new DraggableResizableModal('vod-highlight-scanner', 'VOD 하이라이트'); this.#setupControlButton(); } static #secondsToHMS(seconds) { seconds = Math.floor(seconds); const h = String(Math.floor(seconds / 3600)).padStart(2, '0'); const m = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0'); const s = String(seconds % 60).padStart(2, '0'); return `[${h}:${m}:${s}]`; } // [수정] 버튼 클릭 시 토글 동작을 하도록 onclick 핸들러 변경 #setupControlButton() { const chatWrap = document.querySelector('.chatting-item-wrap'); if (chatWrap) { this.#controlButton = document.createElement("button"); this.#controlButton.id = "hl-control-btn"; this.#controlButton.className = "chat-icon statistics-icon_54334 statistics"; this.#controlButton.onclick = () => { if (this.#isScanCompleted) { this.#modal.isVisible() ? this.hidePanel() : this.showPanel(); } else { this.startScan(); } }; chatWrap.appendChild(this.#controlButton); this.#updateButton('', false); } } #updateButton(text, disabled) { if (this.#controlButton) { this.#controlButton.textContent = text; this.#controlButton.disabled = disabled; } } #showNotification(message, isError = false) { this.#modal?.showNotification(message, isError); } async startScan() { if (!this.#videoInfo.szLoginId) { this.showPanel(); this.#showNotification("비로그인 (일부 기능 제한)", false, 5000); } this.#updateButton("", true); this.#highlights = []; try { const chapterApiUrl = `${this.#CHAPTER_API_URL}?nTitleNo=${this.#videoInfo.nTitleNo}&szFileType=REVIEW`; const chapterPromise = fetch(chapterApiUrl, { credentials: 'include' }).then(res => res.json()); const menuParams = new URLSearchParams({ szAction: 'list', nDeviceType: '1', szSysType: 'html5', nTitleNo: this.#videoInfo.nTitleNo, szLang: 'ko_KR', szLoginId: this.#videoInfo.szLoginId }); const menuPromise = fetch(this.#API_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: menuParams.toString(), credentials: 'include' }).then(res => res.json()); const [chapterResult, menuData] = await Promise.all([chapterPromise, menuPromise]); if (chapterResult?.result === 1 && chapterResult.data) { chapterResult.data.forEach(chapter => { this.#highlights.push({ seconds: chapter.time_sec, description: `[🚩챕터] ${chapter.title}` }); }); } if (menuData?.result === 1 && menuData.data) { const excludedModules = new Set(['BjFavView', 'BjHappy', 'BjUpCnt']); const dataPromises = menuData.data .filter(module => !excludedModules.has(module.module_name)) .map(module => { const viewParams = new URLSearchParams({ szAction: 'view', nDeviceType: '1', nTitleNo: this.#videoInfo.nTitleNo, szLang: 'ko_KR', nStationNo: this.#videoInfo.nStationNo, nBbsNo: this.#videoInfo.nBbsNo, szType: module.data_type === "1" ? 'user' : 'bj', szModule: module.module_name, nIdx: module.idx, szSysType: 'html5', szLoginId: this.#videoInfo.szLoginId }); return fetch(this.#API_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: viewParams.toString(), credentials: 'include' }).then(res => res.json()).then(data => ({ module, data })); }); const allData = await Promise.all(dataPromises); for (const { module, data } of allData) { if (data.result !== 1 || !data.data) continue; const { title } = module; if (data.data.cnt && Array.isArray(data.data.cnt) && data.data.cnt.length > 0) { let overallPeak = { minute: -1, value: -1 }; for (const [minute, value] of data.data.cnt) { if (value > overallPeak.value) { overallPeak = { minute, value }; } } if (overallPeak.minute !== -1) { const unit = title.includes('채팅') ? '개' : '명'; const description = `🚀 최고 ${title.replace(' 그래프', '')}: ${overallPeak.value.toLocaleString()}${unit}`; this.#highlights.push({ seconds: overallPeak.minute * 60, description }); } } else if (Array.isArray(data.data) && data.data.length > 0 && data.data[0]?.hasOwnProperty('duration')) { data.data.forEach(item => { this.#highlights.push({ seconds: item.duration, description: title }); }); } } } this.#highlights.sort((a, b) => a.seconds - b.seconds); this.#isScanCompleted = true; this.#updateButton("", false); this.#showNotification(`분석 완료! (${this.#highlights.length}개)`); this.showPanel(); } catch (error) { this.#updateButton("", false); this.#showNotification(error.message, true); } } populatePanel() { const contentElement = this.#modal.getContentElement(); if (!contentElement) return; this.#modal.setTitle(`VOD 하이라이트 (${this.#highlights.length}개)`); if (this.#highlights.length === 0) { contentElement.innerHTML = `
        분석된 하이라이트가 없습니다.
        `; return; } const list = document.createElement("ul"); list.style.cssText = 'list-style:none; padding:5px; margin:0;'; this.#highlights.forEach(activity => { const item = document.createElement("li"); item.style.cssText = 'display:flex; gap:12px; align-items:flex-start; padding:8px 10px; border-radius:4px; font-size:15px;'; item.innerHTML = ` ${VODHighlightScanner.#secondsToHMS(activity.seconds)}
        ${activity.description}
        `; item.querySelector('.timestamp').onclick = () => { this.#vodCore.seek(activity.seconds); }; list.appendChild(item); }); contentElement.innerHTML = ''; contentElement.appendChild(list); } showPanel() { this.populatePanel(); this.#modal.show(); } hidePanel() { this.#modal.hide(); } destroy() { this.#modal?.destroy(); this.#controlButton?.remove(); } }; // 다른 스크립트와의 CSS 클래스 이름 충돌을 방지하기 위해 페이지 로드 시 한 번만 고유한 접미사를 생성합니다. const uniqueStyleSuffix = Math.random().toString(36).substring(2, 8); /** * 기본 클래스 이름에 고유한 접미사를 추가하여 스코프가 지정된 CSS 클래스 이름을 반환합니다. * @param {string} baseName 기본 클래스 이름 * @returns {string} 고유한 접미사가 추가된 클래스 이름 (예: 'modal-header-a1b2c3') */ const scopedClass = (baseName) => `${baseName}-${uniqueStyleSuffix}`; /** * 드래그 및 크기 조절이 가능한 재사용 가능한 모달 클래스입니다. * 위치, 크기, 표시 상태를 관리하고 localStorage에 상태를 저장합니다. * CSS 클래스 이름에 고유한 접미사를 사용하여 스타일 충돌을 방지합니다. */ class DraggableResizableModal { #options; #modalElement = null; #headerElement = null; #contentElement = null; #resizeHandleElement = null; #closeButton = null; #titleElement = null; #id = ''; #localStorageKey = ''; #initialState = {}; #notificationElement = null; #notificationTimeout = null; constructor(id, title, initialState = {}, options = {}) { this.#id = id; this.#localStorageKey = `MODAL_STATE_${this.#id}`; this.#initialState = { width: '400px', height: '400px', top: '150px', right: '150px', left: 'auto', ...initialState }; this.#options = { ignoreSavedState: false, ...options }; // 옵션 저장 this.#init(title); } #init(title) { this.#addStyles(); this.#modalElement = document.createElement('div'); this.#modalElement.id = this.#id; this.#modalElement.className = scopedClass('draggable-modal'); this.#modalElement.style.display = 'none'; this.#modalElement.innerHTML = `
        ${title}
        `; document.body.appendChild(this.#modalElement); this.#headerElement = this.#modalElement.querySelector(`.${scopedClass('modal-header')}`); this.#contentElement = this.#modalElement.querySelector(`.${scopedClass('modal-content')}`); this.#resizeHandleElement = this.#modalElement.querySelector(`.${scopedClass('modal-resize-handle')}`); this.#closeButton = this.#modalElement.querySelector(`.${scopedClass('modal-close-btn')}`); this.#titleElement = this.#modalElement.querySelector(`.${scopedClass('modal-header-title')}`); this.#notificationElement = this.#modalElement.querySelector(`.${scopedClass('modal-notification')}`); this.#closeButton.onclick = () => this.hide(); this.#initDraggableAndResizable(); this.#loadState(); this.#handleScrollLock(); // 스크롤 잠금 핸들러 활성화 } #addStyles() { const styleId = `draggable-modal-styles-${uniqueStyleSuffix}`; if (document.getElementById(styleId)) return; GM_addStyle(` .${scopedClass('draggable-modal')} { 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: 300px; min-height: 200px; position: fixed; overflow: hidden; } .${scopedClass('modal-header')} { padding: 10px 15px; background-color: #2a2a2e; cursor: move; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #444; color: #fff; } .${scopedClass('modal-header-title')} { font-weight: bold; pointer-events: none; flex-grow: 1; } .${scopedClass('modal-close-btn')} { background: none; border: none; color: #aaa; font-size: 20px; cursor: pointer; line-height: 1; margin-left: 10px; } .${scopedClass('modal-close-btn')}:hover { color: #fff; } .${scopedClass('modal-content')} { flex-grow: 1; overflow-y: auto; padding: 10px; background-color: #18181b; } .${scopedClass('modal-resize-handle')} { position: absolute; right: 0; bottom: 0; width: 15px; height: 15px; cursor: se-resize; z-index: 10000; } .${scopedClass('modal-resize-handle')}::after { content: ''; position: absolute; right: 2px; bottom: 2px; width: 8px; height: 8px; background: linear-gradient(135deg, transparent 40%, #888 40%, #888 60%, transparent 60%); pointer-events: none; } .${scopedClass('modal-notification')} { color: #6bff96; font-size: 13px; font-weight: bold; opacity: 0; transition: opacity 0.5s; pointer-events: none; text-align: right; margin: 0 10px; } /* ✨ [수정됨] 스크롤 잠금을 위한 CSS 클래스 */ .modal-scroll-lock { overflow: hidden !important; } `).id = styleId; } /** * 모달 위에 마우스가 있을 때 body 스크롤을 막는 메서드 (클래스 기반) */ #handleScrollLock() { const scrollLockClass = 'modal-scroll-lock'; const htmlEl = document.documentElement; const bodyEl = document.body; this.#modalElement.addEventListener('mouseenter', () => { if (!this.isVisible()) return; htmlEl.classList.add(scrollLockClass); bodyEl.classList.add(scrollLockClass); }); this.#modalElement.addEventListener('mouseleave', () => { htmlEl.classList.remove(scrollLockClass); bodyEl.classList.remove(scrollLockClass); }); } #initDraggableAndResizable() { const panel = this.#modalElement; const header = this.#headerElement; const resizeHandle = this.#resizeHandleElement; let isDragging = false, isResizing = false, initial = {}; const onDrag = (e) => { e.preventDefault(); if (isDragging) { let newLeft = e.clientX - initial.x; let newTop = e.clientY - initial.y; const maxLeft = window.innerWidth - panel.offsetWidth; const maxTop = window.innerHeight - panel.offsetHeight; newLeft = Math.max(0, Math.min(newLeft, maxLeft)); newTop = Math.max(0, Math.min(newTop, maxTop)); panel.style.top = `${newTop}px`; panel.style.left = `${newLeft}px`; panel.style.right = 'auto'; } if (isResizing) { const maxWidth = window.innerWidth - panel.offsetLeft; const maxHeight = window.innerHeight - panel.offsetTop; let newWidth = initial.w + (e.clientX - initial.x); let newHeight = initial.h + (e.clientY - initial.y); newWidth = Math.max(300, Math.min(newWidth, maxWidth)); newHeight = Math.max(200, Math.min(newHeight, maxHeight)); panel.style.width = `${newWidth}px`; panel.style.height = `${newHeight}px`; } }; const stopActions = () => { if (isDragging || isResizing) this.#saveState(); isDragging = isResizing = false; document.documentElement.style.userSelect = ''; window.removeEventListener('mousemove', onDrag); window.removeEventListener('mouseup', stopActions); }; header.addEventListener('mousedown', (e) => { if (e.target.closest(`.${scopedClass('modal-close-btn')}`)) return; isDragging = true; initial = { x: e.clientX - panel.offsetLeft, y: e.clientY - panel.offsetTop }; document.documentElement.style.userSelect = 'none'; window.addEventListener('mousemove', onDrag); window.addEventListener('mouseup', stopActions); }); resizeHandle.addEventListener('mousedown', (e) => { isResizing = true; initial = { x: e.clientX, y: e.clientY, w: panel.offsetWidth, h: panel.offsetHeight }; document.documentElement.style.userSelect = 'none'; e.preventDefault(); e.stopPropagation(); window.addEventListener('mousemove', onDrag); window.addEventListener('mouseup', stopActions); }); } #saveState() { const state = { width: this.#modalElement.style.width, height: this.#modalElement.style.height, top: this.#modalElement.style.top, left: this.#modalElement.style.left, right: this.#modalElement.style.right, }; localStorage.setItem(this.#localStorageKey, JSON.stringify(state)); } #loadState() { if (this.#options.ignoreSavedState) { Object.assign(this.#modalElement.style, this.#initialState); return; } let savedState; try { savedState = JSON.parse(localStorage.getItem(this.#localStorageKey)); } catch (e) { /* 무시 */ } if (savedState) { Object.assign(this.#modalElement.style, savedState); } else { Object.assign(this.#modalElement.style, this.#initialState); } } #resetPositionIfOffscreen() { this.#modalElement.style.visibility = 'hidden'; this.#modalElement.style.display = 'flex'; const rect = this.#modalElement.getBoundingClientRect(); this.#modalElement.style.display = 'none'; this.#modalElement.style.visibility = 'visible'; const isOffscreen = rect.bottom < 50 || rect.right < 50 || rect.top > window.innerHeight - 50 || rect.left > window.innerWidth - 50; if (isOffscreen) { Object.assign(this.#modalElement.style, this.#initialState); this.#saveState(); } } show() { this.#resetPositionIfOffscreen(); this.#modalElement.style.display = 'flex'; const modals = document.querySelectorAll(`.${scopedClass('draggable-modal')}`); const maxZ = Math.max(9999, ...Array.from(modals).map(el => parseFloat(window.getComputedStyle(el).zIndex) || 0)); this.#modalElement.style.zIndex = maxZ + 1; } hide() { this.#modalElement.style.display = 'none'; // ✨ [수정됨] 모달이 숨겨질 때 스크롤 잠금 클래스를 확실하게 제거 const scrollLockClass = 'modal-scroll-lock'; document.documentElement.classList.remove(scrollLockClass); document.body.classList.remove(scrollLockClass); } isVisible() { return this.#modalElement.style.display !== 'none'; } getContentElement() { return this.#contentElement; } setTitle(newTitle) { if (this.#titleElement) this.#titleElement.textContent = newTitle; } showNotification(message, isError = false, duration = 3000) { if (!this.#notificationElement) return; clearTimeout(this.#notificationTimeout); this.#notificationElement.textContent = message; this.#notificationElement.style.color = isError ? '#ff6b6b' : '#6bff96'; this.#notificationElement.style.opacity = '1'; this.#notificationTimeout = setTimeout(() => { this.#notificationElement.style.opacity = '0'; }, duration); } destroy() { clearTimeout(this.#notificationTimeout); // ✨ [수정됨] 모달이 제거되기 전에 스크롤 잠금 클래스를 확실하게 제거 const scrollLockClass = 'modal-scroll-lock'; document.documentElement.classList.remove(scrollLockClass); document.body.classList.remove(scrollLockClass); this.#modalElement?.remove(); } // [추가] 외부에서 모달 요소에 접근하기 위한 public 메서드 getModalElement() { return this.#modalElement; } } /** * ================================================================= * [최종] 영상 필터 전용 클래스 (PlayerAdvancedControls) * ================================================================= */ class PlayerAdvancedControls { #videoElement; #panelElement; #controlButton; #isPanelVisible = false; #filterSettings = { brightness: 100, contrast: 100, saturate: 100 }; // body 클릭 이벤트를 관리하기 위한 속성 #boundBodyClickListener = this.#handleBodyClick.bind(this); constructor(videoElement) { if (!videoElement) { customLog.error("[AdvControls] 비디오 요소를 찾을 수 없습니다."); return; } this.#videoElement = videoElement; this.#filterSettings = GM_getValue('filterSettings', this.#filterSettings); this.#createUI(); this.#addEventListeners(); this.#applyVideoFilters(); } #createUI() { const rightCtrl = document.querySelector('#player .player_ctrlBox .ctrlBox .right_ctrl'); if (rightCtrl) { this.#controlButton = document.createElement('button'); this.#controlButton.type = 'button'; this.#controlButton.className = 'btn_advanced_controls'; this.#controlButton.title = '영상 효과'; const captureButton = rightCtrl.querySelector('.imageCapture'); if (captureButton) { rightCtrl.insertBefore(this.#controlButton, captureButton); } else { rightCtrl.insertBefore(this.#controlButton, rightCtrl.firstChild); } } this.#panelElement = document.createElement('div'); this.#panelElement.className = 'advanced-controls-panel'; this.#panelElement.innerHTML = `
        영상 필터
        ${this.#createSliderGroup('brightness', '밝기', 50, 150, this.#filterSettings.brightness)} ${this.#createSliderGroup('contrast', '대비', 50, 150, this.#filterSettings.contrast)} ${this.#createSliderGroup('saturate', '채도', 50, 150, this.#filterSettings.saturate)}
        `; const playerDiv = document.getElementById('player'); if (playerDiv) playerDiv.appendChild(this.#panelElement); } #createSliderGroup(id, label, min, max, value) { return `
        ${value}%
        `; } #handleBodyClick(e) { if (this.#isPanelVisible && !this.#panelElement.contains(e.target)) { this.togglePanel(); } } #addEventListeners() { this.#controlButton?.addEventListener('click', (e) => { e.stopPropagation(); this.togglePanel(); }); this.#panelElement.addEventListener('click', (e) => e.stopPropagation()); this.#panelElement.querySelectorAll('input[type="range"]').forEach(slider => { slider.addEventListener('input', (e) => { const { filter } = e.target.dataset; const value = e.target.value; this.#filterSettings[filter] = Number(value); document.getElementById(`ac-value-${filter}`).textContent = `${value}%`; this.#applyVideoFilters(); }); slider.addEventListener('change', () => this.#saveSettings()); slider.addEventListener('dblclick', (e) => { e.target.value = 100; e.target.dispatchEvent(new Event('input')); e.target.dispatchEvent(new Event('change')); }); }); this.#panelElement.querySelector('.ac-reset-btn').addEventListener('click', () => { this.#filterSettings = { brightness: 100, contrast: 100, saturate: 100 }; this.#saveSettings(); this.#applyVideoFilters(); // UI도 리셋 for (const [key, value] of Object.entries(this.#filterSettings)) { const slider = this.#panelElement.querySelector(`#ac-slider-${key}`); const valueLabel = this.#panelElement.querySelector(`#ac-value-${key}`); if (slider) slider.value = value; if (valueLabel) valueLabel.textContent = `${value}%`; } }); } #applyVideoFilters() { const { brightness, contrast, saturate } = this.#filterSettings; this.#videoElement.style.filter = `brightness(${brightness}%) contrast(${contrast}%) saturate(${saturate}%)`; } #saveSettings() { GM_setValue('filterSettings', this.#filterSettings); } togglePanel() { this.#isPanelVisible = !this.#isPanelVisible; this.#panelElement.style.display = this.#isPanelVisible ? 'flex' : 'none'; if (this.#isPanelVisible) { document.body.addEventListener('click', this.#boundBodyClickListener); } else { document.body.removeEventListener('click', this.#boundBodyClickListener); } } destroy() { document.body.removeEventListener('click', this.#boundBodyClickListener); this.#controlButton?.remove(); this.#panelElement?.remove(); } }; class PlayerPanzoom { #videoElement; #videoContainer; #playerElement; #panzoomInstance = null; #controlsElement = null; #boundOnWheel; #roiElement = null; #isRoiActive = false; #videoOverlay; #roiModal = null; #drawLoopAnimationId = null; #resizeObserver = null; #roiSourceRect = { sx: 0, sy: 0, sWidth: 0, sHeight: 0 }; #lastUpdateTime = 0; #updateTimeout = null; isActive = false; constructor(videoElement, videoContainer, playerElement) { this.#videoElement = videoElement; this.#videoContainer = videoContainer; this.#playerElement = playerElement; this.#boundOnWheel = this.#onWheel.bind(this); this.#videoOverlay = document.getElementById('videoLayerCover'); } init() { if (this.isActive) return; customLog.log("Panzoom 및 ROI 기능을 초기화합니다."); if (this.#videoOverlay) this.#videoOverlay.style.display = 'none'; // [수정] classList 대신 dataset 사용 this.#playerElement.dataset.panzoomEnabled = 'true'; this.#videoContainer.style.overflow = 'hidden'; this.#panzoomInstance = Panzoom(this.#videoElement, { maxScale: 5, minScale: 1, contain: 'outside' }); this.#videoContainer.addEventListener('wheel', this.#boundOnWheel, { passive: false }); this.#addControls(); this.#createRoiElement(); this.isActive = true; } destroy() { if (!this.isActive) return; customLog.log("Disabling Panzoom and ROI functionality."); if (this.#videoOverlay) this.#videoOverlay.style.display = ''; delete this.#playerElement.dataset.panzoomEnabled; delete this.#playerElement.dataset.roiActive; this.#videoContainer.removeEventListener('wheel', this.#boundOnWheel); this.#panzoomInstance?.destroy(); this.#panzoomInstance = null; this.#controlsElement?.remove(); this.#controlsElement = null; this.#roiElement?.remove(); this.#roiElement = null; this.#roiModal?.destroy(); this.#roiModal = null; if (this.#drawLoopAnimationId) cancelAnimationFrame(this.#drawLoopAnimationId); this.#drawLoopAnimationId = null; this.#resizeObserver?.disconnect(); this.#resizeObserver = null; if (this.#updateTimeout) clearTimeout(this.#updateTimeout); this.#updateTimeout = null; this.#videoContainer.style.overflow = ''; this.#videoElement.style.transform = ''; this.isActive = false; this.#isRoiActive = false; } #onWheel(event) { if (!this.isActive || !this.#panzoomInstance) return; event.preventDefault(); this.#panzoomInstance.zoomWithWheel(event); } #addControls() { this.#controlsElement = document.createElement('div'); this.#controlsElement.id = 'zoom-controls-container'; this.#controlsElement.style.cssText = ` position: absolute; z-index: 1000; bottom: 80px; left: 20px; background: rgba(0,0,0,0.5); padding: 5px; border-radius: 5px; color: white; `; const buttons = [ { id: 'zoom-in-btn', title: 'Zoom In', text: '+', action: () => this.#panzoomInstance?.zoomIn() }, { id: 'zoom-out-btn', title: 'Zoom Out', text: '-', action: () => this.#panzoomInstance?.zoomOut() }, { id: 'zoom-reset-btn', title: 'Reset', text: 'Reset', action: () => this.#panzoomInstance?.reset() }, { id: 'toggle-roi-btn', title: 'Toggle Popup', text: 'Popup', action: () => this.#toggleRoiVisibility() } ]; buttons.forEach(({ id, title, text, action }) => { const button = document.createElement('button'); button.id = id; button.title = title; button.textContent = text; button.style.cssText = ` padding: 5px 10px; margin: 2px; color: white; background-color: #333; border: 1px solid #555; cursor: pointer; `; this.#controlsElement.appendChild(button); }); this.#controlsElement.addEventListener('click', (e) => { e.stopPropagation(); const button = buttons.find(b => b.id === e.target.id); if (button) button.action(); }); this.#playerElement.appendChild(this.#controlsElement); } #createRoiElement() { if (this.#roiElement) this.#roiElement.remove(); this.#roiElement = document.createElement('div'); this.#roiElement.id = 'roi-selector'; this.#roiElement.textContent = '더블클릭해서 팝업 열기'; this.#roiElement.style.cssText = ` position: absolute; border: 2px solid #32ff7e; background-color: rgba(50, 255, 126, 0.2); cursor: move; z-index: 1001; box-sizing: border-box; /* --- 텍스트 중앙 정렬 및 스타일을 위한 추가 CSS --- */ display: flex; justify-content: center; align-items: center; color: white; font-weight: bold; font-size: 14px; text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8); text-align: center; padding: 5px; /* 텍스트가 너무 길어질 경우를 대비 */ `; this.#videoContainer.appendChild(this.#roiElement); const resizeHandle = document.createElement('div'); resizeHandle.style.cssText = ` position: absolute; width: 15px; height: 15px; background-color: #32ff7e; right: -2px; bottom: -2px; cursor: se-resize; `; this.#roiElement.appendChild(resizeHandle); this.#makeDraggable(this.#roiElement); this.#makeResizable(this.#roiElement, resizeHandle); // 3. 더블클릭 이벤트는 그대로 유지합니다. this.#roiElement.addEventListener('dblclick', () => this.#openRoiInPopup()); } #makeDraggable(element) { let offsetX, offsetY; const videoContainer = this.#videoContainer; const onMouseMove = (e) => { const containerRect = videoContainer.getBoundingClientRect(); const newLeft = Math.max(0, Math.min(e.clientX - offsetX, containerRect.width - element.offsetWidth)); const newTop = Math.max(0, Math.min(e.clientY - offsetY, containerRect.height - element.offsetHeight)); element.style.left = `${newLeft}px`; element.style.top = `${newTop}px`; }; element.addEventListener('mousedown', (e) => { if (e.target !== element) return; e.preventDefault(); offsetX = e.clientX - element.offsetLeft; offsetY = e.clientY - element.offsetTop; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', () => { document.removeEventListener('mousemove', onMouseMove); this.#debounceUpdateRoiSourceRect(); }, { once: true }); }); } #makeResizable(element, handle) { let startX, startWidth; const videoContainer = this.#videoContainer; const onResizeMove = (e) => { const dx = e.clientX - startX; const containerRect = videoContainer.getBoundingClientRect(); const newSize = Math.max(20, Math.min(startWidth + dx, containerRect.width - element.offsetLeft, containerRect.height - element.offsetTop)); element.style.width = `${newSize}px`; element.style.height = `${newSize}px`; }; handle.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); startX = e.clientX; startWidth = parseInt(getComputedStyle(element).width, 10); document.addEventListener('mousemove', onResizeMove); document.addEventListener('mouseup', () => { document.removeEventListener('mousemove', onResizeMove); this.#debounceUpdateRoiSourceRect(); }, { once: true }); }); } #debounceUpdateRoiSourceRect() { if (this.#updateTimeout) clearTimeout(this.#updateTimeout); this.#updateTimeout = setTimeout(() => { this.#updateRoiSourceRect(); }, 50); } #toggleRoiVisibility() { this.#isRoiActive = !this.#isRoiActive; if (this.#isRoiActive) { this.#playerElement.dataset.roiActive = 'true'; } else { delete this.#playerElement.dataset.roiActive; } if (this.#isRoiActive && !this.#roiElement.style.width) { this.#roiElement.style.width = '150px'; this.#roiElement.style.height = '150px'; this.#roiElement.style.top = '50px'; this.#roiElement.style.left = '50px'; } } #updateRoiSourceRect() { if (!this.#isRoiActive || !this.#panzoomInstance) return; const now = performance.now(); if (now - this.#lastUpdateTime < 50) return; // Throttle updates this.#lastUpdateTime = now; const containerRect = this.#videoContainer.getBoundingClientRect(); const roiRect = this.#roiElement.getBoundingClientRect(); const pan = this.#panzoomInstance.getPan(); const scale = this.#panzoomInstance.getScale(); const { videoWidth, videoHeight } = this.#videoElement; if (videoWidth === 0 || videoHeight === 0) return; const videoRatio = videoWidth / videoHeight; const containerRatio = containerRect.width / containerRect.height; let renderedWidth, renderedHeight, offsetX = 0, offsetY = 0; // Recalculate rendered dimensions based on aspect ratio if (videoRatio > containerRatio) { renderedWidth = containerRect.width; renderedHeight = renderedWidth / videoRatio; offsetY = (containerRect.height - renderedHeight) / 2; } else { renderedHeight = containerRect.height; renderedWidth = renderedHeight * videoRatio; offsetX = (containerRect.width - renderedWidth) / 2; } // Calculate ROI source rectangle in video coordinates Object.assign(this.#roiSourceRect, { sx: ((roiRect.left - containerRect.left - offsetX - pan.x) / scale) * (videoWidth / renderedWidth), sy: ((roiRect.top - containerRect.top - offsetY - pan.y) / scale) * (videoHeight / renderedHeight), sWidth: (roiRect.width / scale) * (videoWidth / renderedWidth), sHeight: (roiRect.height / scale) * (videoHeight / renderedHeight) }); // Ensure ROI coordinates are within video bounds this.#roiSourceRect.sx = Math.max(0, Math.min(this.#roiSourceRect.sx, videoWidth - this.#roiSourceRect.sWidth)); this.#roiSourceRect.sy = Math.max(0, Math.min(this.#roiSourceRect.sy, videoHeight - this.#roiSourceRect.sHeight)); } #openRoiInPopup() { if (!this.#roiElement || !this.#isRoiActive) return; this.#roiModal?.destroy(); if (this.#drawLoopAnimationId) cancelAnimationFrame(this.#drawLoopAnimationId); this.#roiModal = new DraggableResizableModal('roi-zoom-popup', '실시간 확대 영상', { width: '450px', height: '490px', top: '100px', left: '100px' }); const contentArea = this.#roiModal.getContentElement(); contentArea.style.cssText = 'padding: 0; display: flex; background-color: #000;'; const popupCanvas = document.createElement('canvas'); popupCanvas.style.cssText = 'width: 100%; height: 100%;'; contentArea.appendChild(popupCanvas); this.#roiModal.show(); const popupCtx = popupCanvas.getContext('2d', { alpha: false }); popupCtx.imageSmoothingEnabled = false; // ResizeObserver for popup canvas this.#resizeObserver?.disconnect(); this.#resizeObserver = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; popupCanvas.width = width; popupCanvas.height = height; this.#debounceUpdateRoiSourceRect(); // Ensure ROI is updated when popup resizes }); this.#resizeObserver.observe(contentArea); const drawLoop = () => { if (!popupCanvas.isConnected) { this.#drawLoopAnimationId = null; return; } this.#updateRoiSourceRect(); // Update ROI coordinates in each frame const { sx, sy, sWidth, sHeight } = this.#roiSourceRect; const { width: canvasWidth, height: canvasHeight } = popupCanvas; const size = Math.min(canvasWidth, canvasHeight); const dx = (canvasWidth - size) / 2; const dy = (canvasHeight - size) / 2; popupCtx.clearRect(0, 0, canvasWidth, canvasHeight); popupCtx.drawImage(this.#videoElement, sx, sy, sWidth, sHeight, dx, dy, size, size); this.#drawLoopAnimationId = requestAnimationFrame(drawLoop); }; this.#drawLoopAnimationId = requestAnimationFrame(drawLoop); } } const setupPlayerPanzoom = async (videoSelector, containerSelector, playerSelector) => { if (!isPlayerPanzoomEnabled) return; try { if (!window.Panzoom) { await loadScript('https://unpkg.com/@panzoom/panzoom@4.5.1/dist/panzoom.min.js'); } } catch (error) { customLog.error("Panzoom 라이브러리 로드 실패:", error); return; } const rightCtrl = await waitForElementAsync('#player .player_ctrlBox .ctrlBox .right_ctrl'); const videoElement = await waitForElementAsync(videoSelector); const videoContainer = await waitForElementAsync(containerSelector); const playerElement = await waitForElementAsync(playerSelector); if (!rightCtrl || !videoElement || !videoContainer || !playerElement) { customLog.error("Panzoom에 필요한 요소를 찾을 수 없습니다."); return; } // 이전 핸들러 및 버튼 정리 panzoomHandlerInstance?.destroy(); document.querySelector('.btn_panzoom_toggle')?.remove(); panzoomHandlerInstance = new PlayerPanzoom(videoElement, videoContainer, playerElement); const toggleButton = document.createElement('button'); toggleButton.type = 'button'; toggleButton.title = '영상 확대/이동'; toggleButton.className = 'btn_panzoom_toggle'; toggleButton.innerHTML = ``; toggleButton.style.color = 'white'; const advancedControlsBtn = rightCtrl.querySelector('.btn_advanced_controls'); if (advancedControlsBtn) { rightCtrl.insertBefore(toggleButton, advancedControlsBtn); } else { rightCtrl.insertBefore(toggleButton, rightCtrl.firstChild); } toggleButton.addEventListener('click', (e) => { e.stopPropagation(); if (panzoomHandlerInstance.isActive) { panzoomHandlerInstance.destroy(); toggleButton.style.color = 'white'; // 비활성 색상 } else { panzoomHandlerInstance.init(); toggleButton.style.color = '#4998fd'; // 활성 색상 } }); }; //2026.02.24 VOD 및 클립 자동재생 삭제기능(03.25 수정) // ====================================== // VOD/클립 자동재생 차단 모듈 // ON: 영상 종료 시 자동재생 없이 멈춤 // OFF: SOOP 기본 동작 (VOD→다음영상, 클립→반복재생) // ====================================== function setupBlockNextContentModule() { if (location.host !== 'vod.sooplive.com') return; const isBlockVODNext = !!GM_getValue("isBlockVODNextEnabled", false); const isBlockClipNext = !!GM_getValue("isBlockClipNextEnabled", false); if (!isBlockVODNext && !isBlockClipNext) return; if (window.__soopBlockNextContentInitialized) return; window.__soopBlockNextContentInitialized = true; // 캐치 페이지 판별 (기존 isClipPage는 /clip을 보는데 캐치는 /catch라 미작동) const isCatchPage = () => { const path = location.pathname.toLowerCase(); return path.includes('/catch') || location.href.includes('vod.sooplive.com/player/catch'); }; // 페이지 종류에 따라 독립적으로 차단 여부 결정 const shouldBlock = () => { if (isCatchPage()) return !!GM_getValue("isBlockClipNextEnabled", false); return !!GM_getValue("isBlockVODNextEnabled", false); }; const attachEndedHandler = (video) => { if (!video || video.dataset.blockNextBound === 'true') return; video.dataset.blockNextBound = 'true'; video.addEventListener('ended', function (e) { if (!shouldBlock()) return; e.stopImmediatePropagation(); try { video.pause(); } catch (_) {} setTimeout(removeAutoNextOverlay, 0); setTimeout(removeAutoNextOverlay, 500); setTimeout(removeAutoNextOverlay, 1500); }, true); }; const removeAutoNextOverlay = () => { if (!shouldBlock()) return; const selectors = [ '.autonext', '.auto_next', '.autoplay_next', '.next_autoplay', '[class*="auto_next"]', '[class*="autoplay"]', '.recommend_autoplay', '.countdown_next', '[class*="countdown"]', '.clip_autoplay', '.vod_autoplay' ]; selectors.forEach(sel => { document.querySelectorAll(sel).forEach(el => { el.style.setProperty('display', 'none', 'important'); el.style.setProperty('pointer-events', 'none', 'important'); }); }); }; const scanAndApply = () => { if (!shouldBlock()) return; const video = document.querySelector('video'); if (video) attachEndedHandler(video); }; const observer = new MutationObserver(() => { scanAndApply(); }); observer.observe(document.documentElement, { childList: true, subtree: true }); scanAndApply(); observeUrlChanges(() => { const oldVideo = document.querySelector('video'); if (oldVideo) delete oldVideo.dataset.blockNextBound; setTimeout(() => { window.__soopBlockNextContentInitialized = false; setupBlockNextContentModule(); }, 1000); }); } //====================================== // 4. 메인 실행 로직 (Main Execution Logic) //====================================== if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { if (isChangeFontEnabled) applyFontStyles(); loadCategoryData(); }); } else { if (isChangeFontEnabled) applyFontStyles(); loadCategoryData(); } // 4.1. 메인 페이지 실행 (sooplive.com) if (CURRENT_URL.startsWith("https://www.sooplive.com")) { if (window.location.href.includes('/station/') && window.location.search.includes('iframe=true')) { GM_addStyle(` #serviceHeader { display: none !important } #soop_wrap { padding-top: 0 !important } `); return; } GM_addStyle(CommonStyles); GM_addStyle(mainPageCommonStyles); if (isPreviewModalEnabled || isReplaceEmptyThumbnailEnabled || isPreviewModalFromSidebarEnabled) { loadHlsScript(); previewModalManager = new PreviewModal(); unsafeWindow.handleSidebarContextMenu = (element, event) => { previewModalManager.handleSidebarContextMenu(element, event); }; } if (isCustomSidebarEnabled) document.body.classList.add('customSidebar'); (async () => { const serviceLnbDiv = await waitForElementAsync('#serviceLnb'); if (isCustomSidebarEnabled) { makeTopNavbarAndSidebar("main"); await waitForElementAsync('#sidebar'); insertFoldButton(); } runCommonFunctions(); })() //2026.02.27 방송국에서 SOOP메인 홈페이지 전환 시 사이드바 강제 펼침 // 사이드바 생성 후 강제 펼침 waitForElementAsync('#sidebar').then(() => { forceSidebarExpandOnMain(); }); // SPA URL 변경 대응 observeUrlChanges(() => { setTimeout(forceSidebarExpandOnMain, 150); // DOM 재렌더링이 끝난 뒤 실행되도록 약간의 지연 }); removeUnwantedTags(); processStreamers(); return; } // 4.2. 플레이어 페이지 실행 (play.sooplive.com) if (CURRENT_URL.startsWith("https://play.sooplive.com")) { // Embed 페이지에서는 실행하지 않음 const pattern = /^https:\/\/play.sooplive.com\/.*\/.*\/embed(\?.*)?$/; if (pattern.test(CURRENT_URL)) { return; // embed는 그대로 종료 } // 채팅 팝업 전용 처리 (사이드바/플레이어 기능 제외, 채팅 기능만) if (CURRENT_URL.includes("vtype=chat")) { GM_addStyle(CommonStyles); hideBadges(); (async () => { const writeArea = await waitForElementAsync('#write_area'); if (writeArea && isUnlockCopyPasteEnabled) unlockCopyPaste(writeArea); })(); if (isAlignNicknameRightEnabled) alignNicknameRight(); if (ishideButtonsAboveChatInputEnabled) hideButtonsAboveChatInput(); // ── [추가] 모아보기 버튼 팝업에서도 작동 ── if (isShowDeletedMessagesEnabled || isShowSelectedMessagesEnabled) { (async () => { const chattingItemWrapDiv = await waitForElementAsync('.chatting-item-wrap'); if (chattingItemWrapDiv) setupChatMessageTrackers(chattingItemWrapDiv); })(); } if (isHideDuplicateChatEnabled || isBlockWordsEnabled) { (async () => { const chattingItemWrapDiv = await waitForElementAsync('.chatting-item-wrap'); if (chattingItemWrapDiv) setupChatMessageTrackers(chattingItemWrapDiv); })(); } if (isChatResizableEnabled) setupChatResizable(); if (isExpandLiveChatAreaEnabled) setupExpandLiveChatFeature(); return; } GM_addStyle(CommonStyles); GM_addStyle(playerCommonStyles); hideBadges(); compileBlockRules(); if (isPreviewModalFromSidebarEnabled) { loadHlsScript(); previewModalManager = new PreviewModal(); unsafeWindow.handleSidebarContextMenu = (element, event) => { previewModalManager.handleSidebarContextMenu(element, event); }; } if (isCustomSidebarEnabled) document.body.classList.add('customSidebar'); if (isCustomSidebarEnabled) { makeTopNavbarAndSidebar("player"); insertFoldButton(); if (showSidebarOnScreenMode && !showSidebarOnScreenModeAlways) { showSidebarOnMouseOver(); } setupScreenModePageScroll(); // ★ [추가] 스크린 모드 휠 스크롤 수정 } 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'); if (livePlayerDiv) 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 || isAutoChangeQualityEnabled || isSelectBestQualityEnabled) { // 플레이어 로드 후 충분한 시간을 두고 실행 setTimeout(() => downgradeFrom1440p(), 2000); observeUrlChanges(() => { setTimeout(() => downgradeFrom1440p(), 4000); }); } checkPlayerPageHeaderAd(); if (!isOpenNewtabEnabled) { homePageCurrentTab(); } if (isDocumentTitleUpdateEnabled) { setTimeout(updateTitleWithViewers, 10000); setInterval(updateTitleWithViewers, 60000); } runCommonFunctions(); // ★ [추가] 라이브 재생바 숨기기 if (isHideLiveProgressBarEnabled) { GM_addStyle(` .player_ctrlBox .progress { display: none !important; } .player_ctrlBox .ctrlBox { margin-top: 0 !important; } `); } if (isUnlockCopyPasteEnabled) { (async () => { const writeArea = await waitForElementAsync('#write_area'); if (writeArea) unlockCopyPaste(writeArea); })() }; if (isAlignNicknameRightEnabled) { alignNicknameRight(); } if (isAutoScreenModeEnabled) { (async () => { await waitForElementAsync('#livePlayer'); if (!document.body.classList.contains('screen_mode')) { document.body.querySelector('#player .btn_screen_mode').click(); } })() } if (isClickPlayerEventMapperEnabled) { async function initializePlayerControls() { const player = await waitForElementAsync('#player'); const video = await waitForElementAsync('#livePlayer'); if (!player || !video) { customLog.error("플레이어 또는 비디오 요소를 찾을 수 없어 시스템을 시작할 수 없습니다."); return; } const pauseSelector = document.querySelector('#closeStream') ? '#closeStream' : '#time_shift_play'; const buttonSelectors = { mute: '#btn_sound', pause: pauseSelector, stop: '#play', screenMode: '.btn_screen_mode', fullscreen: '.btn_fullScreen_mode', }; const mapper = new PlayerEventMapper(player, video, buttonSelectors); mapper.player.addEventListener('mapper-ready', () => { mapper.applyConfiguration(USER_CLICK_CONFIG); }); } // 스크립트 실행 initializePlayerControls(); } if (ishideButtonsAboveChatInputEnabled) { hideButtonsAboveChatInput(); } if (isChatResizableEnabled) { setupChatResizable(); } if (isExpandLiveChatAreaEnabled) { setupExpandLiveChatFeature(); } if (isShowDeletedMessagesEnabled || isShowSelectedMessagesEnabled || isBlockWordsEnabled || isHideDuplicateChatEnabled) { (async () => { const chattingItemWrapDiv = await waitForElementAsync('.chatting-item-wrap'); if (chattingItemWrapDiv) setupChatMessageTrackers(chattingItemWrapDiv); })(); observeUrlChanges(() => { unsafeWindow.resetChatData(); }); } if (isNoAutoVODEnabled) { let redirectRetryTimer = null; let disconnectUrlObserver = null; const tabManager = getSharedTabSyncManager("play.sooplive.com/{userId}/{broadcastId}"); const cancelAutoRedirectRetry = () => { if (redirectRetryTimer) { clearTimeout(redirectRetryTimer); // 예약된 setTimeout을 취소 redirectRetryTimer = null; // 타이머 ID 변수 초기화 customLog.log('사용자 활동이 감지되어 자동 전환 재시도를 중단합니다.'); } } /** * 지정된 기준에 따라 다음 라이브 방송으로 전환하는 함수 (안전 장치 및 재시도 로직 추가됨) * @param {number} retryCount - 현재까지의 재시도 횟수 */ async function redirectLiveWithTabCheck(retryCount = 0) { // --- 설정 변수 --- const MAX_RETRIES = 100; // 최대 재시도 횟수 const RETRY_DELAY_MS = 10000; // 재시도 사이의 대기 시간 (10초) const LOCK_KEY = 'auto_redirect_lock'; const LOCK_TIMEOUT_MS = 10000; // 잠금 유효 시간 (10초) // 1. 최대 재시도 횟수를 초과하면 실행을 완전히 중단합니다. if (retryCount >= MAX_RETRIES) { customLog.log(`최대 재시도 횟수(${MAX_RETRIES}회)를 초과하여 자동 전환을 중단합니다.`); return; } try { const now = Date.now(); const lockTimestamp = localStorage.getItem(LOCK_KEY); // 2. 다른 탭이 유효한 잠금을 가지고 있는지 확인합니다. if (lockTimestamp && (now - parseInt(lockTimestamp, 10)) < LOCK_TIMEOUT_MS) { customLog.log(`다른 탭에서 자동 전환 진행 중... ${RETRY_DELAY_MS / 1000}초 후 재시도합니다. (시도 ${retryCount + 1}/${MAX_RETRIES})`); // 재시도 로직: 일정 시간 대기 후, 재시도 횟수를 늘려 다시 함수를 호출합니다. redirectRetryTimer = setTimeout(() => redirectLiveWithTabCheck(retryCount + 1), RETRY_DELAY_MS); return; // 현재 실행은 중단하고, 예약된 다음 시도를 기다립니다. } // 3. 유효한 잠금이 없으므로, 현재 탭이 잠금을 획득하고 리디렉션을 시작합니다. customLog.log('잠금을 획득하여 자동 전환을 시작합니다.'); localStorage.setItem(LOCK_KEY, now.toString()); const sortMethod = redirectLiveSortOption; customLog.log(`방송 종료. 다음 방송 자동 전환을 시작합니다. (선택 기준: ${sortMethod})`); const favoriteData = await fetchBroadList('https://myapi.sooplive.com/api/favorite', 50); let potentialTargets = getPrioritizedLiveBroadcasts(favoriteData); if (!potentialTargets.length) { customLog.log("자동으로 전환할 라이브 방송을 찾지 못했습니다."); localStorage.removeItem(LOCK_KEY); // 전환할 방송이 없으므로 잠금 해제 return; } // ... (정렬 로직은 이전과 동일) ... switch (sortMethod) { case 'mostViewers': potentialTargets.sort((a, b) => (b.total_view_cnt || 0) - (a.total_view_cnt || 0)); customLog.log('시청자 많은 순으로 후보 목록을 정렬했습니다.'); break; case 'leastViewers': potentialTargets.sort((a, b) => (a.total_view_cnt || 0) - (b.total_view_cnt || 0)); customLog.log('시청자 적은 순으로 후보 목록을 정렬했습니다.'); break; case 'random': for (let i = potentialTargets.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [potentialTargets[i], potentialTargets[j]] = [potentialTargets[j], potentialTargets[i]]; } customLog.log('후보 목록을 무작위로 섞었습니다.'); break; case 'custom': default: customLog.log('기존 우선순위(고정/알림/일반)를 사용합니다.'); break; } customLog.log(`전환할 후보 방송: ${potentialTargets.length}개`); for (const target of potentialTargets) { const userId = target.user_id; const broadcastId = target.broad_no; if (!userId || !broadcastId) { continue; } const isAlreadyOpen = tabManager.isTargetTabOpen(userId, broadcastId); if (!isAlreadyOpen) { customLog.log(`다음 우선순위 방송[${userId}/${broadcastId}]을 찾았습니다. 전환합니다.`); // 리디렉션이 성공하면 이 탭의 스크립트 실행은 중단됩니다. // 잠금은 타임아웃으로 자동 해제됩니다. unsafeWindow.liveView.playerController.sendLoadBroad(userId, broadcastId); return; } else { customLog.log(`방송[${userId}/${broadcastId}]은(는) 이미 열려있어 건너뜁니다. 다음 우선순위를 확인합니다.`); } } customLog.log("모든 우선순위의 라이브 방송이 이미 열려있습니다. 전환하지 않습니다."); localStorage.removeItem(LOCK_KEY); // 모든 작업이 끝났으므로 잠금 해제 } catch (error) { customLog.error('다음 방송 자동 전환 중 오류가 발생했습니다:', error); localStorage.removeItem(LOCK_KEY); // 오류 발생 시에도 잠금 해제 } } function disableAutoVOD() { const container = unsafeWindow.liveView?.aContainer?.[1]; if (container?.autoPlayVodBanner) { if (isRedirectLiveEnabled === 1) { container.autoPlayVodBanner.show = redirectLiveWithTabCheck; if (!disconnectUrlObserver) { disconnectUrlObserver = observeUrlChanges(cancelAutoRedirectRetry); } customLog.log('자동 LIVE 전환 기능 활성화'); } else { container.autoPlayVodBanner.show = () => { customLog.log('VOD 자동 재생 비활성화'); } } } else { setTimeout(disableAutoVOD, 3000); } } 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; } ` ); } if (isPlayerAdvancedControlsLiveEnabled) { let advControlsInstance = null; const initAdvControls = async () => { try { advControlsInstance?.destroy(); const videoElement = await waitForElementAsync('#livePlayer'); advControlsInstance = new PlayerAdvancedControls(videoElement); } catch (e) { customLog.error('고급 컨트롤 초기화 실패:', e); } }; initAdvControls(); observeUrlChanges(() => { setTimeout(initAdvControls, 2000); }); } if (isPlayerPanzoomEnabled) { const initPanzoom = () => setupPlayerPanzoom('#livePlayer', '#videoLayer', '#player'); initPanzoom(); observeUrlChanges(() => { setTimeout(initPanzoom, 2000); }); } return; } // 4.3. VOD 페이지 실행 (vod.sooplive.com) if (CURRENT_URL.startsWith("https://vod.sooplive.com/player/")) { //2026.02.24 VOD 및 클립 자동재생 삭제기능(03.25 수정) // VOD/클립 자동재생 차단 (ON: 멈춤, OFF: SOOP 기본동작) setupBlockNextContentModule(); const isChatUrl = (url) => /https:\/\/vod\.sooplive\.com\/player\/\d+\/chat/.test(url); const isCatchUrl = (url) => /https:\/\/vod\.sooplive\.com\/player\/\d+\/catch/.test(url) || /https:\/\/vod\.sooplive\.com\/player\/catch/.test(url); const isBaseUrl = (url) => { // VOD 채팅 팝업 전용 처리 const isChatPopupUrl = (url) => /https:\/\/vod\.sooplive\.com\/player\/\d+\/chat/.test(url); if (isChatPopupUrl(CURRENT_URL)) { GM_addStyle(CommonStyles); hideBadges(); if (isAlignNicknameRightEnabled) alignNicknameRight(); if (ishideButtonsAboveChatInputEnabled) hideButtonsAboveChatInput(); if (isShowDeletedMessagesEnabled || isShowSelectedMessagesEnabled) { (async () => { const chattingItemWrapDiv = await waitForElementAsync('.chatting-item-wrap'); if (chattingItemWrapDiv) setupChatMessageTrackers(chattingItemWrapDiv); })(); } if (isChatResizableEnabled) setupChatResizable(); if (isExpandVODChatAreaEnabled) setupExpandVODChatFeature(); // return으로 나머지 VOD 코드 건너뜀 (사이드바 생성 방지) return; // ← 이 return이 VOD 블록 전체를 감싸는 if 밖으로 나가야 하므로 } const u = new URL(url); // /player/숫자 로 끝나는 경로만 (뒤에 /chat, /embed 등 없음) if (!/\/player\/\d+$/.test(u.pathname)) return false; // embed 쿼리 파라미터 또는 body의 embeded_mode 클래스 제외 if (u.searchParams.has('embed') || document.body.classList.contains('embeded_mode')) return false; if (isCatchUrl(url) || isChatUrl(url)) return false; return true; }; // 다시보기 페이지 if (isBaseUrl(CURRENT_URL)) { // 리스트 창 닫은 후 새로고침/페이지 이동 시 자동 스크롤 내려가는 문제 수정 if ('scrollRestoration' in history) { history.scrollRestoration = 'manual'; } const _resetAllScroll = () => { window.scrollTo(0, 0); document.documentElement.scrollTop = 0; document.body.scrollTop = 0; }; _resetAllScroll(); const _scrollResetDeadline = Date.now() + 1500; const _scrollResetHandler = () => { if (Date.now() < _scrollResetDeadline) { _resetAllScroll(); } else { window.removeEventListener('scroll', _scrollResetHandler); } }; window.addEventListener('scroll', _scrollResetHandler, { passive: true }); GM_addStyle(CommonStyles); hideBadges(); compileBlockRules(); // VOD 페이지 사이드바 활성화 if (isCustomSidebarEnabled) { GM_addStyle(playerCommonStyles); document.body.classList.add('customSidebar'); document.body.classList.add('vodPage'); // ★ [추가] VOD 페이지 식별용 클래스 makeTopNavbarAndSidebar("player"); insertFoldButton(); if (showSidebarOnScreenMode && !showSidebarOnScreenModeAlways) { showSidebarOnMouseOver(); } hideUsersSection(); initializeSidebar(false); checkSidebarVisibility(); setupScreenModePageScroll(); // ★ [추가] 스크린 모드 휠 스크롤 수정 } if (isBottomChatEnabled) useBottomChat(); const waitForVodMediaInfo = 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) { customLog.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(customLog.error); } initVODChatScanApp(); observeUrlChanges(() => { scannerInstance?.destroy(); // 기존 인스턴스 파괴 scannerInstance = null; setTimeout(initVODChatScanApp, 2000); }); } if (isVODHighlightEnabled) { let highlightScannerInstance = null; async function initHighlightScanApp() { try { const vodCore = await waitForVariable('vodCore'); const titleNo = vodCore.config.titleNo || vodCore.config.title_no; const mobileApiUrl = 'https://api.m.sooplive.com/station/video/a/view'; const params = new URLSearchParams({ nTitleNo: titleNo, nApiLevel: 11, nPlaylistIdx: 0 }); const response = await fetch(mobileApiUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString(), credentials: 'include' }); const videoData = await response.json(); if (videoData.result !== 1 || !videoData.data.bbs_no) { throw new Error(`모바일 API에서 bbs_no를 가져오는 데 실패했습니다: ${videoData.message || '알 수 없는 오류'}`); } const bbsNo = videoData.data.bbs_no; highlightScannerInstance?.destroy(); highlightScannerInstance = new VODHighlightScanner(vodCore, bbsNo); } catch (err) { customLog.error("VOD 스캐너 초기화 실패:", err); highlightScannerInstance?.destroy(); } } initHighlightScanApp(); observeUrlChanges(() => { highlightScannerInstance?.destroy(); setTimeout(initHighlightScanApp, 1000); }); } if (isSelectBestQualityEnabled) { waitForVodMediaInfo(); observeUrlChanges(() => { setTimeout(waitForVodMediaInfo, 2000); }); } if (isCaptureButtonEnabled) { makeCaptureButton(); } // VOD 채팅창 (async () => { const webplayerContentsDiv = await waitForElementAsync('#webplayer_contents'); if (webplayerContentsDiv) observeChatForBlockingWords('#webplayer_contents', webplayerContentsDiv); })(); if (isPlayerAdvancedControlsVODEnabled) { let advControlsInstance = null; const initAdvControlsForVOD = () => { waitForVariable('vodCore') .then(() => waitForElementAsync('.right_ctrl')) .then(() => waitForElementAsync('#video')) .then((videoElement) => { if (!videoElement) return; try { advControlsInstance?.destroy(); advControlsInstance = new PlayerAdvancedControls(videoElement); } catch (e) { customLog.error('VOD 고급 컨트롤 초기화 실패:', e); } }) .catch((e) => { customLog.error('vodCore 또는 필요한 요소 로딩 실패:', e); }); }; observeWithReinit(initAdvControlsForVOD); } if (isPlayerPanzoomVODEnabled) { const initPanzoomForVOD = () => { waitForVariable('vodCore').then((vodCore) => { waitForElementAsync('#video').then((videoElement) => { if (videoElement) setupPlayerPanzoom('#video', '#videoLayer', '#player'); }); }).catch((e) => { customLog.error('vodCore 또는 video 로딩 실패:', e); }); }; observeWithReinit(initPanzoomForVOD); } setupSettingButtonTopbar(); if (isAlignNicknameRightEnabled) { alignNicknameRight(); } if (isExpandVODChatAreaEnabled) { setupExpandVODChatFeature(); } if (isChatResizableEnabled) { setupChatResizable(); } if (isMonthlyRecapEnabled) observeAndAppendRecapButton(); // 캐치 페이지 } else if (isCatchUrl(CURRENT_URL)) { GM_addStyle(CommonStyles); GM_addStyle(mainPageCommonStyles); // 캐치 페이지에서도 반복재생 차단 모듈 실행 (기존에 누락되어 있을 경우 보완) if (!window.__soopBlockNextContentInitialized) { setupBlockNextContentModule(); } if (isCustomSidebarEnabled) document.body.classList.add('customSidebar'); (async () => { const serviceLnbDiv = await waitForElementAsync('#serviceLnb'); if (isCustomSidebarEnabled) { makeTopNavbarAndSidebar("main"); // 사이드바 접기/펼치기 버튼 추가 (캐치 페이지용) await waitForElementAsync('#sidebar'); insertFoldButton(); } runCommonFunctions(); })() if (isRemoveShadowsFromCatchEnabled) addStyleRemoveShadowsFromCatch(); // 수정 if (isCatchAutoNextEnabled) { setupCatchAutoNextOnEnd(); } if (isChatResizableEnabled) { setupChatResizable(); } } } //문제시 삭제할 부분 function setupCatchAutoNextOnEnd() { if (!isCatchAutoNextEnabled) return; const isCatchPlayerUrl = (url) => /^https:\/\/vod\.sooplive\.com\/player\/\d+\/catch/.test(url) || /^https:\/\/vod\.sooplive\.com\/player\/catch/.test(url); if (!isCatchPlayerUrl(window.location.href)) return; if (typeof unsafeWindow.__soopCatchAutoNextCleanup === 'function') { unsafeWindow.__soopCatchAutoNextCleanup(); } let isTransitioning = false; const videoStateMap = new WeakMap(); unsafeWindow.__soopCatchAutoNextInitialized = true; const getCurrentCatchInfo = () => { const url = new URL(window.location.href); const stationMatch = url.pathname.match(/^\/player\/(\d+)\/catch(?:\/([^/?#]+))?/); const catchOnlyMatch = !stationMatch ? url.pathname.match(/^\/player\/catch(?:\/([^/?#]+))?/) : null; if (!stationMatch && !catchOnlyMatch) return null; const stationNo = stationMatch?.[1] || url.searchParams.get('nStationNo') || url.searchParams.get('stationNo') || url.searchParams.get('station_no') || ''; const episodeFromPath = stationMatch?.[2] ? decodeURIComponent(stationMatch[2]) : (catchOnlyMatch?.[1] ? decodeURIComponent(catchOnlyMatch[1]) : ''); const episodeFromQuery = url.searchParams.get('bbs_no') || url.searchParams.get('bbsNo') || url.searchParams.get('fileNo') || url.searchParams.get('titleNo'); return { stationNo, episodeId: episodeFromPath || episodeFromQuery || '' }; }; const collectCatchEpisodeLinks = () => { const current = getCurrentCatchInfo(); if (!current) return []; const seen = new Set(); const anchors = Array.from(document.querySelectorAll('a[href]')); const episodes = []; for (const anchor of anchors) { try { const href = new URL(anchor.href, window.location.href); if (href.origin !== window.location.origin) continue; const stationMatch = href.pathname.match(/^\/player\/(\d+)\/catch(?:\/([^/?#]+))?/); const catchOnlyMatch = !stationMatch ? href.pathname.match(/^\/player\/catch(?:\/([^/?#]+))?/) : null; if (!stationMatch && !catchOnlyMatch) continue; const stationNo = stationMatch?.[1] || href.searchParams.get('nStationNo') || href.searchParams.get('stationNo') || href.searchParams.get('station_no') || ''; if (current.stationNo && stationNo && stationNo !== current.stationNo) continue; const pathEpisode = stationMatch?.[2] ? decodeURIComponent(stationMatch[2]) : (catchOnlyMatch?.[1] ? decodeURIComponent(catchOnlyMatch[1]) : ''); const queryEpisode = href.searchParams.get('bbs_no') || href.searchParams.get('bbsNo') || href.searchParams.get('fileNo') || href.searchParams.get('titleNo'); const episodeId = pathEpisode || queryEpisode; if (!episodeId) continue; const normalized = `${href.pathname}${href.search}`; if (seen.has(normalized)) continue; seen.add(normalized); episodes.push({ href: normalized, episodeId, text: (anchor.textContent || '').trim() }); } catch (_) { continue; } } return episodes; }; const findNextCatchUrl = () => { const current = getCurrentCatchInfo(); if (!current) return null; const episodes = collectCatchEpisodeLinks(); if (!episodes.length) return null; const currentNormalized = `${window.location.pathname}${window.location.search}`; if (!current.episodeId) { const currentByHrefIndex = episodes.findIndex(ep => ep.href === currentNormalized); if (currentByHrefIndex >= 0 && currentByHrefIndex + 1 < episodes.length) { return episodes[currentByHrefIndex + 1].href; } const firstDifferent = episodes.find(ep => ep.href !== currentNormalized); return firstDifferent ? firstDifferent.href : null; } const currentIndex = episodes.findIndex(ep => ep.episodeId === current.episodeId); if (currentIndex >= 0 && currentIndex + 1 < episodes.length) { return episodes[currentIndex + 1].href; } if (currentIndex === -1 && current.episodeId) { const fallback = episodes.find(ep => ep.href.includes(current.episodeId) || ep.text.includes(current.episodeId)); if (fallback) { const index = episodes.indexOf(fallback); if (index >= 0 && index + 1 < episodes.length) return episodes[index + 1].href; } } return null; }; const tryCatchNextByVodCore = () => { const vodCore = unsafeWindow.vodCore; const controller = vodCore?.playerController; if (!controller) return false; const methodNames = [ 'next', 'playNext', 'goNext', 'nextMedia', 'moveToNext', 'skipToNext', 'skip', 'nextFile', 'playNextMedia' ]; for (const methodName of methodNames) { const method = controller[methodName]; if (typeof method !== 'function') continue; try { const result = method.call(controller); if (result !== false) return true; } catch (e) { customLog.warn('[캐치 자동이동] catch next 메서드 실행 실패:', e); } } return false; }; const findAndClickNextButton = () => { const directNextButton = document.querySelector('#main .control_catch button.next, .control_catch button.next, #main .control_catch a.next, .control_catch a.next, [data-action*=\"next\" i], [data-command*=\"next\" i]'); if (directNextButton && !directNextButton.disabled) { try { directNextButton.click(); return true; } catch (e) { customLog.warn('[캐치 자동이동] .control_catch .next 버튼 클릭 실패:', e); } } const keywordPattern = /(다음|next|다음편|다음 화)/i; const candidates = Array.from(document.querySelectorAll('a,button,[role="button"]')) .filter(el => keywordPattern.test((el.textContent || '').trim()) || keywordPattern.test(el.getAttribute('aria-label') || '') || keywordPattern.test(el.getAttribute('title') || '')) .filter(el => /next|catch|재생|playlist|편/.test(el.className || '') || el.closest('.control_catch') || el.closest('[class*="catch"]')); if (!candidates.length) return false; const nextButton = candidates.find(el => { if (el.disabled) return false; const style = window.getComputedStyle(el); return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; }); if (nextButton) { try { nextButton.click(); return true; } catch (e) { customLog.warn('[캐치 자동이동] 다음 버튼 클릭 실패:', e); } } return false; }; const clickNextButtonWithRetry = async () => { const retryDelays = [0, 250, 700]; for (const delay of retryDelays) { if (delay > 0) { await new Promise(resolve => setTimeout(resolve, delay)); } if (findAndClickNextButton()) { return true; } } return false; }; const triggerWheelDownForNext = (target) => { if (!target) return; try { target.dispatchEvent(new WheelEvent('wheel', { deltaY: 240, deltaX: 0, bubbles: true, cancelable: true, clientX: Math.floor(window.innerWidth / 2), clientY: Math.floor(window.innerHeight / 2) })); target.dispatchEvent(new WheelEvent('mousewheel', { deltaY: 240, deltaX: 0, bubbles: true, cancelable: true })); target.dispatchEvent(new WheelEvent('DOMMouseScroll', { deltaY: 240, deltaX: 0, bubbles: true, cancelable: true })); } catch (e) { customLog.warn('[캐치 자동이동] 휠 이벤트 전송 실패:', e); } }; const forceScrollDownForNext = (target) => { if (!target) return; try { if (target === window) { window.scrollBy({ top: 240, behavior: 'auto' }); return; } if (typeof target.scrollBy === 'function') { target.scrollBy({ top: 240, behavior: 'auto' }); } if (typeof target.scrollTop === 'number') { target.scrollTop += 240; } } catch (e) { customLog.warn('[캐치 자동이동] 스크롤 이동 실패:', e); } }; const tryWheelNextWithRetry = async () => { const before = getCurrentCatchInfo()?.episodeId || ''; const initialHref = window.location.href; const targets = [ document.querySelector('#videoLayer'), document.querySelector('#player'), document.querySelector('.catch_webplayer_wrap'), document.querySelector('.vod_player'), document.querySelector('#webplayer'), document.querySelector('video'), document.querySelector('#video')?.parentElement, document.querySelector('#main'), document.body, document.documentElement ].filter(Boolean); const uniqueTargets = [...new Set(targets)]; const retryDelays = [0, 120, 260]; for (const delay of retryDelays) { if (delay > 0) { await new Promise(resolve => setTimeout(resolve, delay)); } uniqueTargets.forEach(triggerWheelDownForNext); uniqueTargets.forEach(forceScrollDownForNext); triggerWheelDownForNext(window); forceScrollDownForNext(window); await new Promise(resolve => setTimeout(resolve, 320)); const after = getCurrentCatchInfo()?.episodeId || ''; if (window.location.href !== initialHref || (before && after && before !== after)) { customLog.log('[캐치 자동이동] 휠 이벤트로 다음 캐치 전환 감지'); return true; } } return false; }; const goNextCatch = async () => { customLog.log('[캐치 자동이동] 영상 종료 감지. 다음 캐치로 이동을 시도합니다.'); const currentNormalized = `${window.location.pathname}${window.location.search}`; if (await tryWheelNextWithRetry()) { return; } if (await clickNextButtonWithRetry()) { return; } const nextHref = findNextCatchUrl(); if (nextHref && nextHref !== currentNormalized) { window.location.href = nextHref.startsWith('http') ? nextHref : window.location.origin + nextHref; return; } if (tryCatchNextByVodCore()) { return; } customLog.log('[캐치 자동이동] 다음 캐치를 찾지 못했습니다.'); isTransitioning = false; }; const startTransitionToNext = async (video) => { if (isTransitioning) return; isTransitioning = true; try { try { if (video && typeof video.pause === 'function') { video.pause(); } } catch (_) { } await goNextCatch(); } finally { setTimeout(() => { isTransitioning = false; }, 1000); } }; const enforceLoopOff = (video) => { if (!video) return; try { video.loop = false; if (video.hasAttribute('loop')) { video.removeAttribute('loop'); } } catch (_) { } }; const updateVideoProgressState = (video) => { if (!video) return; const duration = video.duration; const currentTime = video.currentTime; if (!Number.isFinite(duration) || duration <= 0 || !Number.isFinite(currentTime)) { return; } const state = videoStateMap.get(video) || { lastTime: 0, nearEndSeen: false }; if (duration - currentTime <= 0.4) { state.nearEndSeen = true; } const wrappedToStart = state.nearEndSeen && currentTime < 1.2 && state.lastTime > Math.max(duration - 1.2, 0); if (wrappedToStart) { state.nearEndSeen = false; startTransitionToNext(video); } state.lastTime = currentTime; videoStateMap.set(video, state); }; const bind = (video) => { if (!video || video.tagName !== 'VIDEO' || video.dataset?.soopCatchAutoNextBound === '1') return; video.dataset.soopCatchAutoNextBound = '1'; enforceLoopOff(video); videoStateMap.set(video, { lastTime: 0, nearEndSeen: false }); video.addEventListener('timeupdate', () => { updateVideoProgressState(video); }); video.addEventListener('loadedmetadata', () => { enforceLoopOff(video); }); video.addEventListener('play', () => { enforceLoopOff(video); updateVideoProgressState(video); }); video.addEventListener('ended', async () => { await startTransitionToNext(video); }); }; const findCurrentVideo = () => { const candidates = [ document.querySelector('#video'), document.querySelector('#livePlayer'), ...Array.from(document.querySelectorAll('video')) ].filter(Boolean); return candidates.find(el => el && el.tagName === 'VIDEO') || null; }; const bindFromDom = () => { const video = findCurrentVideo(); if (video) { bind(video); } return video; }; bindFromDom(); const domObserver = new MutationObserver(() => { bindFromDom(); }); domObserver.observe(document.body, { childList: true, subtree: true }); const monitorIntervalId = setInterval(() => { const video = bindFromDom(); if (!video) return; enforceLoopOff(video); updateVideoProgressState(video); if (video.ended) { startTransitionToNext(video); } }, 500); const disconnectUrlObserver = observeUrlChanges(() => { if (!isCatchPlayerUrl(window.location.href)) { if (typeof unsafeWindow.__soopCatchAutoNextCleanup === 'function') { unsafeWindow.__soopCatchAutoNextCleanup(); } return; } bindFromDom(); }); unsafeWindow.__soopCatchAutoNextCleanup = () => { try { clearInterval(monitorIntervalId); } catch (_) { } try { domObserver.disconnect(); } catch (_) { } try { if (typeof disconnectUrlObserver === 'function') { disconnectUrlObserver(); } } catch (_) { } delete unsafeWindow.__soopCatchAutoNextCleanup; delete unsafeWindow.__soopCatchAutoNextInitialized; }; } if (CURRENT_URL.startsWith("https://www.sooplive.com/station/")) { if (window.location.search.includes('iframe=true')) { GM_addStyle(` #bs-navi, #af-header, .bs-infomation { display: none !important; } #contents_wrap, #bs-contents, #bs-container { width: 650px !important; padding: 0 !important; margin: 0 !important; } #bs-container, #contents, .post_detail { width: 650px !important; max-width: 650px !important; } .post_detail * { max-width: 650px !important; } `); return; } } })();