// ==UserScript==
// @name SOOP (숲) - 사이드바 UI 변경
// @name:ko SOOP (숲) - 사이드바 UI 변경
// @namespace https://gf.qytechs.cn/ko/scripts/484713
// @version 20250636
// @description SOOP (숲)의 사이드바 UI를 변경합니다.
// @description:ko SOOP (숲)의 사이드바 UI를 변경합니다.
// @author You
// @match https://www.sooplive.co.kr/*
// @match https://play.sooplive.co.kr/*
// @match https://vod.sooplive.co.kr/player/*
// @icon https://res.sooplive.co.kr/afreeca.ico
// @grant unsafeWindow
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @connect sooplive.co.kr
// @connect naver.com
// @run-at document-end
// @license
// ==/UserScript==
(function() {
'use strict';
//======================================
// 1. 전역 변수 및 설정 (Global Variables & Configuration)
//======================================
const NEW_UPDATE_DATE = 20250611;
const CURRENT_URL = window.location.href;
const IS_DARK_MODE = document.documentElement.getAttribute('dark') === 'true';
const HIDDEN_BJ_LIST = [];
let allFollowUserIds = GM_getValue('allFollowUserIds', []);
let STATION_FEED_DATA;
let menuIds = {};
let categoryMenuIds = {};
let wordMenuIds = {};
let displayFollow = GM_getValue("displayFollow", 6);
let displayMyplus = GM_getValue("displayMyplus", 6);
let displayMyplusvod = GM_getValue("displayMyplusvod", 4);
let displayTop = GM_getValue("displayTop", 6);
let myplusPosition = GM_getValue("myplusPosition", 1);
let myplusOrder = GM_getValue("myplusOrder", 1);
let blockedUsers = GM_getValue('blockedUsers', []);
let blockedCategories = GM_getValue('blockedCategories', []);
let blockedWords = GM_getValue('blockedWords', []); // 방송 목록 차단 단어
let registeredWords = GM_getValue("registeredWords",""); // 채팅창 차단 단어
let selectedUsers = GM_getValue("selectedUsers",""); // 유저 채팅 모아보기 아이디
let nicknameWidth = GM_getValue("nicknameWidth",126);
let isOpenNewtabEnabled = GM_getValue("isOpenNewtabEnabled", 0);
let isSidebarMinimized = GM_getValue("isSidebarMinimized", 0);
let showSidebarOnScreenMode = GM_getValue("showSidebarOnScreenMode", 1);
let showSidebarOnScreenModeAlways = GM_getValue("showSidebarOnScreenModeAlways", 0);
let savedCategory = GM_getValue("szBroadCategory",0);
let isAutoChangeMuteEnabled = GM_getValue("isAutoChangeMuteEnabled", 0);
let isAutoChangeQualityEnabled = GM_getValue("isAutoChangeQualityEnabled", 0);
let isNo1440pEnabled = GM_getValue("isNo1440pEnabled", 0);
let isDuplicateRemovalEnabled = GM_getValue("isDuplicateRemovalEnabled", 1);
let isRemainingBufferTimeEnabled = GM_getValue("isRemainingBufferTimeEnabled", 1);
let isPinnedStreamWithNotificationEnabled = GM_getValue("isPinnedStreamWithNotificationEnabled", 0);
let isPinnedStreamWithPinEnabled = GM_getValue("isPinnedStreamWithPinEnabled", 0);
let isBottomChatEnabled = GM_getValue("isBottomChatEnabled", 0);
let isMakePauseButtonEnabled = GM_getValue("isMakePauseButtonEnabled", 1);
let isCaptureButtonEnabled = GM_getValue("isCaptureButtonEnabled", 1);
let isStreamDownloadEnabled = GM_getValue("isStreamDownloadEnabled", 0);
let isMakeSharpModeShortcutEnabled = GM_getValue("isMakeSharpModeShortcutEnabled", 1);
let isMakeLowLatencyShortcutEnabled = GM_getValue("isMakeLowLatencyShortcutEnabled", 1);
let isMakeQualityChangeShortcutEnabled = GM_getValue("isMakeQualityChangeShortcutEnabled", 0);
let isSendLoadBroadEnabled = GM_getValue("isSendLoadBroadEnabled", 1);
let isSelectBestQualityEnabled = GM_getValue("isSelectBestQualityEnabled", 1);
let isHideSupporterBadgeEnabled = GM_getValue("isHideSupporterBadgeEnabled",0);
let isHideFanBadgeEnabled = GM_getValue("isHideFanBadgeEnabled",0);
let isHideSubBadgeEnabled = GM_getValue("isHideSubBadgeEnabled",0);
let isHideVIPBadgeEnabled = GM_getValue("isHideVIPBadgeEnabled",0);
let isHideManagerBadgeEnabled = GM_getValue("isHideManagerBadgeEnabled",0);
let isHideStreamerBadgeEnabled = GM_getValue("isHideStreamerBadgeEnabled",0);
let isBlockWordsEnabled = GM_getValue("isBlockWordsEnabled",0);
let isAutoClaimGemEnabled = GM_getValue("isAutoClaimGemEnabled",0);
let isVideoSkipHandlerEnabled = GM_getValue("isVideoSkipHandlerEnabled",0);
let isSmallUserLayoutEnabled = GM_getValue("isSmallUserLayoutEnabled",0);
let isChannelFeedEnabled = GM_getValue("isChannelFeedEnabled",1);
let isChangeFontEnabled = GM_getValue("isChangeFontEnabled", 0);
let isCustomSidebarEnabled = GM_getValue("isCustomSidebarEnabled", 1);
let isRemoveCarouselEnabled = GM_getValue("isRemoveCarouselEnabled", 0);
let isDocumentTitleUpdateEnabled = GM_getValue("isDocumentTitleUpdateEnabled", 1);
let isRemoveRedistributionTagEnabled = GM_getValue("isRemoveRedistributionTagEnabled", 1);
let isRemoveWatchLaterButtonEnabled = GM_getValue("isRemoveWatchLaterButtonEnabled", 1);
let isRemoveBroadStartTimeTagEnabled = GM_getValue("isRemoveBroadStartTimeTagEnabled", 0);
let isBroadTitleTextEllipsisEnabled = GM_getValue("isBroadTitleTextEllipsisEnabled", 0);
let isUnlockCopyPasteEnabled = GM_getValue("isUnlockCopyPasteEnabled", 0);
let isAlignNicknameRightEnabled = GM_getValue("isAlignNicknameRightEnabled", 0);
let isReplaceEmptyThumbnailEnabled = GM_getValue("isReplaceEmptyThumbnailEnabled", 1);
let isAutoScreenModeEnabled = GM_getValue("isAutoScreenModeEnabled", 0);
let isAdjustDelayNoGridEnabled = GM_getValue("isAdjustDelayNoGridEnabled", 0);
let ishideButtonsAboveChatInputEnabled = GM_getValue("ishideButtonsAboveChatInputEnabled", 0);
let isExpandVODChatAreaEnabled = GM_getValue("isExpandVODChatAreaEnabled", 1);
let isExpandLiveChatAreaEnabled = GM_getValue("isExpandLiveChatAreaEnabled", 1);
let isOpenExternalPlayerEnabled = GM_getValue("isOpenExternalPlayerEnabled", 0);
let isOpenExternalPlayerFromSidebarEnabled = GM_getValue("isOpenExternalPlayerFromSidebarEnabled", 0);
let isRemoveShadowsFromCatchEnabled = GM_getValue("isRemoveShadowsFromCatchEnabled", 0);
let isChzzkTopChannelsEnabled = GM_getValue("isChzzkTopChannelsEnabled", 0);
let isChzzkFollowChannelsEnabled = GM_getValue("isChzzkFollowChannelsEnabled", 0);
let isAdaptiveSpeedControlEnabled = GM_getValue("isAdaptiveSpeedControlEnabled", 0);
let isShowSelectedMessagesEnabled = GM_getValue("isShowSelectedMessagesEnabled", 0);
let isShowDeletedMessagesEnabled = GM_getValue("isShowDeletedMessagesEnabled", 0);
let isNoAutoVODEnabled = GM_getValue("isNoAutoVODEnabled", 1);
let isHideEsportsInfoEnabled = GM_getValue("isHideEsportsInfoEnabled",0);
let isBlockedCategorySortingEnabled = GM_getValue("isBlockedCategorySortingEnabled",0);
let isChatCounterEnabled = GM_getValue("isChatCounterEnabled",1);
let isRandomSortEnabled = GM_getValue("isRandomSortEnabled",0);
let isPinnedOnlineOnlyEnabled = GM_getValue("isPinnedOnlineOnlyEnabled",0);
let isMonthlyRecapEnabled = GM_getValue("isMonthlyRecapEnabled",1);
const WEB_PLAYER_SCROLL_LEFT = isSidebarMinimized ? 52 : 240;
const REG_WORDS = registeredWords ? registeredWords.split(',').map(word => word.trim()).filter(Boolean) : [];
const qualityNameToInternalType = {
sd: 'LOW',
hd: 'NORMAL',
hd4k: 'HIGH_4000',
hd8k: 'HIGH_8000',
original: 'ORIGINAL',
auto: 'AUTO'
};
const BUTTON_DATA = [
{ href: 'https://www.sooplive.co.kr/live/all', text: 'LIVE', onClickTarget: '#live > a' },
{ href: 'https://www.sooplive.co.kr/my/favorite', text: 'MY', onClickTarget: '#my > a' },
{ href: 'https://www.sooplive.co.kr/directory/category', text: '탐색', onClickTarget: '#cate > a' },
{ href: 'https://vod.sooplive.co.kr/player/catch', text: '캐치', onClickTarget: '#catch > a' }
];
let qualityChangeTimeout = null;
let previousQualityBeforeDowngrade = null;
let previousIsAutoMode = null;
let didChangeToLowest = false;
let previousViewers = 0;
let previousTitle = '';
// 리캡 관련 전역 변수들
let recapInitialized = false;
let recapModalBackdrop = null; // 모달 요소 참조
let activeCharts = []; // 활성 차트 인스턴스 저장
let categoryImageMap = null; // 카테고리 이미지 URL 캐시
const STATS_API_URL = 'https://broadstatistic.sooplive.co.kr/api/watch_statistic.php';
const INFO_API_URL = 'https://afevent2.sooplive.co.kr/api/get_private_info.php';
const SEARCH_API_URL = 'https://sch.sooplive.co.kr/api.php';
const CATEGORY_API_URL = 'https://sch.sooplive.co.kr/api.php';
const screenshotGradientPalette = [
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'linear-gradient(135deg, #2af598 0%, #009efd 100%)',
'linear-gradient(135deg, #ff8c42 0%, #ff3f3f 100%)',
'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)'
];
const deviceTranslations = { desktop: '데스크톱', mobile: '모바일' };
const typeTranslations = { general: '일반', best: '베스트', partner: '파트너' };
//======================================
// 2. CSS 스타일 정의 (CSS Styles)
//======================================
const CommonStyles = `
#blockWordsInput::placeholder, #selectedUsersInput::placeholder {
font-size: 14px;
}
/* Expand 토글용 li 스타일 */
.expand-toggle-li {
width: 32px;
height: 32px;
cursor: pointer;
background-color: transparent;
background-repeat: no-repeat;
background-position: center;
list-style: none;
background-size: 20px;
/* 채팅 확장 아이콘 */
background-image: url('data:image/svg+xml,%3Csvg%20fill%3D%22%23757B8A%22%20height%3D%2264%22%20width%3D%2264%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20512%20512%22%20xml%3Aspace%3D%22preserve%22%20stroke%3D%22%23757B8A%22%3E%3Cg%20stroke-width%3D%220%22%2F%3E%3Cg%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M335.085%20207.085%20469.333%2072.837V128c0%2011.782%209.551%2021.333%2021.333%2021.333S512%20139.782%20512%20128V21.335q-.001-1.055-.106-2.107c-.031-.316-.09-.622-.135-.933-.054-.377-.098-.755-.172-1.13-.071-.358-.169-.705-.258-1.056-.081-.323-.152-.648-.249-.968-.104-.345-.234-.678-.355-1.015-.115-.319-.22-.641-.35-.956s-.284-.616-.428-.923c-.153-.324-.297-.651-.467-.969-.158-.294-.337-.574-.508-.86-.186-.311-.362-.626-.565-.93-.211-.316-.447-.613-.674-.917-.19-.253-.366-.513-.568-.76a22%2022%200%200%200-1.402-1.551l-.011-.012-.011-.01a22%2022%200%200%200-1.552-1.403c-.247-.203-.507-.379-.761-.569-.303-.227-.6-.462-.916-.673-.304-.203-.619-.379-.931-.565-.286-.171-.565-.35-.859-.508-.318-.17-.644-.314-.969-.467-.307-.145-.609-.298-.923-.429-.315-.13-.637-.236-.957-.35-.337-.121-.669-.25-1.013-.354-.32-.097-.646-.168-.969-.249-.351-.089-.698-.187-1.055-.258-.375-.074-.753-.119-1.13-.173-.311-.044-.617-.104-.933-.135A22%2022%200%200%200%20490.667%200H384c-11.782%200-21.333%209.551-21.333%2021.333S372.218%2042.666%20384%2042.666h55.163L304.915%20176.915c-8.331%208.331-8.331%2021.839%200%2030.17s21.839%208.331%2030.17%200zm-158.17%2097.83L42.667%20439.163V384c0-11.782-9.551-21.333-21.333-21.333C9.551%20362.667%200%20372.218%200%20384v106.667q.001%201.055.106%202.105c.031.315.09.621.135.933.054.377.098.756.173%201.13.071.358.169.704.258%201.055.081.324.152.649.249.969.104.344.233.677.354%201.013.115.32.22.642.35.957s.284.616.429.923c.153.324.297.651.467.969.158.294.337.573.508.859.186.311.362.627.565.931.211.316.446.612.673.916.19.254.366.514.569.761q.664.811%201.403%201.552l.01.011.012.011q.741.738%201.551%201.402c.247.203.507.379.76.568.304.227.601.463.917.674.303.203.618.379.93.565.286.171.565.35.86.508.318.17.645.314.969.467.307.145.609.298.923.428s.636.235.956.35c.337.121.67.25%201.015.355.32.097.645.168.968.249.351.089.698.187%201.056.258.375.074.753.118%201.13.172.311.044.618.104.933.135q1.05.105%202.104.106H128c11.782%200%2021.333-9.551%2021.333-21.333s-9.551-21.333-21.333-21.333H72.837l134.248-134.248c8.331-8.331%208.331-21.839%200-30.17s-21.839-8.331-30.17%200zm330.821%20198.51c.226-.302.461-.598.671-.913.204-.304.38-.62.566-.932.17-.285.349-.564.506-.857.17-.318.315-.646.468-.971.145-.306.297-.607.428-.921.13-.315.236-.637.35-.957.121-.337.25-.669.354-1.013.097-.32.168-.646.249-.969.089-.351.187-.698.258-1.055.074-.375.118-.753.173-1.13.044-.311.104-.617.135-.933a22%2022%200%200%200%20.106-2.107V384c0-11.782-9.551-21.333-21.333-21.333s-21.333%209.551-21.333%2021.333v55.163L335.085%20304.915c-8.331-8.331-21.839-8.331-30.17%200s-8.331%2021.839%200%2030.17l134.248%20134.248H384c-11.782%200-21.333%209.551-21.333%2021.333S372.218%20512%20384%20512h106.667q1.055-.001%202.105-.106c.315-.031.621-.09.933-.135.377-.054.756-.098%201.13-.173.358-.071.704-.169%201.055-.258.324-.081.649-.152.969-.249.344-.104.677-.233%201.013-.354.32-.115.642-.22.957-.35s.615-.283.921-.428c.325-.153.653-.297.971-.468.293-.157.572-.336.857-.506.312-.186.628-.363.932-.566.315-.211.611-.445.913-.671.255-.191.516-.368.764-.571q.804-.659%201.54-1.392l.023-.021.021-.023q.732-.736%201.392-1.54c.205-.248.382-.509.573-.764zM72.837%2042.667H128c11.782%200%2021.333-9.551%2021.333-21.333C149.333%209.551%20139.782%200%20128%200H21.332q-1.054.001-2.104.106c-.316.031-.622.09-.933.135-.377.054-.755.098-1.13.172-.358.071-.705.169-1.056.258-.323.081-.648.152-.968.249-.345.104-.678.234-1.015.355-.319.115-.641.22-.956.35-.315.131-.618.284-.925.43-.323.152-.65.296-.967.466-.295.158-.575.338-.862.509-.31.185-.625.36-.928.563-.317.212-.615.448-.92.676-.252.189-.511.364-.756.566a21.5%2021.5%200%200%200-2.977%202.977c-.202.245-.377.504-.566.757-.228.305-.464.603-.676.92-.203.303-.378.617-.564.928-.171.286-.351.567-.509.862-.17.317-.313.643-.466.967-.145.307-.299.61-.43.925-.13.315-.235.636-.35.956-.121.337-.25.67-.355%201.015-.097.32-.168.645-.249.968-.089.351-.187.698-.258%201.056-.074.375-.118.753-.172%201.13-.044.311-.104.618-.135.933A22%2022%200%200%200%200%2021.333V128c0%2011.782%209.551%2021.333%2021.333%2021.333S42.666%20139.782%2042.666%20128V72.837l134.248%20134.248c8.331%208.331%2021.839%208.331%2030.17%200s8.331-21.839%200-30.17z%22%2F%3E%3C%2Fsvg%3E');
}
.expandVODChat .expand-toggle-li,
.expandLiveChat .expand-toggle-li {
background-image: url('data:image/svg+xml,%3Csvg%20fill%3D%22%23757B8A%22%20height%3D%2264%22%20width%3D%2264%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20512%20512%22%20xml%3Aspace%3D%22preserve%22%20stroke%3D%22%23757B8A%22%3E%3Cg%20stroke-width%3D%220%22%2F%3E%3Cg%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M320.106%20172.772c.031.316.09.622.135.933.054.377.098.755.172%201.13.071.358.169.705.258%201.056.081.323.152.648.249.968.104.345.234.678.355%201.015.115.319.22.641.35.956.131.315.284.618.43.925.152.323.296.65.466.967.158.294.337.574.508.86.186.311.362.626.565.93.211.316.447.613.674.917.19.253.365.513.568.759a21.4%2021.4%200%200%200%202.977%202.977c.246.202.506.378.759.567.304.228.601.463.918.675.303.203.618.379.929.565.286.171.566.351.861.509.317.17.644.314.968.466.307.145.609.298.924.429.315.13.637.236.957.35.337.121.669.25%201.013.354.32.097.646.168.969.249.351.089.698.187%201.055.258.375.074.753.119%201.13.173.311.044.617.104.932.135q1.051.105%202.105.106H448c11.782%200%2021.333-9.551%2021.333-21.333s-9.551-21.333-21.333-21.333h-55.163L505.752%2036.418c8.331-8.331%208.331-21.839%200-30.17s-21.839-8.331-30.17%200L362.667%20119.163V64c0-11.782-9.551-21.333-21.333-21.333C329.551%2042.667%20320%2052.218%20320%2064v106.668q.001%201.053.106%202.104zM170.667%2042.667c-11.782%200-21.333%209.551-21.333%2021.333v55.163L36.418%206.248c-8.331-8.331-21.839-8.331-30.17%200s-8.331%2021.839%200%2030.17l112.915%20112.915H64c-11.782%200-21.333%209.551-21.333%2021.333C42.667%20182.449%2052.218%20192%2064%20192h106.667q1.055-.001%202.105-.106c.316-.031.622-.09.933-.135.377-.054.755-.098%201.13-.172.358-.071.705-.169%201.056-.258.323-.081.648-.152.968-.249.345-.104.678-.234%201.015-.355.319-.115.641-.22.956-.35.315-.131.618-.284.925-.43.323-.152.65-.296.967-.466.295-.158.575-.338.862-.509.311-.185.625-.361.928-.564.317-.212.615-.448.92-.676.252-.189.511-.364.757-.566a21.5%2021.5%200%200%200%202.977-2.977c.202-.246.377-.505.566-.757.228-.305.464-.603.676-.92.203-.303.378-.617.564-.928.171-.286.351-.567.509-.862.17-.317.313-.643.466-.967.145-.307.299-.61.43-.925.13-.315.235-.636.35-.956.121-.337.25-.67.355-1.015.097-.32.168-.645.249-.968.089-.351.187-.698.258-1.056.074-.375.118-.753.172-1.13.044-.311.104-.618.135-.933q.105-1.05.106-2.104V64c-.002-11.782-9.553-21.333-21.335-21.333zm21.227%20296.561c-.031-.316-.09-.622-.135-.933-.054-.377-.098-.755-.172-1.13-.071-.358-.169-.705-.258-1.056-.081-.323-.152-.648-.249-.968-.104-.345-.234-.678-.355-1.015-.115-.319-.22-.641-.35-.956-.131-.315-.284-.618-.43-.925-.152-.323-.296-.65-.466-.967-.158-.295-.338-.575-.509-.862-.185-.311-.361-.625-.564-.928-.212-.317-.448-.615-.676-.92-.189-.252-.364-.511-.566-.757a21.5%2021.5%200%200%200-2.977-2.977c-.246-.202-.505-.377-.757-.566-.305-.228-.603-.464-.92-.676-.303-.203-.617-.378-.928-.564-.286-.171-.567-.351-.862-.509-.317-.17-.643-.313-.967-.466-.307-.145-.61-.299-.925-.43-.315-.13-.636-.235-.956-.35-.337-.121-.67-.25-1.015-.355-.32-.097-.645-.168-.968-.249-.351-.089-.698-.187-1.056-.258-.375-.074-.753-.118-1.13-.172-.311-.044-.618-.104-.933-.135q-1.051-.105-2.105-.106H64c-11.782%200-21.333%209.551-21.333%2021.333S52.218%20362.664%2064%20362.664h55.163L6.248%20475.582c-8.331%208.331-8.331%2021.839%200%2030.17s21.839%208.331%2030.17%200l112.915-112.915V448c0%2011.782%209.551%2021.333%2021.333%2021.333s21.333-9.551%2021.333-21.333V341.332a21%2021%200%200%200-.105-2.104zm200.943%2023.439H448c11.782%200%2021.333-9.551%2021.333-21.333s-9.551-21.333-21.333-21.333H341.333q-1.055.001-2.105.106c-.315.031-.621.09-.932.135-.378.054-.756.098-1.13.173-.358.071-.704.169-1.055.258-.324.081-.649.152-.969.249-.344.104-.677.233-1.013.354-.32.115-.642.22-.957.35-.315.131-.617.284-.924.429-.324.153-.65.296-.968.466-.295.158-.575.338-.861.509-.311.186-.626.362-.929.565-.316.212-.614.447-.918.675-.253.19-.512.365-.759.567a21.4%2021.4%200%200%200-2.977%202.977c-.202.246-.378.506-.568.759-.227.304-.463.601-.674.917-.203.304-.379.619-.565.93-.171.286-.351.566-.508.86-.17.317-.313.643-.466.967-.145.307-.299.61-.43.925-.13.315-.235.636-.35.956-.121.337-.25.67-.355%201.015-.097.32-.168.645-.249.968-.089.351-.187.698-.258%201.056-.074.374-.118.753-.172%201.13-.044.311-.104.618-.135.933q-.105%201.05-.106%202.104V448c0%2011.782%209.551%2021.333%2021.333%2021.333s21.333-9.551%2021.333-21.333v-55.163l112.915%20112.915c8.331%208.331%2021.839%208.331%2030.17%200s8.331-21.839%200-30.17z%22%2F%3E%3C%2Fsvg%3E');
}
html[dark="true"] .expand-toggle-li {
background-image: url('data:image/svg+xml,%3Csvg%20fill%3D%22%23ACB0B9%22%20height%3D%2264%22%20width%3D%2264%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20512%20512%22%20xml%3Aspace%3D%22preserve%22%3E%3Cg%20stroke-width%3D%220%22%2F%3E%3Cg%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M335.085%20207.085%20469.333%2072.837V128c0%2011.782%209.551%2021.333%2021.333%2021.333S512%20139.782%20512%20128V21.335q-.001-1.055-.106-2.107c-.031-.316-.09-.622-.135-.933-.054-.377-.098-.755-.172-1.13-.071-.358-.169-.705-.258-1.056-.081-.323-.152-.648-.249-.968-.104-.345-.234-.678-.355-1.015-.115-.319-.22-.641-.35-.956s-.284-.616-.428-.923c-.153-.324-.297-.651-.467-.969-.158-.294-.337-.574-.508-.86-.186-.311-.362-.626-.565-.93-.211-.316-.447-.613-.674-.917-.19-.253-.366-.513-.568-.76a22%2022%200%200%200-1.402-1.551l-.011-.012-.011-.01a22%2022%200%200%200-1.552-1.403c-.247-.203-.507-.379-.761-.569-.303-.227-.6-.462-.916-.673-.304-.203-.619-.379-.931-.565-.286-.171-.565-.35-.859-.508-.318-.17-.644-.314-.969-.467-.307-.145-.609-.298-.923-.429-.315-.13-.637-.236-.957-.35-.337-.121-.669-.25-1.013-.354-.32-.097-.646-.168-.969-.249-.351-.089-.698-.187-1.055-.258-.375-.074-.753-.119-1.13-.173-.311-.044-.617-.104-.933-.135A22%2022%200%200%200%20490.667%200H384c-11.782%200-21.333%209.551-21.333%2021.333S372.218%2042.666%20384%2042.666h55.163L304.915%20176.915c-8.331%208.331-8.331%2021.839%200%2030.17s21.839%208.331%2030.17%200m-158.17%2097.83L42.667%20439.163V384c0-11.782-9.551-21.333-21.333-21.333C9.551%20362.667%200%20372.218%200%20384v106.667q.001%201.055.106%202.105c.031.315.09.621.135.933.054.377.098.756.173%201.13.071.358.169.704.258%201.055.081.324.152.649.249.969.104.344.233.677.354%201.013.115.32.22.642.35.957s.284.616.429.923c.153.324.297.651.467.969.158.294.337.573.508.859.186.311.362.627.565.931.211.316.446.612.673.916.19.254.366.514.569.761q.664.811%201.403%201.552l.01.011.012.011q.741.738%201.551%201.402c.247.203.507.379.76.568.304.227.601.463.917.674.303.203.618.379.93.565.286.171.565.35.86.508.318.17.645.314.969.467.307.145.609.298.923.428s.636.235.956.35c.337.121.67.25%201.015.355.32.097.645.168.968.249.351.089.698.187%201.056.258.375.074.753.118%201.13.172.311.044.618.104.933.135q1.05.105%202.104.106H128c11.782%200%2021.333-9.551%2021.333-21.333s-9.551-21.333-21.333-21.333H72.837l134.248-134.248c8.331-8.331%208.331-21.839%200-30.17s-21.839-8.331-30.17%200m330.821%20198.51c.226-.302.461-.598.671-.913.204-.304.38-.62.566-.932.17-.285.349-.564.506-.857.17-.318.315-.646.468-.971.145-.306.297-.607.428-.921.13-.315.236-.637.35-.957.121-.337.25-.669.354-1.013.097-.32.168-.646.249-.969.089-.351.187-.698.258-1.055.074-.375.118-.753.173-1.13.044-.311.104-.617.135-.933a22%2022%200%200%200%20.106-2.107V384c0-11.782-9.551-21.333-21.333-21.333s-21.333%209.551-21.333%2021.333v55.163L335.085%20304.915c-8.331-8.331-21.839-8.331-30.17%200s-8.331%2021.839%200%2030.17l134.248%20134.248H384c-11.782%200-21.333%209.551-21.333%2021.333S372.218%20512%20384%20512h106.667q1.055-.001%202.105-.106c.315-.031.621-.09.933-.135.377-.054.756-.098%201.13-.173.358-.071.704-.169%201.055-.258.324-.081.649-.152.969-.249.344-.104.677-.233%201.013-.354.32-.115.642-.22.957-.35s.615-.283.921-.428c.325-.153.653-.297.971-.468.293-.157.572-.336.857-.506.312-.186.628-.363.932-.566.315-.211.611-.445.913-.671.255-.191.516-.368.764-.571q.804-.659%201.54-1.392l.023-.021.021-.023q.732-.736%201.392-1.54c.205-.248.382-.509.573-.764M72.837%2042.667H128c11.782%200%2021.333-9.551%2021.333-21.333C149.333%209.551%20139.782%200%20128%200H21.332q-1.054.001-2.104.106c-.316.031-.622.09-.933.135-.377.054-.755.098-1.13.172-.358.071-.705.169-1.056.258-.323.081-.648.152-.968.249-.345.104-.678.234-1.015.355-.319.115-.641.22-.956.35-.315.131-.618.284-.925.43-.323.152-.65.296-.967.466-.295.158-.575.338-.862.509-.31.185-.625.36-.928.563-.317.212-.615.448-.92.676-.252.189-.511.364-.756.566a21.5%2021.5%200%200%200-2.977%202.977c-.202.245-.377.504-.566.757-.228.305-.464.603-.676.92-.203.303-.378.617-.564.928-.171.286-.351.567-.509.862-.17.317-.313.643-.466.967-.145.307-.299.61-.43.925-.13.315-.235.636-.35.956-.121.337-.25.67-.355%201.015-.097.32-.168.645-.249.968-.089.351-.187.698-.258%201.056-.074.375-.118.753-.172%201.13-.044.311-.104.618-.135.933A22%2022%200%200%200%200%2021.333V128c0%2011.782%209.551%2021.333%2021.333%2021.333S42.666%20139.782%2042.666%20128V72.837l134.248%20134.248c8.331%208.331%2021.839%208.331%2030.17%200s8.331-21.839%200-30.17z%22%2F%3E%3C%2Fsvg%3E') !important;
}
html[dark="true"] .expandVODChat .expand-toggle-li,
html[dark="true"] .expandLiveChat .expand-toggle-li {
background-image: url('data:image/svg+xml,%3Csvg%20fill%3D%22%23ACB0B9%22%20height%3D%2264%22%20width%3D%2264%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20512%20512%22%20xml%3Aspace%3D%22preserve%22%20stroke%3D%22%23ACB0B9%22%3E%3Cg%20stroke-width%3D%220%22%2F%3E%3Cg%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke%3D%22%23CCC%22%20stroke-width%3D%222.048%22%2F%3E%3Cpath%20d%3D%22M320.106%20172.772c.031.316.09.622.135.933.054.377.098.755.172%201.13.071.358.169.705.258%201.056.081.323.152.648.249.968.104.345.234.678.355%201.015.115.319.22.641.35.956.131.315.284.618.43.925.152.323.296.65.466.967.158.294.337.574.508.86.186.311.362.626.565.93.211.316.447.613.674.917.19.253.365.513.568.759a21.4%2021.4%200%200%200%202.977%202.977c.246.202.506.378.759.567.304.228.601.463.918.675.303.203.618.379.929.565.286.171.566.351.861.509.317.17.644.314.968.466.307.145.609.298.924.429.315.13.637.236.957.35.337.121.669.25%201.013.354.32.097.646.168.969.249.351.089.698.187%201.055.258.375.074.753.119%201.13.173.311.044.617.104.932.135q1.051.105%202.105.106H448c11.782%200%2021.333-9.551%2021.333-21.333s-9.551-21.333-21.333-21.333h-55.163L505.752%2036.418c8.331-8.331%208.331-21.839%200-30.17s-21.839-8.331-30.17%200L362.667%20119.163V64c0-11.782-9.551-21.333-21.333-21.333C329.551%2042.667%20320%2052.218%20320%2064v106.668q.001%201.053.106%202.104zM170.667%2042.667c-11.782%200-21.333%209.551-21.333%2021.333v55.163L36.418%206.248c-8.331-8.331-21.839-8.331-30.17%200s-8.331%2021.839%200%2030.17l112.915%20112.915H64c-11.782%200-21.333%209.551-21.333%2021.333C42.667%20182.449%2052.218%20192%2064%20192h106.667q1.055-.001%202.105-.106c.316-.031.622-.09.933-.135.377-.054.755-.098%201.13-.172.358-.071.705-.169%201.056-.258.323-.081.648-.152.968-.249.345-.104.678-.234%201.015-.355.319-.115.641-.22.956-.35.315-.131.618-.284.925-.43.323-.152.65-.296.967-.466.295-.158.575-.338.862-.509.311-.185.625-.361.928-.564.317-.212.615-.448.92-.676.252-.189.511-.364.757-.566a21.5%2021.5%200%200%200%202.977-2.977c.202-.246.377-.505.566-.757.228-.305.464-.603.676-.92.203-.303.378-.617.564-.928.171-.286.351-.567.509-.862.17-.317.313-.643.466-.967.145-.307.299-.61.43-.925.13-.315.235-.636.35-.956.121-.337.25-.67.355-1.015.097-.32.168-.645.249-.968.089-.351.187-.698.258-1.056.074-.375.118-.753.172-1.13.044-.311.104-.618.135-.933q.105-1.05.106-2.104V64c-.002-11.782-9.553-21.333-21.335-21.333zm21.227%20296.561c-.031-.316-.09-.622-.135-.933-.054-.377-.098-.755-.172-1.13-.071-.358-.169-.705-.258-1.056-.081-.323-.152-.648-.249-.968-.104-.345-.234-.678-.355-1.015-.115-.319-.22-.641-.35-.956-.131-.315-.284-.618-.43-.925-.152-.323-.296-.65-.466-.967-.158-.295-.338-.575-.509-.862-.185-.311-.361-.625-.564-.928-.212-.317-.448-.615-.676-.92-.189-.252-.364-.511-.566-.757a21.5%2021.5%200%200%200-2.977-2.977c-.246-.202-.505-.377-.757-.566-.305-.228-.603-.464-.92-.676-.303-.203-.617-.378-.928-.564-.286-.171-.567-.351-.862-.509-.317-.17-.643-.313-.967-.466-.307-.145-.61-.299-.925-.43-.315-.13-.636-.235-.956-.35-.337-.121-.67-.25-1.015-.355-.32-.097-.645-.168-.968-.249-.351-.089-.698-.187-1.056-.258-.375-.074-.753-.118-1.13-.172-.311-.044-.618-.104-.933-.135q-1.051-.105-2.105-.106H64c-11.782%200-21.333%209.551-21.333%2021.333S52.218%20362.664%2064%20362.664h55.163L6.248%20475.582c-8.331%208.331-8.331%2021.839%200%2030.17s21.839%208.331%2030.17%200l112.915-112.915V448c0%2011.782%209.551%2021.333%2021.333%2021.333s21.333-9.551%2021.333-21.333V341.332a21%2021%200%200%200-.105-2.104zm200.943%2023.439H448c11.782%200%2021.333-9.551%2021.333-21.333s-9.551-21.333-21.333-21.333H341.333q-1.055.001-2.105.106c-.315.031-.621.09-.932.135-.378.054-.756.098-1.13.173-.358.071-.704.169-1.055.258-.324.081-.649.152-.969.249-.344.104-.677.233-1.013.354-.32.115-.642.22-.957.35-.315.131-.617.284-.924.429-.324.153-.65.296-.968.466-.295.158-.575.338-.861.509-.311.186-.626.362-.929.565-.316.212-.614.447-.918.675-.253.19-.512.365-.759.567a21.4%2021.4%200%200%200-2.977%202.977c-.202.246-.378.506-.568.759-.227.304-.463.601-.674.917-.203.304-.379.619-.565.93-.171.286-.351.566-.508.86-.17.317-.313.643-.466.967-.145.307-.299.61-.43.925-.13.315-.235.636-.35.956-.121.337-.25.67-.355%201.015-.097.32-.168.645-.249.968-.089.351-.187.698-.258%201.056-.074.374-.118.753-.172%201.13-.044.311-.104.618-.135.933q-.105%201.05-.106%202.104V448c0%2011.782%209.551%2021.333%2021.333%2021.333s21.333-9.551%2021.333-21.333v-55.163l112.915%20112.915c8.331%208.331%2021.839%208.331%2030.17%200s8.331-21.839%200-30.17z%22%2F%3E%3C%2Fsvg%3E') !important;
}
.screen_mode .expand-toggle-li,
.fullScreen_mode .expand-toggle-li {
display: none !important;
}
.customSidebar #serviceLnb {
display: none !important;
}
.left_navbar {
display: flex;
align-items: center;
justify-content: flex-end;
position: fixed;
flex-direction: row-reverse;
top: 0px;
left: 128px;
z-index: 9999;
background-color: white;
}
html[dark="true"] .left_navbar {
background-color: #0c0d0e;
}
html[dark="true"] .left_nav_button {
color: #e5e5e5;
}
html:not([dark="true"]) .left_nav_button {
color: #1F1F23;
}
html[dark="true"] .left_nav_button {
color: #e5e5e5;
}
html:not([dark="true"]) .left_nav_button {
color: #1F1F23;
}
.left_navbar button.left_nav_button {
position: relative;
width: 68px;
height: 64px;
padding: 0;
border: 0;
cursor: pointer;
z-index: 3001;
font-size: 1.25em !important;
font-weight: 600;
}
@media (max-width: 1280px) {
#serviceHeader .left_navbar {
left: 124px !important;
}
#serviceHeader .left_nav_button {
width: 58px !important;
font-size: 1.2em !important;
}
}
@media (max-width: 1100px) {
#serviceHeader .left_navbar {
left: 120px !important;
}
#serviceHeader .left_nav_button {
width: 46px !important;
font-size: 1.1em !important;
}
}
#sidebar {
top: 64px;
display: flex !important;
flex-direction: column !important;
}
#sidebar .top-section.follow {
order: 1;
}
#sidebar .users-section.follow {
order: 2;
}
#sidebar .top-section.myplus {
order: 3;
}
#sidebar .users-section.myplus {
order: 4;
}
#sidebar .top-section.myplusvod {
order: 5;
}
#sidebar .users-section.myplusvod {
order: 6;
}
#sidebar .top-section.top {
order: 7;
}
#sidebar .users-section.top {
order: 8;
}
.starting-line .chatting-list-item .message-container .username {
width: ${nicknameWidth}px !important;
}
.duration-overlay {
position: absolute;
top: 235px;
right: 4px;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 2px 5px;
font-size: 15px;
border-radius: 3px;
z-index:9999;
line-height: 17px;
}
#studioPlayKorPlayer,
#studioPlayKor,
#studioPlay,
.btn-broadcast {
display: none;
}
#myModal.modal {
display: none;
position: fixed;
z-index: 9999;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.4);
color: black;
}
#myModal .modal-content {
background-color: #fefefe;
margin: 15% auto;
padding: 20px;
border: 1px solid #888;
border-radius: 10px;
width: clamp(400px, 80%, 550px);
}
#myModal .modal-content .myModalClose {
color: #aaa;
float: right;
font-size: 36px;
font-weight: bold;
margin-top: -12px;
}
#myModal .modal-content .myModalClose:hover,
#myModal .modal-content .myModalClose:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
#myModal .modal-content .option {
margin-bottom: 10px;
display: flex;
align-items: center;
}
#myModal .modal-content .option label {
margin-right: 10px;
font-size: 15px;
}
#myModal .modal-content .switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
transform: scale(0.9); /* 축소 */
}
#myModal .modal-content .switch input {
display: none;
}
#myModal .modal-content .slider_95642 {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 34px;
}
#myModal .modal-content .slider_95642:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
#myModal .modal-content .slider_95642.round {
border-radius: 34px;
min-width: 60px;
}
#myModal .modal-content .slider_95642.round:before {
border-radius: 50%;
}
#myModal .modal-content input:checked + .slider_95642 {
background-color: #2196F3;
}
#myModal .modal-content input:focus + .slider_95642 {
box-shadow: 0 0 1px #2196F3;
}
#myModal .modal-content input:checked + .slider_95642:before {
transform: translateX(26px);
}
#myModal .modal-content #range {
width: 100%;
}
#myModal .modal-content #rangeValue {
display: inline-block;
margin-left: 10px;
}
#myModal .modal-content .divider {
width: 100%; /* 가로 폭 설정 */
height: 1px; /* 세로 높이 설정 */
background-color: #000; /* 배경색 설정 */
margin: 20px 0; /* 위아래 여백 설정 */
}
#openModalBtn {
box-sizing: border-box;
font-size: 12px;
line-height: 1.2 !important;
font-family: "NG";
list-style: none;
position: relative;
margin-left: 12px;
width: 40px;
height: 40px;
}
#topInnerHeader #openModalBtn {
margin-right: 12px;
}
#openModalBtn > button.btn-settings-ui {
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' fill='none'%3e%3cpath stroke='%23757B8A' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.4' d='M8.269 2.061c.44-1.815 3.022-1.815 3.462 0a1.782 1.782 0 0 0 2.658 1.101c1.595-.971 3.42.854 2.449 2.449a1.781 1.781 0 0 0 1.1 2.658c1.816.44 1.816 3.022 0 3.462a1.781 1.781 0 0 0-1.1 2.659c.971 1.595-.854 3.42-2.449 2.448a1.781 1.781 0 0 0-2.658 1.101c-.44 1.815-3.022 1.815-3.462 0a1.781 1.781 0 0 0-2.658-1.101c-1.595.972-3.42-.854-2.449-2.448a1.782 1.782 0 0 0-1.1-2.659c-1.816-.44-1.816-3.021 0-3.462a1.782 1.782 0 0 0 1.1-2.658c-.972-1.595.854-3.42 2.449-2.449a1.781 1.781 0 0 0 2.658-1.1Z'/%3e%3cpath stroke='%23757B8A' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.4' d='M13.1 10a3.1 3.1 0 1 1-6.2 0 3.1 3.1 0 0 1 6.2 0Z'/%3e%3c/svg%3e") 50% 50% no-repeat !important;
background-size: 18px !important;
}
html[dark="true"] #openModalBtn > button.btn-settings-ui {
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' fill='none'%3e%3cpath stroke='%23ACB0B9' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.4' d='M8.269 2.061c.44-1.815 3.022-1.815 3.462 0a1.782 1.782 0 0 0 2.658 1.101c1.595-.971 3.42.854 2.449 2.449a1.781 1.781 0 0 0 1.1 2.658c1.816.44 1.816 3.022 0 3.462a1.781 1.781 0 0 0-1.1 2.659c.971 1.595-.854 3.42-2.449 2.448a1.781 1.781 0 0 0-2.658 1.101c-.44 1.815-3.022 1.815-3.462 0a1.781 1.781 0 0 0-2.658-1.101c-1.595.972-3.42-.854-2.449-2.448a1.782 1.782 0 0 0-1.1-2.659c-1.816-.44-1.816-3.021 0-3.462a1.782 1.782 0 0 0 1.1-2.658c-.972-1.595.854-3.42 2.449-2.449a1.781 1.781 0 0 0 2.658-1.1Z'/%3e%3cpath stroke='%23ACB0B9' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.4' d='M13.1 10a3.1 3.1 0 1 1-6.2 0 3.1 3.1 0 0 1 6.2 0Z'/%3e%3c/svg%3e") 50% 50% no-repeat !important;
background-size: 18px !important;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* .red-dot이 있을 때만 회전 */
#openModalBtn:has(.red-dot) .btn-settings-ui {
animation: rotate 4s linear infinite;
animation-duration: 4s; /* 4초에 한 번 회전 */
animation-iteration-count: 10; /* 10번 반복 */
}
#sidebar.max {
width: 240px;
}
#sidebar.min {
width: 52px;
}
#sidebar.min .users-section a.user span {
display: none;
}
#sidebar.min .users-section button {
font-size:12px;
padding: 4px;
}
#sidebar.max .button-fold-sidebar {
background-size: 7px 11px;
background-repeat: no-repeat;
width: 26px;
height: 26px;
background-position: center;
position: absolute;
top: 13px;
left: 200px;
}
#sidebar.max .button-unfold-sidebar {
display:none;
}
#sidebar.min .button-fold-sidebar {
display:none;
}
#sidebar.min .button-unfold-sidebar {
background-size: 7px 11px;
background-repeat: no-repeat;
width: 26px;
height: 26px;
background-position: center;
position: relative;
top: 8px;
left: 12px;
padding-top:16px;
padding-bottom:12px;
}
#sidebar.min .top-section span.max{
display:none;
}
#sidebar.max .top-section span.min{
display:none;
}
#toggleButton, #toggleButton2, #toggleButton3, #toggleButton4 {
padding: 7px 0px;
width: 100%;
text-align: center;
font-size: 14px;
}
html[dark="true"] #toggleButton,
html[dark="true"] #toggleButton2,
html[dark="true"] #toggleButton3,
html[dark="true"] #toggleButton4 {
color:#A1A1A1;
}
html:not([dark="true"]) #toggleButton,
html:not([dark="true"]) #toggleButton2,
html:not([dark="true"]) #toggleButton3,
html:not([dark="true"]) #toggleButton4 {
color: #53535F;
}
#sidebar {
grid-area: sidebar;
padding-bottom: 360px;
height: 100vh;
overflow-y: auto;
position: fixed;
scrollbar-width: none; /* 파이어폭스 */
transition: all 0.1s ease-in-out; /* 부드러운 전환 효과 */
}
#sidebar::-webkit-scrollbar {
display: none; /* Chrome, Safari, Edge */
}
#sidebar .top-section {
display: flex;
align-items: center;
justify-content: space-around;
margin: 12px 0px 6px 0px;
line-height: 17px;
}
#sidebar .top-section > span {
text-transform: uppercase;
font-weight: 550;
font-size: 14px;
margin-top: 6px;
margin-bottom: 2px;
}
.users-section .user.show-more {
max-height: 0;
opacity: 0;
padding-top: 0;
padding-bottom: 0;
pointer-events: none;
}
.users-section .user {
display: grid;
grid-template-areas: "profile-picture username watchers" "profile-picture description blank";
grid-template-columns: 40px auto auto;
padding: 5px 10px;
max-height: 50px;
opacity: 1;
overflow: hidden;
transition: opacity 0.7s ease;
}
.users-section .user:hover {
cursor: pointer;
}
.users-section .user .profile-picture {
grid-area: profile-picture;
width: 30px;
height: 30px;
border-radius: 50%;
line-height: 20px;
}
.users-section .user .username {
grid-area: username;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.6px;
margin-left:1px;
line-height: 17px;
}
.users-section .user .description {
grid-area: description;
font-size: 13px;
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-left:1px;
line-height: 16px;
}
.users-section .user .watchers {
grid-area: watchers;
display: flex;
align-items: center;
justify-content: flex-end;
font-weight: 400;
font-size: 14px;
margin-right: 2px;
line-height: 17px;
}
.users-section .user .watchers .dot {
font-size: 10px;
margin-right: 5px;
color: #ff2424;
}
.users-section .user .watchers .dot.greendot {
color: #34c76b !important;
}
.tooltip-container {
z-index: 999;
width: 460px;
height: auto;
position: fixed;
display: flex;
flex-direction: column;
align-items: center;
border-radius: 10px;
box-shadow: 5px 5px 10px 0px rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.1s ease-in-out;
pointer-events: none;
}
.tooltip-container.visible {
opacity: 1;
pointer-events: auto;
}
.tooltip-container img {
z-index: 999;
width: 100%; /* 컨테이너의 너비에 맞게 확장 */
height: 260px; /* 고정 높이 */
object-fit: cover; /* 비율 유지하며 공간에 맞게 잘리기 */
border-top-left-radius: 10px;
border-top-right-radius: 10px;
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}
.tooltiptext {
position: relative;
z-index: 999;
width: 100%;
max-width: 460px;
height: auto;
text-align: center;
box-sizing: border-box;
padding: 14px 20px;
font-size: 17px;
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
line-height: 22px;
overflow-wrap: break-word;
}
.tooltiptext .dot {
font-size: 11px;
margin-right: 2px;
vertical-align: middle;
line-height: 22px;
display: inline-block;
}
.profile-grayscale {
filter: grayscale(100%) contrast(85%);
opacity: .8;
}
#sidebar.max .small-user-layout.show-more {
max-height: 0;
opacity: 0;
padding: 0 !important;
pointer-events: none;
}
#sidebar.max .small-user-layout {
grid-template-areas: "profile-picture username description watchers" !important;
grid-template-columns: 24px auto 1fr auto !important;
padding: 4px 10px !important;
gap: 8px !important;
max-height: 32px;
opacity: 1;
overflow: hidden;
transition: opacity 0.4s ease;
}
#sidebar.max .small-user-layout .profile-picture {
width: 24px !important;
height: 24px !important;
border-radius: 20% !important;
}
#sidebar.max .small-user-layout .username {
max-width: 80px !important;
font-size: 14px !important;
line-height: 24px !important;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
#sidebar.max .small-user-layout .description {
font-size: 12px !important;
line-height: 24px !important;
}
#sidebar.max .small-user-layout .watchers {
font-size: 14px !important;
line-height: 24px !important;
}
#sidebar.max .small-user-layout .watchers .dot {
font-size: 8px !important;
margin-right: 4px !important;
}
.customSidebar #serviceHeader .a_d_banner {
display: none !important;
}
.customSidebar #serviceHeader .btn_flexible+.logo_wrap {
left: 24px !important;
}
.customSidebar #serviceHeader .logo_wrap {
left: 24px !important;
}
html[dark="true"] .users-section .user.user-offline span {
filter: grayscale(1) brightness(0.8); /* 다크모드: 완전 흑백과 약간 어둡게 */
}
html:not([dark="true"]) .users-section .user.user-offline span {
opacity: 0.7; /* 밝은 모드: 투명하게 */
}
/* darkMode Sidebar Styles */
html[dark="true"] #sidebar.max .button-fold-sidebar {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='none slice' viewBox='0 0 7 11'%3e%3cpath fill='%23f9f9f9' d='M5.87 11.01L.01 5.51 5.87.01l1.08 1.01-4.74 4.45L7 9.96 5.87 11z'/%3e%3c/svg%3e");
}
html[dark="true"] #sidebar.min .button-unfold-sidebar {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='none slice' viewBox='0 0 7 11'%3e%3cpath fill='%23f9f9f9' d='M1.13 11.01l5.86-5.5L1.13.01.05 1.02l4.74 4.45L0 9.96 1.13 11z'/%3e%3c/svg%3e");
}
html[dark="true"] #sidebar {
color: white;
background-color: #1F1F23;
}
html[dark="true"] #sidebar .top-section > span {
color:#DEDEE3;
}
html[dark="true"] #sidebar .top-section > span > a {
color:#DEDEE3;
}
html[dark="true"] .users-section .user:hover {
background-color: #26262c;
}
html[dark="true"] .users-section .user .username {
color:#DEDEE3;
}
html[dark="true"] .users-section .user .description {
color: #a1a1a1;
}
html[dark="true"] .users-section .user .watchers {
color: #c0c0c0;
}
html[dark="true"] .tooltip-container {
background-color: #26262C;
}
html[dark="true"] .tooltiptext {
color: #fff;
background-color: #26262C;
}
/* whiteMode Sidebar Styles */
html:not([dark="true"]) #sidebar.max .button-fold-sidebar {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='none slice' viewBox='0 0 7 11'%3e%3cpath fill='%23888' d='M5.87 11.01L.01 5.51 5.87.01l1.08 1.01-4.74 4.45L7 9.96 5.87 11z'/%3e%3c/svg%3e");
}
html:not([dark="true"]) #sidebar.min .button-unfold-sidebar {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='none slice' viewBox='0 0 7 11'%3e%3cpath fill='%23888' d='M1.13 11.01l5.86-5.5L1.13.01.05 1.02l4.74 4.45L0 9.96 1.13 11z'/%3e%3c/svg%3e");
}
html:not([dark="true"]) #sidebar {
color: white;
background-color: #EFEFF1;
}
html:not([dark="true"]) #sidebar .top-section > span {
color:#0E0E10;
}
html:not([dark="true"]) #sidebar .top-section > span > a {
color:#0E0E10;
}
html:not([dark="true"]) .users-section .user:hover {
background-color: #E6E6EA;
}
html:not([dark="true"]) .users-section .user .username {
color:#1F1F23;
}
html:not([dark="true"]) .users-section .user .description {
color: #53535F;
}
html:not([dark="true"]) .users-section .user .watchers {
color: black;
}
html:not([dark="true"]) .tooltip-container {
background-color: #E6E6EA;
}
html:not([dark="true"]) .tooltiptext {
color: black;
background-color: #E6E6EA;
}
`;
const mainPageCommonStyles = `
._moreDot_layer button {
text-align: left;
}
.customSidebar .btn_flexible {
display: none;
}
#sidebar {
z-index: 1401;
}
body.customSidebar main {
padding-left: 238px !important;
}
body.customSidebar .catch_webplayer_wrap {
margin-left: 24px !important;
}
`;
const playerCommonStyles = `
.screen_mode .left_navbar,
.fullScreen_mode .left_navbar {
display: none;
}
.customSidebar .btn_flexible {
display: none;
}
/* 스크롤바 스타일링 */
html {
overflow: auto; /* 스크롤 기능 유지 */
}
/* Firefox 전용 스크롤바 감추기 */
html::-webkit-scrollbar {
display: none; /* 크롬 및 사파리에서 */
}
/* Firefox에서는 아래와 같이 처리 */
html {
scrollbar-width: none; /* Firefox에서 스크롤바 감추기 */
-ms-overflow-style: none; /* Internet Explorer 및 Edge */
}
.customSidebar #player,
.customSidebar #webplayer #webplayer_contents #player_area .float_box,
.customSidebar #webplayer #webplayer_contents #player_area
{
min-width: 180px !important;
}
.customSidebar.screen_mode #webplayer,
.customSidebar.screen_mode #sidebar
{
transition: all 0.25s ease-in-out !important;
}
@media screen and (max-width: 892px) {
.screen_mode.bottomChat #webplayer #player .view_ctrl,
.screen_mode.bottomChat #webplayer .wrapping.side {
display: block !important;
}
}
.customSidebar #webplayer_contents {
width: calc(100vw - ${WEB_PLAYER_SCROLL_LEFT}px) !important;
gap:0 !important;
padding: 0 !important;
margin: 64px 0 0 !important;
left: ${WEB_PLAYER_SCROLL_LEFT}px !important;
}
.customSidebar.top_hide #webplayer_contents,
.customSidebar.top_hide #sidebar {
top: 0 !important;
margin-top: 0 !important;
min-height: 100vh !important;
}
/* sidebar가 .max 클래스를 가질 때, body에 .screen_mode가 없을 경우 */
body:not(.screen_mode):not(.fullScreen_mode):has(#sidebar.max) #webplayer_contents {
width: calc(100vw - 240px) !important;
left: 240px !important;
}
/* sidebar가 .min 클래스를 가질 때, body에 .screen_mode가 없을 경우 */
body:not(.screen_mode):not(.fullScreen_mode):has(#sidebar.min) #webplayer_contents {
width: calc(100vw - 52px) !important;
left: 52px !important;
}
.customSidebar.screen_mode #webplayer #webplayer_contents,
.customSidebar.fullScreen_mode #webplayer #webplayer_contents {
top: 0 !important;
left: 0 !important;
width: 100vw;
height: 100vh !important;
margin: 0 !important;
}
.customSidebar.screen_mode #sidebar{
top: 0 !important;
}
.customSidebar.screen_mode #sidebar .button-fold-sidebar,
.customSidebar.screen_mode #sidebar .button-unfold-sidebar
{
display: none !important;
}
.customSidebar.screen_mode.showSidebar #sidebar{
display: flex !important;
}
.customSidebar.screen_mode #webplayer_contents,
.customSidebar.fullScreen_mode #webplayer_contents{
width: 100vw !important
}
.customSidebar.screen_mode.showSidebar:has(#sidebar.min) #webplayer_contents {
width: calc(100vw - 52px) !important
}
.customSidebar.screen_mode.showSidebar:has(#sidebar.max) #webplayer_contents {
width: calc(100vw - 240px) !important
}
.screen_mode.bottomChat #webplayer #webplayer_contents {
top: 0 !important;
margin: 0 !important;
}
.screen_mode.bottomChat #player {
min-height: auto !important;
}
.screen_mode.bottomChat #webplayer #webplayer_contents {
position: relative;
box-sizing: border-box;
flex: auto;
display: flex;
flex-direction: column !important;
justify-content:flex-start !important;
}
.screen_mode.bottomChat #webplayer #webplayer_contents .wrapping.side {
width: 100% !important;
max-height: calc(100vh - (100vw * 9 / 16)) !important;
}
.screen_mode.bottomChat.showSidebar:has(#sidebar.min) #webplayer #webplayer_contents .wrapping.side {
width: 100% !important;
max-height: calc(100vh - ((100vw - 52px) * 9 / 16)) !important;
}
.screen_mode.bottomChat.showSidebar:has(#sidebar.max) #webplayer #webplayer_contents .wrapping.side {
width: 100% !important;
max-height: calc(100vh - ((100vw - 240px) * 9 / 16)) !important;
}
.screen_mode.bottomChat #webplayer #webplayer_contents .wrapping.side section.box.chatting_box {
height: 100% !important;
}
.screen_mode.bottomChat #webplayer #webplayer_contents .wrapping.side section.box.chatting_box #chatting_area {
height: 100% !important;
min-height: 10vh !important;
}
.screen_mode.bottomChat #webplayer #webplayer_contents #player_area .htmlplayer_wrap,
.screen_mode.bottomChat #webplayer #webplayer_contents #player_area .htmlplayer_content,
.screen_mode.bottomChat #webplayer #webplayer_contents #player_area .float_box,
.screen_mode.bottomChat #webplayer #webplayer_contents #player_area #player {
height: auto !important;
max-height: max-content;
}
.customSidebar #player {
max-height: 100vh !important;
}
`;
//======================================
// 3. 함수 정의 (Function Definitions)
//======================================
// 3.1. API 및 데이터 호출 함수 (API & Data Fetching)
const getHiddenbjList = async () => {
const url = "https://live.sooplive.co.kr/api/hiddenbj/hiddenbjController.php";
const response = await fetchBroadList(url, 3000);
if (response?.RESULT === 1) {
return response.DATA || [];
} else {
return [];
}
};
const getStationFeed = async () => {
// 채널 피드가 비활성화된 경우 빈 배열을 반환합니다.
if (!isChannelFeedEnabled) {
return [];
}
const feedUrl = "https://myapi.sooplive.co.kr/api/feed?index_reg_date=0&user_id=&is_bj_write=1&feed_type=&page=1";
const response = await fetchBroadList(feedUrl, 5000);
return response?.data || [];
};
const loadCategoryData = () => {
// 현재 시간 기록
const currentTime = new Date().getTime();
// 이전 실행 시간 불러오기
const lastExecutionTime = GM_getValue("lastExecutionTime", 0);
// 마지막 실행 시간으로부터 15분 이상 경과했는지 확인
if (currentTime - lastExecutionTime >= 900000) {
// URL에 현재 시간을 쿼리 스트링으로 추가해서 캐시 방지
const url = "https://live.sooplive.co.kr/script/locale/ko_KR/broad_category.js?" + currentTime;
GM_xmlhttpRequest({
method: "GET",
url: url,
headers: {
"Content-Type": "text/plain; charset=utf-8"
},
onload: function(response) {
if (response.status === 200) {
// 성공적으로 데이터를 받았을 때 처리할 코드 작성
let szBroadCategory = response.responseText;
console.log(szBroadCategory);
// 이후 처리할 작업 추가
szBroadCategory = JSON.parse(szBroadCategory.split('var szBroadCategory = ')[1].slice(0, -1));
if (szBroadCategory.CHANNEL.RESULT === "1") {
// 데이터 저장
GM_setValue("szBroadCategory", szBroadCategory);
// 현재 시간을 마지막 실행 시간으로 업데이트
GM_setValue("lastExecutionTime", currentTime);
}
} else {
console.error("Failed to load data:", response.statusText);
}
},
onerror: function(error) {
console.error("Error occurred while loading data:", error);
}
});
}
};
const fetchBroadList = async (url, timeout) => {
const CACHE_EXPIRY_MS = 45 * 1000; // 45초
const cacheKey = `fetchCache_${encodeURIComponent(url)}`;
// 1. LocalStorage 확인
const cached = localStorage.getItem(cacheKey);
if (cached) {
try {
const { timestamp, data } = JSON.parse(cached);
if (Date.now() - timestamp < CACHE_EXPIRY_MS) {
return data;
}
} catch (e) {
console.warn(url, 'Cache parse error in LocalStorage, ignoring.');
}
}
// 2. GM 저장소 확인
const gmCached = await GM_getValue(cacheKey, null);
if (gmCached) {
try {
const { timestamp, data } = JSON.parse(gmCached);
if (Date.now() - timestamp < CACHE_EXPIRY_MS) {
// LocalStorage에도 저장해두기 (빠른 재사용을 위해)
localStorage.setItem(cacheKey, gmCached);
return data;
}
} catch (e) {
console.warn(url, 'Cache parse error in GM storage, ignoring.');
}
}
// 3. 요청 수행
return new Promise((resolve) => {
let timeoutId;
if (timeout) {
timeoutId = setTimeout(() => {
console.error(url, `Request timed out after ${timeout} ms`);
resolve([]);
}, timeout);
}
GM_xmlhttpRequest({
method: 'GET',
url: url,
headers: {
'Content-Type': 'application/json'
},
onload: async (response) => {
if (timeoutId) clearTimeout(timeoutId);
try {
if (response.status >= 200 && response.status < 300) {
const jsonResponse = JSON.parse(response.responseText);
// 에러 응답 처리
if (jsonResponse?.RESULT === -1 || (jsonResponse?.code && jsonResponse.code < 0)) {
console.error(url, `API Error (Login Required or other): ${jsonResponse.MSG || jsonResponse.message}`);
localStorage.removeItem(cacheKey);
await GM_setValue(cacheKey, ""); // GM 저장소도 삭제 (빈 문자열)
resolve([]);
} else {
const cacheData = JSON.stringify({
timestamp: Date.now(),
data: jsonResponse
});
// LocalStorage + GM 저장소에 저장
localStorage.setItem(cacheKey, cacheData);
await GM_setValue(cacheKey, cacheData);
resolve(jsonResponse);
}
} else if (response.status === 401) {
console.error(url, "Unauthorized: 401 error - possibly invalid credentials");
resolve([]);
} else {
console.error(url, `Error: ${response.status}`);
resolve([]);
}
} catch (error) {
console.error(url, "Parsing error: ", error);
resolve([]);
}
},
onerror: (error) => {
if (timeoutId) clearTimeout(timeoutId);
console.error(url, "Request error: " + error.message);
resolve([]);
}
});
});
};
const getBroadAid2 = async (id, broadNumber, quality = 'original') => {
const basePayload = {
bid: id,
bno: broadNumber,
from_api: '0',
mode: 'landing',
player_type: 'html5',
stream_type: 'common',
quality: quality
};
// AID 요청 함수
const requestAid = async (password = '') => {
const payload = {
...basePayload,
type: 'aid',
pwd: password
};
const options = {
method: 'POST',
body: new URLSearchParams(payload),
credentials: 'include',
cache: 'no-store'
};
const res = await fetch('https://live.sooplive.co.kr/afreeca/player_live_api.php', options);
return await res.json();
};
// LIVE 요청 함수
const requestLive = async () => {
const payload = {
...basePayload,
type: 'live',
pwd: ''
};
const options = {
method: 'POST',
body: new URLSearchParams(payload),
credentials: 'include',
cache: 'no-store'
};
const res = await fetch('https://live.sooplive.co.kr/afreeca/player_live_api.php', options);
return await res.json();
};
try {
// 1차: 비밀번호 없이 AID 요청
const result1 = await requestAid('');
if (result1?.CHANNEL?.AID) {
console.log(result1.CHANNEL.AID);
return result1.CHANNEL.AID;
}
// 2차: LIVE 요청으로 BPWD 확인
const result2 = await requestLive();
if (result2?.CHANNEL?.BPWD === 'Y') {
const password = prompt('비밀번호를 입력하세요:');
if (password === null) return null;
// 3차: 입력된 비밀번호로 다시 AID 요청
const retryResult = await requestAid(password);
if (retryResult?.CHANNEL?.AID) {
console.log(result1.CHANNEL.AID);
return retryResult.CHANNEL.AID;
} else {
alert('비밀번호가 틀렸거나 종료된 방송입니다.');
}
}
return null;
} catch (error) {
console.log('오류 발생:', error);
return null;
}
};
unsafeWindow.getBroadAid2 = getBroadAid2;
const getLatestFrameData = async (id, broadNumber) => {
const videoElement = document.createElement('video');
videoElement.playbackRate = 16; // 빠른 재생 속도 설정
const aid = await getBroadAid2(id, broadNumber, 'sd');
const m3u8url = `https://live-global-cdn-v02.sooplive.co.kr/live-stm-12/auth_playlist.m3u8?aid=${aid}`
if (unsafeWindow.Hls.isSupported()) {
const hls = new unsafeWindow.Hls();
hls.loadSource(m3u8url);
hls.attachMedia(videoElement);
return new Promise((resolve) => {
videoElement.addEventListener('canplay', async () => {
const frameData = await captureLatestFrame(videoElement);
resolve(frameData);
videoElement.pause();
videoElement.src = '';
});
});
} else {
console.error('HLS.js를 지원하지 않는 브라우저입니다.');
return null;
}
};
// 3.2. 핵심 유틸리티 함수 (Core Utility Functions)
const loadHlsScript = () => {
// hls.js 동적 로드
const hlsScript = document.createElement('script');
hlsScript.src = 'https://cdn.jsdelivr.net/npm/hls.js@latest';
hlsScript.onload = function() {
console.log('hls.js가 성공적으로 로드되었습니다.');
};
hlsScript.onerror = function() {
console.error('hls.js 로드 중 오류가 발생했습니다.');
};
document.head.appendChild(hlsScript);
};
const applyFontStyles = () => {
const style = document.createElement('style');
style.textContent = `
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
* {
font-family: 'Inter' !important;
}
`;
document.head.appendChild(style);
};
const checkIfTimeover = (timestamp) => {
const now = Date.now();
const inputTime = timestamp * 1000; // 초 단위 타임스탬프를 밀리초로 변환
// 24시간(1일) = 86400000 밀리초
return (now - inputTime) > 86400000;
};
const timeSince = (serverTimeStr) => {
// 입력 문자열 → ISO 8601 + KST 오프셋으로 변환
const toKSTDate = (str) => {
const iso = str.replace(' ', 'T') + '+09:00';
return new Date(iso);
};
const postTime = toKSTDate(serverTimeStr).getTime(); // 게시물 작성 시각 (KST)
const now = Date.now(); // 현재 시각 (밀리초 기준, UTC)
const seconds = Math.floor((now - postTime) / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 365) return `${Math.floor(days / 365)}년 전`;
if (days > 30) return `${Math.floor(days / 30)}개월 전`;
if (days > 0) return `${days}일 전`;
if (hours > 0) return `${hours}시간 전`;
if (minutes > 0) return `${minutes}분 전`;
return `${seconds}초 전`;
};
const waitForElement = (selector, callback, timeout = 10000) => {
let observer = null;
const timeoutId = setTimeout(() => {
if (observer) {
observer.disconnect();
console.warn(`[waitForElement] Timeout: '${selector}' 요소를 ${timeout}ms 내에 찾지 못했습니다.`);
}
}, timeout);
const element = document.querySelector(selector);
if (element) {
clearTimeout(timeoutId);
callback(selector, element);
return;
}
observer = new MutationObserver((mutations, obs) => {
const targetElement = document.querySelector(selector);
if (targetElement) {
obs.disconnect();
clearTimeout(timeoutId);
callback(selector, targetElement);
}
});
observer.observe(document.body, { childList: true, subtree: true });
};
const waitForElementAsync = (selector, timeout = 10000) => {
return new Promise((resolve, reject) => {
// 1. 요소가 이미 존재하는지 즉시 확인
const element = document.querySelector(selector);
if (element) {
resolve(element);
return;
}
let observer = null;
// 2. 타임아웃 설정: 지정된 시간이 지나면 reject 실행
const timeoutId = setTimeout(() => {
if (observer) {
observer.disconnect();
// new Error 객체를 사용해 더 명확한 에러 스택 추적 가능
reject(new Error(`Timeout: '${selector}' 요소를 ${timeout}ms 내에 찾지 못했습니다.`));
}
}, timeout);
// 3. MutationObserver 설정: DOM 변경 감지
observer = new MutationObserver((mutations) => {
// 변경이 감지되면 요소를 다시 찾아봄
const targetElement = document.querySelector(selector);
if (targetElement) {
observer.disconnect(); // 관찰 중단
clearTimeout(timeoutId); // 타임아웃 타이머 제거
resolve(targetElement); // Promise 성공 처리
}
});
// 4. 관찰 시작
observer.observe(document.body, {
childList: true,
subtree: true
});
});
};
const waitForLivePlayer = (timeout = 10000) => {
return new Promise((resolve, reject) => {
const interval = 1500;
let elapsed = 0;
const check = () => {
if (unsafeWindow.livePlayer) {
resolve(unsafeWindow.livePlayer);
} else {
elapsed += interval;
if (elapsed >= timeout) {
reject(new Error('livePlayer 객체를 찾지 못했습니다.'));
} else {
setTimeout(check, interval);
}
}
};
check();
});
};
const waitForNonEmptyArray = async () => {
const timeout = new Promise((resolve) =>
setTimeout(() => resolve([]), 3000) // 3초 후 빈 배열 반환
);
const checkArray = (async () => {
while (allFollowUserIds.length === 0) {
await new Promise((resolve) => setTimeout(resolve, 100)); // 100ms 대기
}
return allFollowUserIds;
})();
return Promise.race([timeout, checkArray]);
};
const updateElementWithContent = (targetElement, newContent) => {
// DocumentFragment 생성
const createFragment = (content) => {
const fragment = document.createDocumentFragment();
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content;
// tempDiv의 자식 요소를 fragment에 추가
while (tempDiv.firstChild) {
fragment.appendChild(tempDiv.firstChild);
}
return fragment;
};
// 기존 내용을 지우고 DocumentFragment를 적용
const applyFragment = (fragment) => {
targetElement.innerHTML = ''; // 기존 내용을 모두 지움
targetElement.appendChild(fragment); // 새로운 내용 추가
};
// DocumentFragment 생성 후 적용
applyFragment(createFragment(newContent));
};
const manageRedDot = (targetDiv) => {
const RED_DOT_CLASS = 'red-dot';
const style = document.createElement('style');
style.textContent = `
.${RED_DOT_CLASS} {
position: absolute;
top: 8px;
right: 8px;
width: 4px;
height: 4px;
background-color: red;
border-radius: 50%;
}
`;
document.head.appendChild(style);
const lastUpdateDate = GM_getValue('lastUpdateDate', 0);
const btn = targetDiv;
// 빨간 점 추가 함수
const showRedDot = () => {
if (!btn || document.querySelector(`#openModalBtn .${RED_DOT_CLASS}`)) return;
const redDot = document.createElement('div');
redDot.classList.add(RED_DOT_CLASS);
btn.parentElement.appendChild(redDot);
};
// 빨간 점 제거 함수
const hideRedDot = () => {
const redDot = document.querySelector(`#openModalBtn .${RED_DOT_CLASS}`);
if (redDot) redDot.remove();
};
// 날짜를 비교하여 빨간 점 표시
if (NEW_UPDATE_DATE > lastUpdateDate) {
showRedDot();
} else {
hideRedDot();
}
// 버튼 클릭 시 이벤트 핸들러 추가
btn?.addEventListener('click', () => {
GM_setValue('lastUpdateDate', NEW_UPDATE_DATE);
hideRedDot();
});
};
const addNumberSeparator = (number) => {
number = Number(number);
// 숫자가 10,000 이상일 때
if (number >= 10000) {
const displayNumber = (number / 10000).toFixed(1);
return displayNumber.endsWith('.0') ?
displayNumber.slice(0, -2) + '만' : displayNumber + '만';
}
return number.toLocaleString();
};
const addNumberSeparatorAll = (number) => {
number = Number(number);
// 숫자가 10,000 이상일 때
if (number >= 10000) {
const displayNumber = (number / 10000).toFixed(1);
return displayNumber.endsWith('.0') ?
displayNumber.slice(0, -2) + '만' : displayNumber + '만';
}
// 숫자가 1,000 이상일 때
else if (number >= 1000) {
const displayNumber = (number / 1000).toFixed(1);
return displayNumber.endsWith('.0') ?
displayNumber.slice(0, -2) + '천' : displayNumber + '천';
}
// 기본적으로 쉼표 추가
return number.toLocaleString();
};
const getCategoryName = (targetCateNo) => {
const searchCategory = (categories) => {
for (const category of categories) {
if (category.cate_no === targetCateNo) {
return category.cate_name;
}
if (category.child?.length) {
const result = searchCategory(category.child);
if (result) return result;
}
}
return targetCateNo === "ADULT_BROAD_CATE" ? "연령제한" : null;
};
return searchCategory(savedCategory.CHANNEL.BROAD_CATEGORY);
};
const getCategoryNo = (targetCateName) => {
const searchCategory = (categories) => {
for (const category of categories) {
if (category.cate_name === targetCateName) {
return category.cate_no;
}
if (category.child?.length) {
const result = searchCategory(category.child);
if (result) return result;
}
}
return targetCateName === "연령제한" ? "ADULT_BROAD_CATE" : null;
};
return searchCategory(savedCategory.CHANNEL.BROAD_CATEGORY);
};
const desc_order = (selector) => {
const container = document.body.querySelector(selector);
const userElements = container.children;
const categories = [[], [], [], [], [], []];
for (let i = 0; i < userElements.length; i++) {
const user = userElements[i];
const isPin = user.getAttribute('is_pin') === 'Y';
const hasBroadThumbnail = user.hasAttribute('broad_thumbnail');
const isMobilePush = user.getAttribute('is_mobile_push') === 'Y';
const isOffline = user.hasAttribute('is_offline');
const broad_cate_no = user.getAttribute('broad_cate_no');
const isBlocked = blockedCategories.some(b => b.categoryId === broad_cate_no);
if (isPin && hasBroadThumbnail) {
categories[0].push(user); // 1. 고정 + 생방
} else if (isPin) {
categories[1].push(user); // 2. 고정 + 오프라인
} else if (isMobilePush && !isOffline) {
categories[2].push(user); // 3. 알림 켜짐 + 생방
} else if (isBlocked && isBlockedCategorySortingEnabled) {
categories[4].push(user); // 5. 차단된 카테고리 (고정 제외, 알림 켜짐 제외)
} else if (!isMobilePush && !isOffline) {
categories[3].push(user); // 4. 알림 꺼짐 + 생방
} else {
categories[5].push(user); // 6. 그 외
}
}
categories.forEach((category, index) => {
if (index === 5 || selector !== '.users-section.follow') { // 방송국 글이거나 즐찾 채널이 아닌 경우 시청자 많은 순
category.sort(compareWatchers);
} else {
category.sort(isRandomSortEnabled ? stableRandomOrder : compareWatchers)
}
});
container.innerHTML = '';
const fragment = document.createDocumentFragment();
categories.forEach(category => {
category.forEach(user => fragment.appendChild(user));
});
container.appendChild(fragment);
};
const compareWatchers = (a, b) => {
// Get watchers data only once for each element
const watchersA = a.dataset.watchers ? +a.dataset.watchers : 0; // Use dataset for better performance
const watchersB = b.dataset.watchers ? +b.dataset.watchers : 0; // Use dataset for better performance
return watchersB - watchersA; // Sort by watchers
};
const stableRandomOrder = (() => {
// 한 번에 여러 개를 정렬할 때 일관된 랜덤성을 유지하려면, 미리 섞어주는 방식이 좋습니다.
// 이 함수는 내부적으로 shuffle된 index 맵을 사용해서 안정적인 무작위 정렬을 구현합니다.
let randomMap = new WeakMap();
return (a, b) => {
if (!randomMap.has(a)) randomMap.set(a, Math.random());
if (!randomMap.has(b)) randomMap.set(b, Math.random());
return randomMap.get(a) - randomMap.get(b);
};
})();
const debounce = (func, wait) => {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
const isElementVisible = (selector) => {
const el = document.querySelector(selector);
if (!el) return false; // 요소가 없음
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false; // CSS로 숨겨진 경우
}
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
return false; // 크기가 0인 경우
}
// 화면 안에 일부라도 보이는 경우
return (
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < (window.innerHeight || document.documentElement.clientHeight) &&
rect.left < (window.innerWidth || document.documentElement.clientWidth)
);
};
const updateBodyClass = (targetClass) => {
if (!window.matchMedia("(orientation: portrait)").matches) {
document.body.classList.remove(targetClass);
document.querySelector('.expand-toggle-li').style.display = 'none';
} else {
document.querySelector('.expand-toggle-li').style.display = 'block';
}
};
const extractDateTime = (text) => {
const [dateStr, timeStr] = text.split(' '); // split 한 번으로 날짜와 시간을 동시에 얻기
const dateTimeStr = `${dateStr}T${timeStr}Z`; // 문자열 템플릿 사용
return new Date(dateTimeStr);
};
const getElapsedTime = (broadcastStartTimeText, type) => {
const broadcastStartTime = extractDateTime(broadcastStartTimeText);
broadcastStartTime.setHours(broadcastStartTime.getHours() - 9);
const currentTime = new Date();
const timeDiff = currentTime - broadcastStartTime;
const secondsElapsed = Math.floor(timeDiff / 1000);
const hoursElapsed = Math.floor(secondsElapsed / 3600);
const minutesElapsed = Math.floor((secondsElapsed % 3600) / 60);
const remainingSeconds = secondsElapsed % 60;
let formattedTime = '';
if (type === "HH:MM:SS") {
formattedTime = `${String(hoursElapsed).padStart(2, '0')}:${String(minutesElapsed).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`;
} else if (type === "HH:MM") {
if (hoursElapsed > 0) {
formattedTime = `${String(hoursElapsed)}시간 `;
}
formattedTime += `${String(minutesElapsed)}분`;
}
return formattedTime;
};
const isUserTyping = () => {
const active = document.activeElement;
const tag = active?.tagName?.toUpperCase();
return (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
active?.isContentEditable ||
active?.id === 'write_area'
);
};
const observeElementChanges = (targetSelector, callback, options = {}) => {
/**
* 지정된 요소의 DOM 변경을 감지하고, 변경 시 콜백 함수를 실행하는 범용 유틸리티 함수입니다.
*
* @param {string} targetSelector - 감시할 요소의 CSS 선택자입니다.
* @param {function(MutationRecord[], MutationObserver): void} callback - DOM 변경이 감지되었을 때 실행할 콜백 함수입니다.
* @param {Object} [options] - 관찰에 대한 설정 객체입니다. (선택 사항)
* @param {boolean} [options.once=false] - true로 설정하면 콜백을 한 번만 실행하고 관찰을 자동 중단합니다.
* @param {MutationObserverInit} [options] - MutationObserver의 표준 설정도 포함합니다. (childList, subtree, attributes 등)
* @returns {MutationObserver|null} 생성된 MutationObserver 인스턴스를 반환합니다.
*/
// 1. 감시할 대상 요소 선택
const targetElement = document.querySelector(targetSelector);
if (!targetElement) {
console.error(`[observeElementChanges] 오류: 선택자 '${targetSelector}'에 해당하는 요소를 찾을 수 없습니다.`);
return null;
}
// 2. 콜백 함수 유효성 검사
if (typeof callback !== 'function') {
console.error(`[observeElementChanges] 오류: 두 번째 인자로 전달된 콜백이 함수가 아닙니다.`);
return null;
}
// 3. 옵션 분리 및 설정
// options 객체에서 'once' 속성을 분리하고, 나머지는 observer 설정으로 사용합니다.
const { once = false, ...observerOptions } = options;
const defaultConfig = {
childList: true, // 기본값: 자식 요소 변경 감지
subtree: true // 기본값: 하위 트리까지 감지
};
// 기본 설정, 사용자 지정 observer 설정을 병합
const config = { ...defaultConfig, ...observerOptions };
// 4. MutationObserver 인스턴스 생성 및 콜백 연결
const observer = new MutationObserver((mutationsList, observer) => {
// 사용자 콜백 실행
callback(mutationsList, observer);
// 5. 'once' 옵션이 true이면, 콜백 실행 후 즉시 관찰 중단
if (once) {
observer.disconnect();
console.log(`[observeElementChanges] '${targetSelector}' 요소에 대한 관찰이 1회 실행 후 중단되었습니다.`);
}
});
// 6. 관찰 시작
observer.observe(targetElement, config);
console.log(`[observeElementChanges] '${targetSelector}' 요소에 대한 관찰을 시작합니다. (once: ${once})`);
// 7. 생성된 observer 인스턴스 반환
return observer;
};
const observeUrlChanges = (callback) => {
let lastUrl = window.location.pathname;
// popstate 이벤트 리스너
const handlePopState = () => {
const currentUrl = window.location.pathname;
if (currentUrl !== lastUrl) {
lastUrl = currentUrl;
callback(currentUrl);
}
};
window.addEventListener('popstate', handlePopState);
// history.pushState와 history.replaceState를 감싸서 커스텀 로직 추가
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function(...args) {
originalPushState.apply(this, args);
const newUrl = args[2] ? args[2].toString() : window.location.pathname;
if (newUrl !== lastUrl) {
lastUrl = newUrl;
callback(newUrl);
}
};
history.replaceState = function(...args) {
originalReplaceState.apply(this, args);
const newUrl = args[2] ? args[2].toString() : window.location.pathname;
if (newUrl !== lastUrl) {
lastUrl = newUrl;
callback(newUrl);
}
};
// 관찰을 중단하는 함수 반환
return function disconnect() {
window.removeEventListener('popstate', handlePopState);
history.pushState = originalPushState;
history.replaceState = originalReplaceState;
console.log("URL 변경 관찰이 중단되었습니다.");
};
};
const waitForConditionAsync = (conditionFn, timeout = 10000) => {
/**
* 주어진 조건 함수(conditionFn)가 true를 반환할 때까지 기다리는 Promise를 반환합니다.
* @param {() => boolean} conditionFn - true 또는 false를 반환하는 조건 함수.
* @param {number} [timeout=10000] - 기다릴 최대 시간 (밀리초).
* @returns {Promise<void>} 조건이 충족되면 resolve되는 Promise.
*/
return new Promise((resolve, reject) => {
// 1. 즉시 조건 확인
if (conditionFn()) {
resolve();
return;
}
let observer = null;
// 2. 타임아웃 설정
const timeoutId = setTimeout(() => {
if (observer) {
observer.disconnect();
reject(new Error("Timeout: 조건이 지정된 시간 내에 충족되지 않았습니다."));
}
}, timeout);
// 3. MutationObserver로 body의 모든 변화를 감지
observer = new MutationObserver(() => {
if (conditionFn()) {
observer.disconnect();
clearTimeout(timeoutId);
resolve();
}
});
observer.observe(document.body, { childList: true, subtree: true, attributes: true });
});
}
const observeClassChanges = (targetSelector, callback) => {
/**
* 지정된 요소의 'class' 속성 변경만을 감지하고, 변경 시 콜백 함수를 실행하는 유틸리티 함수입니다.
* 이 함수는 MutationObserver를 사용하여 불필요한 DOM 변경 감지를 최소화합니다.
*
* @param {string} targetSelector - 감시할 요소의 CSS 선택자입니다.
* @param {function(MutationRecord[], MutationObserver): void} callback - 'class' 속성 변경이 감지되었을 때 실행할 콜백 함수입니다.
* @returns {MutationObserver|null} 생성된 MutationObserver 인스턴스를 반환합니다.
*/
// 1. 감시할 대상 요소 선택
const targetElement = document.querySelector(targetSelector);
if (!targetElement) {
// console.error(`[observeClassChanges] 오류: 선택자 '${targetSelector}'에 해당하는 요소를 찾을 수 없습니다.`);
return null;
}
// 2. 콜백 함수 유효성 검사
if (typeof callback !== 'function') {
// console.error(`[observeClassChanges] 오류: 두 번째 인자로 전달된 콜백이 함수가 아닙니다.`);
return null;
}
// 3. MutationObserver 설정 (class 변화에만 집중)
const config = {
attributes: true, // 속성 변경 감지 활성화
attributeFilter: ['class'], // 'class' 속성만 필터링하여 감지
childList: false, // 자식 요소 변경 감지 비활성화 (기본값 재정의)
subtree: false // 하위 트리 변경 감지 비활성화 (기본값 재정의)
};
// 4. MutationObserver 인스턴스 생성 및 콜백 연결
const observer = new MutationObserver((mutationsList, observerInstance) => {
// 'class' 속성 변경에 대한 모든 변경 레코드를 순회하며 콜백 실행
// 실제 콜백 함수는 모든 mutationList를 받을 수 있지만,
// 이 옵션으로 인해 class attribute 변경만 여기에 전달됩니다.
callback(mutationsList, observerInstance);
});
// 5. 관찰 시작
observer.observe(targetElement, config);
// console.log(`[observeClassChanges] '${targetSelector}' 요소의 클래스 변경 감시를 시작합니다.`);
// 6. 생성된 observer 인스턴스 반환 (필요시 중단 등을 위해)
return observer;
};
const loadScript = (url) => {
return new Promise((resolve, reject) => {
// 동일한 스크립트가 이미 로드되었는지 확인
if (document.querySelector(`script[src="${url}"]`)) {
console.log(`스크립트가 이미 로드됨: ${url}`);
resolve();
return;
}
const script = document.createElement('script');
script.src = url;
script.onload = () => {
console.log(`스크립트 로드 성공: ${url}`);
resolve();
};
script.onerror = () => {
console.error(`스크립트 로드 실패: ${url}`);
reject(new Error(`${url} 로드 실패`));
};
document.head.appendChild(script);
});
}
// 3.3. 차단 기능 관련 함수 (Blocking Features)
function saveBlockedUsers() {
GM_setValue('blockedUsers', blockedUsers);
};
function blockUser(userName, userId) {
// 이미 차단된 사용자인지 확인
if (!isUserBlocked(userId)) {
blockedUsers.push({ userName, userId });
saveBlockedUsers();
alert(`사용자 ${userName}(${userId})를 차단했습니다.\n차단 해제 메뉴는 템퍼몽키 아이콘을 누르면 있습니다.`);
registerUnblockMenu({ userName, userId });
} else {
alert(`사용자 ${userName}(${userId})는 이미 차단되어 있습니다.`);
}
};
function unblockUser(userId) {
// 차단된 사용자 목록에서 해당 사용자 찾기
let unblockedUser = blockedUsers.find(user => user.userId === userId);
// 사용자를 찾았을 때만 차단 해제 및 메뉴 삭제 수행
if (unblockedUser) {
// 차단된 사용자 목록에서 해당 사용자 제거
blockedUsers = blockedUsers.filter(user => user.userId !== userId);
// 변경된 목록을 저장
GM_setValue('blockedUsers', blockedUsers);
alert(`사용자 ${userId}의 차단이 해제되었습니다.`);
unregisterUnblockMenu(unblockedUser.userName);
}
};
function isUserBlocked(userId) {
return blockedUsers.some(user => user.userId === userId);
};
function registerUnblockMenu(user) {
// GM_registerMenuCommand로 메뉴를 등록하고 메뉴 ID를 기록
let menuId = GM_registerMenuCommand(`💔 차단 해제 - ${user.userName}`, function() {
unblockUser(user.userId);
});
// 메뉴 ID를 기록
menuIds[user.userName] = menuId;
};
function unregisterUnblockMenu(userName) {
// userName을 기반으로 저장된 메뉴 ID를 가져와서 삭제
let menuId = menuIds[userName];
if (menuId) {
GM_unregisterMenuCommand(menuId);
delete menuIds[userName]; // 삭제된 메뉴 ID를 객체에서도 제거
}
};
function saveBlockedCategories() {
GM_setValue('blockedCategories', blockedCategories);
};
function blockCategory(categoryName, categoryId) {
// 이미 차단된 카테고리인지 확인
if (!isCategoryBlocked(categoryId)) {
blockedCategories.push({ categoryName, categoryId });
saveBlockedCategories();
alert(`카테고리 ${categoryName}(${categoryId})를 차단했습니다.\n차단 해제 메뉴는 템퍼몽키 아이콘을 누르면 있습니다.`);
registerCategoryUnblockMenu({ categoryName, categoryId });
} else {
alert(`카테고리 ${categoryName}(${categoryId})는 이미 차단되어 있습니다.`);
}
};
function unblockCategory(categoryId) {
// 차단된 카테고리 목록에서 해당 카테고리 찾기
let unblockedCategory = blockedCategories.find(category => category.categoryId === categoryId);
// 카테고리를 찾았을 때만 차단 해제 및 메뉴 삭제 수행
if (unblockedCategory) {
// 차단된 카테고리 목록에서 해당 카테고리 제거
blockedCategories = blockedCategories.filter(category => category.categoryId !== categoryId);
// 변경된 목록을 저장
GM_setValue('blockedCategories', blockedCategories);
alert(`카테고리 ${categoryId}의 차단이 해제되었습니다.`);
unregisterCategoryUnblockMenu(unblockedCategory.categoryName);
}
};
function isCategoryBlocked(categoryId) {
return blockedCategories.some(category => category.categoryId === categoryId);
};
function registerCategoryUnblockMenu(category) {
// GM_registerMenuCommand로 카테고리 메뉴를 등록하고 메뉴 ID를 기록
let menuId = GM_registerMenuCommand(`💔 카테고리 차단 해제 - ${category.categoryName}`, function() {
unblockCategory(category.categoryId);
});
// 메뉴 ID를 기록
categoryMenuIds[category.categoryName] = menuId;
};
function unregisterCategoryUnblockMenu(categoryName) {
// categoryName을 기반으로 저장된 메뉴 ID를 가져와서 삭제
let menuId = categoryMenuIds[categoryName];
if (menuId) {
GM_unregisterMenuCommand(menuId);
delete categoryMenuIds[categoryName]; // 삭제된 메뉴 ID를 객체에서도 제거
}
};
function saveBlockedWords() {
GM_setValue('blockedWords', blockedWords);
};
function blockWord(word) {
// 단어의 양쪽 공백 제거
word = word.trim();
// 단어가 두 글자 이상인지 확인
if (word.length < 2) {
alert("단어는 두 글자 이상이어야 합니다.");
return;
}
// 이미 차단된 단어인지 확인
if (!isWordBlocked(word)) {
blockedWords.push(word);
saveBlockedWords();
alert(`단어 "${word}"를 차단했습니다.`);
registerWordUnblockMenu(word);
} else {
alert(`단어 "${word}"는 이미 차단되어 있습니다.`);
}
};
function unblockWord(word) {
// 차단된 단어 목록에서 해당 단어 찾기
let unblockedWord = blockedWords.find(blockedWord => blockedWord === word);
// 단어를 찾았을 때만 차단 해제 및 메뉴 삭제 수행
if (unblockedWord) {
// 차단된 단어 목록에서 해당 단어 제거
blockedWords = blockedWords.filter(blockedWord => blockedWord !== word);
// 변경된 목록을 저장
saveBlockedWords();
alert(`단어 "${word}"의 차단이 해제되었습니다.`);
unregisterWordUnblockMenu(word);
}
};
function isWordBlocked(word) {
const lowerCaseWord = word.toLowerCase();
return blockedWords.map(word => word.toLowerCase()).includes(lowerCaseWord);
};
function registerWordUnblockMenu(word) {
// GM_registerMenuCommand로 단어 차단 해제 메뉴를 등록하고 메뉴 ID를 기록
let menuId = GM_registerMenuCommand(`💔 단어 차단 해제 - ${word}`, function() {
unblockWord(word);
});
// 메뉴 ID를 기록
wordMenuIds[word] = menuId;
};
function unregisterWordUnblockMenu(word) {
// word를 기반으로 저장된 메뉴 ID를 가져와서 삭제
let menuId = wordMenuIds[word];
if (menuId) {
GM_unregisterMenuCommand(menuId);
delete wordMenuIds[word]; // 삭제된 메뉴 ID를 객체에서도 제거
}
};
function registerMenuBlockingWord() {
// GM 메뉴에 단어 차단 등록 메뉴를 추가합니다.
GM_registerMenuCommand('단어 등록 | 방제에 포함시 차단', function() {
// 사용자에게 차단할 단어 입력을 요청
let word = prompt('차단할 단어 (2자 이상): ');
// 입력한 단어가 있을 때만 처리
if (word) {
blockWord(word);
}
});
};
// 3.4. UI 생성 및 조작 함수 (UI Generation & Manipulation)
const makeTopNavbarAndSidebar = (page) => {
// .left_navbar를 찾거나 생성
let leftNavbar = document.body.querySelector('.left_navbar');
if (!leftNavbar) {
leftNavbar = document.createElement('div');
leftNavbar.className = 'left_navbar';
(async () => {
const serviceHeaderDiv = await waitForElementAsync('#serviceHeader');
serviceHeaderDiv.prepend(leftNavbar);
})()
}
// 버튼을 미리 만들어 DocumentFragment에 추가
const buttonFragment = document.createDocumentFragment();
BUTTON_DATA.reverse().forEach(data => {
const newButton = document.createElement('a');
newButton.innerHTML = `<button type="button" class="left_nav_button">${data.text}</button>`;
const isTargetUrl = CURRENT_URL.startsWith("https://www.sooplive.co.kr");
// 이벤트 리스너 함수 정의
const triggerClick = (event) => {
event.preventDefault();
const targetElement = isTargetUrl && data.onClickTarget ? document.querySelector(data.onClickTarget) : null;
if (targetElement) {
targetElement.click(); // 타겟 요소 클릭
} else {
console.warn("타겟 요소를 찾을 수 없음:", data.onClickTarget);
}
};
// MutationObserver 설정: 타겟 요소가 로드될 때까지 기다림
if (isTargetUrl && data.onClickTarget) {
const observer = new MutationObserver((mutations, observer) => {
const targetElement = document.querySelector(data.onClickTarget);
if (targetElement) {
observer.disconnect(); // 요소가 확인되면 Observer 중지
newButton.addEventListener('click', triggerClick);
}
});
observer.observe(document.body, { childList: true, subtree: true });
} else {
// 기본 링크 설정
newButton.href = data.href;
newButton.target = isOpenNewtabEnabled ? "_blank" : "_self";
}
buttonFragment.appendChild(newButton);
});
leftNavbar.appendChild(buttonFragment); // 한 번에 추가
const tooltipContainer = document.createElement('div');
tooltipContainer.classList.add('tooltip-container');
const sidebarClass = isSidebarMinimized ? "min" : "max";
if (page === "main") {
const newHtml = `
<div id="sidebar" class="max"></div>
`;
const serviceLnbElement = document.getElementById('soop-gnb');
if (serviceLnbElement) {
serviceLnbElement.insertAdjacentHTML('afterend', newHtml);
}
document.body.appendChild(tooltipContainer);
}
if (page === "player") {
const sidebarHtml = `
<div id="sidebar" class="${sidebarClass}"></div>
`;
document.body.insertAdjacentHTML('beforeend', sidebarHtml);
document.body.appendChild(tooltipContainer);
}
};
const createUserElementChzzk = (channel, is_mobile_push) => {
const {
liveTitle: liveTitle,
liveImageUrl: liveImageUrl,
concurrentUserCount: concurrentUserCount,
openDate: openDate,
liveCategoryValue: liveCategoryValue,
liveCategory: liveCategory,
channel: channelInfo,
liveInfo: liveInfo
} = channel;
const userId = channelInfo.channelId;
const playerLink = `https://chzzk.naver.com/live/${channelInfo.channelId}`;
const broadThumbnail = liveImageUrl ? liveImageUrl.split('{type}').join('360')
: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQIHWNgAAAAAgABz8g15QAAAABJRU5ErkJggg==";
const profileImg = channelInfo?.channelImageUrl;
const channelPage = 'https://chzzk.naver.com/'+userId;
const channelName = channelInfo?.channelName;
const emptyimage = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQIHWNgAAAAAgABz8g15QAAAABJRU5ErkJggg==';
const userElement = document.createElement('a');
userElement.classList.add('user');
if (isSmallUserLayoutEnabled) userElement.classList.add('small-user-layout');
userElement.setAttribute('href', playerLink);
if (isOpenNewtabEnabled) {
userElement.setAttribute('target', '_blank');
} else {
userElement.setAttribute('target', '_self');
}
userElement.setAttribute('data-watchers', concurrentUserCount ?? liveInfo.concurrentUserCount);
userElement.setAttribute('broad_thumbnail', broadThumbnail);
userElement.setAttribute('tooltip', liveTitle ?? liveInfo.liveTitle);
userElement.setAttribute('user_id', userId);
userElement.setAttribute('broad_start', openDate ?? 'NotAvailable');
userElement.setAttribute('is_mobile_push', is_mobile_push === "Y" ? 'Y' : 'N');
userElement.setAttribute('is_pin', 'N');
const profilePicture = document.createElement('img');
profilePicture.src = profileImg || emptyimage;
profilePicture.setAttribute('loading', 'lazy');
const profileClickHandler =
`
event.preventDefault();
event.stopPropagation();
if (document.getElementById('sidebar').offsetWidth === 52) {
if(event.ctrlKey) {
window.open('${playerLink}', '_blank');
return;
}
location.href = '${playerLink}';
} else {
window.open('${channelPage}', '_blank');
}
`;
// 프로필 클릭 & 새 탭 열기: 최소화 시 생방송, 최대화 시 방송국
const profileClickHandlerForNewtab = `
event.preventDefault();
event.stopPropagation();
if (document.getElementById('sidebar').offsetWidth === 52) {
window.open('${playerLink}', '_blank');
} else {
window.open('${channelPage}', '_blank');
}
`
profilePicture.setAttribute('onclick', isOpenNewtabEnabled === 1 ?
profileClickHandlerForNewtab :
profileClickHandler
);
profilePicture.setAttribute('onmousedown', `
if (event.button === 1) {
event.preventDefault();
event.stopPropagation();
if (document.getElementById('sidebar').offsetWidth !== 52) {
window.open('${channelPage}', '_blank');
}
}
`);
profilePicture.classList.add('profile-picture');
const username = document.createElement('span');
username.classList.add('username');
username.textContent = (is_mobile_push === "Y") ? `🖈${channelName}` : channelName;
username.setAttribute('title', is_mobile_push === "Y" ? '고정됨(알림 받기 켜짐)' : '');
username.title = username.textContent;
const description = document.createElement('span');
description.classList.add('description');
description.textContent = liveCategoryValue ?? liveInfo.liveCategoryValue;
description.title = description.textContent;
userElement.setAttribute('broad_cate_no', liveCategory ?? '');
const watchers = document.createElement('span');
watchers.classList.add('watchers');
const dot = document.createElement('span');
dot.classList.add('dot', 'greendot');
dot.setAttribute('role', 'img');
dot.textContent = '●';
const userCount = addNumberSeparator(concurrentUserCount ?? liveInfo.concurrentUserCount);
const countText = document.createTextNode(userCount);
watchers.append(dot, countText);
userElement.append(profilePicture, username, description, watchers);
return userElement;
};
const createUserElement = (channel, is_mobile_push, is_pin) => {
const {
user_id: userId,
broad_no: broadNo,
total_view_cnt: totalViewCnt,
broad_title: broadTitle,
user_nick: userNick,
broad_start: broadStart,
broad_cate_no: catNo,
category_name: categoryName,
subscription_only: subscriptionOnly
} = channel;
const isSubOnly = Number(subscriptionOnly || 0) > 0;
const playerLink = `https://play.sooplive.co.kr/${userId}/${broadNo}`;
const broadThumbnail = `https://liveimg.sooplive.co.kr/m/${broadNo}`;
const userElement = document.createElement('a');
userElement.classList.add('user');
if (isSmallUserLayoutEnabled) userElement.classList.add('small-user-layout');
userElement.setAttribute('href', playerLink);
if (isOpenNewtabEnabled) {
userElement.setAttribute('target', '_blank');
} else if (isSendLoadBroadEnabled) {
userElement.setAttribute('onclick', `
if(event.ctrlKey || (window.location.href.indexOf('play.sooplive.co.kr') === -1) ) return;
event.preventDefault();
event.stopPropagation();
if (document.body.querySelector('div.loading') && getComputedStyle(document.body.querySelector('div.loading')).display === 'none') {
liveView.playerController.sendLoadBroad('${userId}', ${broadNo});
} else {
location.href = '${playerLink}';
}
`);
} else {
userElement.setAttribute('target', '_self');
}
userElement.setAttribute('data-watchers', totalViewCnt);
userElement.setAttribute('broad_thumbnail', broadThumbnail);
userElement.setAttribute('tooltip', broadTitle);
userElement.setAttribute('user_id', userId);
userElement.setAttribute('broad_start', broadStart);
if (isOpenExternalPlayerFromSidebarEnabled) {
userElement.setAttribute('oncontextmenu', `
event.preventDefault();
event.stopPropagation();
(async () => {
const aid = await getBroadAid2('${userId}', ${broadNo});
if (aid) openHlsStream('${userNick}', 'https://live-global-cdn-v02.sooplive.co.kr/live-stm-12/auth_playlist.m3u8?aid=' + aid);
})();
`);
}
if (is_mobile_push) {
userElement.setAttribute('is_mobile_push', is_mobile_push);
userElement.setAttribute('is_pin', is_pin ? 'Y' : 'N');
}
const profilePicture = document.createElement('img');
const pp_webp = `https://stimg.sooplive.co.kr/LOGO/${userId.slice(0, 2)}/${userId}/m/${userId}.webp`;
const pp_jpg = `https://profile.img.sooplive.co.kr/LOGO/${userId.slice(0, 2)}/${userId}/m/${userId}.jpg`;
profilePicture.src = pp_webp;
profilePicture.setAttribute('loading', 'lazy');
profilePicture.setAttribute('onerror', `this.onerror=null; this.src='${pp_jpg}'`);
const profileClickHandler = isSendLoadBroadEnabled ?
// 프로필 클릭 & 현재 탭 & 빠른 전환
`
event.preventDefault();
event.stopPropagation();
if (document.getElementById('sidebar').offsetWidth === 52) {
if (event.ctrlKey) {
window.open('${playerLink}', '_blank');
return;
}
if (document.body.querySelector('div.loading') && getComputedStyle(document.body.querySelector('div.loading')).display === 'none') {
liveView.playerController.sendLoadBroad('${userId}', ${broadNo});
} else {
location.href = '${playerLink}';
}
} else {
window.open('https://ch.sooplive.co.kr/${userId}', '_blank');
}
`
:
// 프로필 클릭 & 현재 탭 & 새로고침
`
event.preventDefault();
event.stopPropagation();
if (document.getElementById('sidebar').offsetWidth === 52) {
if(event.ctrlKey) {
window.open('${playerLink}', '_blank');
return;
}
location.href = '${playerLink}';
} else {
window.open('https://ch.sooplive.co.kr/${userId}', '_blank');
}
`;
// 프로필 클릭 & 새 탭 열기: 최소화 시 생방송, 최대화 시 방송국
const profileClickHandlerForNewtab = `
event.preventDefault();
event.stopPropagation();
if (document.getElementById('sidebar').offsetWidth === 52) {
window.open('${playerLink}', '_blank');
} else {
window.open('https://ch.sooplive.co.kr/${userId}', '_blank');
}
`
profilePicture.setAttribute('onclick', isOpenNewtabEnabled === 1 ?
profileClickHandlerForNewtab :
profileClickHandler
);
profilePicture.setAttribute('onmousedown', `
if (event.button === 1) {
event.preventDefault();
event.stopPropagation();
if (document.getElementById('sidebar').offsetWidth !== 52) {
window.open('https://ch.sooplive.co.kr/${userId}', '_blank');
}
}
`);
profilePicture.classList.add('profile-picture');
const username = document.createElement('span');
username.classList.add('username');
username.textContent = (is_pin || is_mobile_push === "Y") ? `🖈${userNick}` : userNick;
username.setAttribute('title', is_pin ? '고정됨(상단 고정 켜짐)' : is_mobile_push === "Y" ? '고정됨(알림 받기 켜짐)' : '');
username.title = username.textContent;
const description = document.createElement('span');
description.classList.add('description');
description.textContent = categoryName || getCategoryName(catNo);
description.title = description.textContent;
userElement.setAttribute('broad_cate_no', catNo);
const watchers = document.createElement('span');
watchers.classList.add('watchers');
// <span class="dot" role="img">●</span>
const dot = document.createElement('span');
dot.classList.add('dot');
dot.setAttribute('role', 'img');
dot.textContent = isSubOnly ? '★' : '●';
dot.title = isSubOnly ? '구독자 전용' : '';
const viewCountText = document.createTextNode(addNumberSeparator(totalViewCnt));
watchers.append(dot, viewCountText);
userElement.append(profilePicture, username, description, watchers);
return userElement;
};
const createUserElement_vod = (channel) => {
const {
user_id: userId,
title_no: broadNo,
view_cnt: totalViewCnt,
title: broadTitle,
user_nick: userNick,
vod_duration: vodDuration,
reg_date: regDate,
thumbnail,
} = channel;
const playerLink = `https://vod.sooplive.co.kr/player/${broadNo}`;
const broadThumbnail = thumbnail.replace("http://", "https://");
const userElement = document.createElement('a');
userElement.classList.add('user');
if (isSmallUserLayoutEnabled) userElement.classList.add('small-user-layout');
userElement.setAttribute('href', playerLink);
if (isOpenNewtabEnabled) {
userElement.setAttribute('target', '_blank');
}
userElement.setAttribute('data-watchers', totalViewCnt);
userElement.setAttribute('broad_thumbnail', broadThumbnail);
userElement.setAttribute('tooltip', broadTitle);
userElement.setAttribute('user_id', userId);
userElement.setAttribute('vod_duration', vodDuration);
const profilePicture = document.createElement('img');
const pp_webp = `https://stimg.sooplive.co.kr/LOGO/${userId.slice(0, 2)}/${userId}/m/${userId}.webp`;
const pp_jpg = `https://profile.img.sooplive.co.kr/LOGO/${userId.slice(0, 2)}/${userId}/m/${userId}.jpg`;
profilePicture.src = pp_webp;
profilePicture.setAttribute('loading', 'lazy');
profilePicture.setAttribute('onerror', `this.onerror=null; this.src='${pp_jpg}'`);
const profileClickHandler = `
event.preventDefault();
event.stopPropagation();
const sidebarWidth = document.getElementById('sidebar').offsetWidth;
if (sidebarWidth === 52) {
if (event.ctrlKey) {
window.open('${playerLink}', '_blank');
return;
}
location.href = '${playerLink}';
} else {
window.open('https://ch.sooplive.co.kr/${userId}', '_blank');
}
`;
profilePicture.setAttribute('onclick', isOpenNewtabEnabled ?
`event.preventDefault(); event.stopPropagation(); window.open('${playerLink}', '_blank');` :
profileClickHandler
);
profilePicture.setAttribute('onmousedown', `
if (event.button === 1) {
if (document.getElementById('sidebar').offsetWidth !== 52) {
event.preventDefault();
event.stopPropagation();
window.open('https://ch.sooplive.co.kr/${userId}', '_blank');
}
}
`);
profilePicture.classList.add('profile-picture', 'profile-grayscale');
const username = document.createElement('span');
username.classList.add('username');
username.textContent = userNick;
username.title = username.textContent;
const description = document.createElement('span');
description.classList.add('description');
description.textContent = vodDuration;
description.title = vodDuration;
const watchers = document.createElement('span');
watchers.classList.add('watchers');
watchers.textContent = timeSince(regDate);
userElement.append(profilePicture, username, description, watchers);
return userElement;
};
const createUserElement_offline = (channel, isFeeditem) => {
const {
user_id: userId,
total_view_cnt: totalViewCnt,
user_nick: userNick,
is_mobile_push: isMobilePush,
is_pin: isPin,
reg_date_human: reg_date_human,
} = channel;
const playerLink = isFeeditem ? isFeeditem.url : `https://ch.sooplive.co.kr/${userId}`;
const isOffline = "Y";
const feedTimestamp = isFeeditem ? isFeeditem.reg_timestamp : false;
const feedRegDate = isFeeditem ? isFeeditem.reg_date : false;
const userElement = document.createElement('a');
userElement.classList.add('user');
userElement.classList.add('user-offline');
if (isSmallUserLayoutEnabled) userElement.classList.add('small-user-layout');
userElement.setAttribute('href', playerLink);
userElement.setAttribute('target', '_blank');
userElement.setAttribute('broad_start', feedRegDate || '');
userElement.setAttribute('data-watchers', isFeeditem ? feedTimestamp : totalViewCnt);
userElement.setAttribute('user_id', userId);
if (isFeeditem) {
if (isFeeditem.photo_cnt) {
userElement.setAttribute('broad_thumbnail', `https:${isFeeditem.photos[0].url}`);
} else {
userElement.setAttribute('data-tooltip-listener', 'false');
}
userElement.setAttribute('tooltip', isFeeditem.title_name);
} else {
userElement.setAttribute('data-tooltip-listener', 'false');
}
if (isMobilePush) {
userElement.setAttribute('is_mobile_push', isMobilePush);
userElement.setAttribute('is_pin', isPin ? 'Y' : 'N');
}
userElement.setAttribute('is_offline', isOffline);
const profilePicture = document.createElement('img');
const pp_webp = `https://stimg.sooplive.co.kr/LOGO/${userId.slice(0, 2)}/${userId}/m/${userId}.webp`;
const pp_jpg = `https://profile.img.sooplive.co.kr/LOGO/${userId.slice(0, 2)}/${userId}/m/${userId}.jpg`;
profilePicture.src = pp_webp;
profilePicture.setAttribute('loading', 'lazy');
profilePicture.setAttribute('onerror', `this.onerror=null; this.src='${pp_jpg}'`);
const profileClickHandler = `
event.preventDefault();
event.stopPropagation();
const sidebarWidth = document.getElementById('sidebar').offsetWidth;
if (sidebarWidth === 52) {
window.open('${playerLink}', '_blank');
} else {
window.open('https://ch.sooplive.co.kr/${userId}', '_blank');
}
`;
profilePicture.setAttribute('onclick', profileClickHandler);
profilePicture.classList.add('profile-picture', 'profile-grayscale');
const username = document.createElement('span');
username.classList.add('username');
username.textContent = isPin ? `🖈${userNick}` : userNick;
username.title = username.textContent;
if (isPin) username.setAttribute('title', '고정됨(상단 고정 켜짐)');
const description = document.createElement('span');
description.classList.add('description');
description.textContent = isFeeditem ? isFeeditem.title_name : '';
description.title = isFeeditem ? isFeeditem.title_name : '';
const watchers = document.createElement('span');
watchers.classList.add('watchers');
if (isFeeditem) {
// 피드 아이템이 있으면 시간 표시
watchers.textContent = isFeeditem.reg_date_human;
} else {
// 오프라인 상태일 경우 ● + "오프라인"
const dot = document.createElement('span');
dot.classList.add('dot', 'profile-grayscale');
dot.setAttribute('role', 'img');
dot.textContent = '●';
const offlineText = document.createTextNode('오프라인');
watchers.append(dot, offlineText);
}
userElement.append(profilePicture, username, description, watchers);
return userElement;
};
const isUserInFollowSection = (userid) => {
const followUsers = document.body.querySelectorAll('.users-section.follow .user');
// 유저가 포함되어 있는지 확인
return Array.from(followUsers).some(user => user.getAttribute('user_id') === userid);
};
const insertFoldButton = () => {
const foldButton = `
<div class="button-fold-sidebar" role="button"></div>
<div class="button-unfold-sidebar" role="button"></div>
`;
const webplayer_scroll = document.getElementById('webplayer_scroll') || document.getElementById('list-container');
const serviceLnbElement = document.getElementById('sidebar');
if (serviceLnbElement) {
serviceLnbElement.insertAdjacentHTML('beforeend', foldButton);
// 클릭 이벤트 리스너를 정의
const toggleSidebar = () => {
isSidebarMinimized = !isSidebarMinimized;
// max 클래스가 있으면 제거하고 min 클래스 추가
if (serviceLnbElement.classList.toggle('max')) {
serviceLnbElement.classList.remove('min');
webplayer_scroll.style.left = '240px';
} else {
serviceLnbElement.classList.remove('max');
serviceLnbElement.classList.add('min');
webplayer_scroll.style.left = '52px';
}
// isSidebarMinimized 값을 저장
GM_setValue("isSidebarMinimized", isSidebarMinimized ? 1 : 0);
};
// 버튼에 클릭 이벤트 리스너 추가
const buttons = serviceLnbElement.querySelectorAll('.button-fold-sidebar, .button-unfold-sidebar');
for (const button of buttons) {
button.addEventListener('click', toggleSidebar);
}
}
};
const insertTopChannels = async (update) => {
const topIcon = IS_DARK_MODE ?
`<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAsTAAALEwEAmpwYAAAExUlEQVR4nO1ZaYgcVRB+HlkVjyheibckxgNRQZSomB8RFC8MElGiP0T9LSgiASELgkpERRGPxeBqghhfV3XPrpN+1bsx8yMRVOKJENR4H9FVo+sRjbKulDPtfN07Mz1s98xOYD94sDtd7+v63lGv6rUxs5hFb8L35Xxm97k2/dvsSSCKlhDJOLNMVpv7zfPCS8yeAM+LLmWWXXXnq41Iftdnppfhee5idbTutPtaG4ogii4yvQhmOZtIdsKo77A2PINo5BQi+RJEjAdBdK7pJRCVTyRy38HIf2GtLIyf699JEe5ba0dPML0AaysHMcu74NwPRHLaVDtZyCxjYPf22rXRgWYm0d/fvzeR+LjGmcPFzew9Ty7EDU4krBxmpkAkq2BE/2EOb2ijz4pUdFplZgLM4XnM7i+I9Q+031dWg/C/fd9dYLqJUql0MJFsB+c3WWv3abe/2jJLBWZhu+4l0y0QuWfB+Z+nE1GYw+NSYXeN6QZ8X65KreEV0+VilhuTXOEVppMYHKzsz+w+hvX7Yl5OZrceZvOjMAz3M50CkdwDI7/T2vDIvJzM0VG6DIF3pekEgqByaHLNutuL4maO7gDeH60dnWuKBpH0w0s+GRjYOqcobmttH7N81rGzQddlMtcJb812yp2qa5rZbfP9DYuy7JndbclcyfYVJsDzwpuSWWZr8kqlsi+RvAF93sqaMWttnzpe75N9qrcNIjcC03tvtr2snFrQuLvb6Hcf2IeFOK8bill2x8S+Hy7IOqCY3a9pAfqbtS8f26qv729YBAP1p574uQUwR8uB9L1se3kJ7D/QBiLWt9H//fpguWsLEFBPvJjlwda20VKwndAkTUvIaqYa/x4tbc3hHppOgthKwEZ4+fJmdpOTk3vhxtV8CZx6Dje02jbjIZLrQUCUWwBmnRoam9npdOM1CrObDwLmJ4t9WdacZ+R0TC1yC9CTsb4mRw9vZkfk3oFRXj2VRx4GAW824xkeHj4Cy9PcArTgiAk1vje2iZaA87sa5UjWlucly8nG1ysDA1vnYLGTW0B1OcRLqHGOghklkTzTggv2gnuhkU25XD4MuMZzC0hWXtGZ6edaSWnMjm1a3flowQ8C/mh0K+F54Vlg82FuAcxuGKb05vRz33fXYNzP4sNzgSi6eqoAuQX4SgUIkLuA0G/g0NOw/u/PFuAegRF+sgHfEAzYnbkF6PEeH0S6VNLpALPbAg5dmc0XXQ4DshmfBYEcH6ct+k683csFvEFIjxrm8Rj7mwsIF4CAT1vMZqUQ56tOhoshHZjAEIgbuJ0ip1pX1xM2kwzFE/Gzwr8naAEPL/5Gs87a7z/Fvw8NbTw6i6d2HvxfV5v60tkBMxmYolEqjRzDLF/hMR8E7iRm9ypkj9dl8aRynS3WRidjqK7ebJfnmU4gCKJzMNcnku91I4JDr7W6rNUbuWTCJ5uVA/7/Rc8B00kQucv0RbDZ0u3RRtlmLVt9rFk/+s/5Ln2Cqn55wUJlSvn4ijqjFZW26nczt6m5aLdNs9CuOF8XMTqXSJ7AcnMabTeze3zduvCQrjqfFKKb0D2P4TSr1WqDQQ0CplegN3e165c1tY06VpsdbWNE7nUieUpPa2vtATPt7yxmYar4F3KMj24yKDCnAAAAAElFTkSuQmCC" style="width:22px">`
: `<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAsTAAALEwEAmpwYAAAEzklEQVR4nO1ZaYgcVRB+URMVb7zibTDGA1FBFA90QSHGY3emq/qhO9Nd1TuR+aegiAhCFgSViIoiHmCI+kOMP4zHD0Ex8UciqOCJENR4H4nX6iYao5JVKjOdru6Znp5s98xOYD940DOvXr2q96req6pnzCxmMZiwli4A4K+lybfZkwBAlwHwJCL/Jw2A/0DkK8yeAERaDMDbQuGjRn9KnxlkIAaXNgTdJfT3jRYpAcCXmEGE44ydA8ATkdnQJmvHzgQITkXkb5U5TVrL55lBAkDtJET+Ua30N9Z6C8N++dZKINJma/0TzSDAWnsgAH+o7P0XAD69lc5bCEA/qR163/O8A8xMYnx8fC9EXq1t3HH4wjR6xOBi7eAA9LzwMDMFAF6mVn7Kdfn67DFBRZ9OALzMzAQcJzgfgP5Rq39Pt2MRablS4l9r/YtMPzEyUjsIkTcqIdZaa/fudrzQAtAbavxG8SXTLwDwSmXHv0/nRHGc6vH62EWkFaYfQPSvjdtwUJkuL9flaoLX1aaXYOb9EOlztfrP5uWJyKuUQ3+2ZMmN+5peAYDvUJNNWDt2ZF6ejuMdJWao+N5ueoFSiQ+N2yzfVBRvRLpZ7eqv1tYPMUUDgMbVKn1Rr9fnFsXbWjsPkb7q2d0gdqljHQBemi1U7TSxaUTaUC77i7LoXZduiMdKdl5hCiCSF48yOzMfGhraB5HeUQq/l7VjtrELm8Mx3dzqu6PAa0qBO7PoxRGTCQ0A39bFuLsU/SuFCC8OBcB/h4zLZTqliwtqaxsFtlpLx3UaWy77i5QZbZcbP7cCjkOuWv2PsugB+Dkl+CfNFv5e1cX4jyP6AHIrEA+86N5OtI4TXK6U3SFBmqSQEqmG/wtNxnz3TSdATAUAvR5NTm4H0jkJx10Z8eCntEMLbfp8/nWK9tXcCuioU47GdLoAdBnFcarHhH3yrZN9AC6n8SmX/TN0aJFbAbkZIwf2D09XgD5QW7+8tZ/vV/3vpvEZHR09QqenuRWQhCNkKOd7WiFLrdq2djGStTw/nk62L6/U6/W5OtnJrUCzqtY0ofYxSiKifKIDr12+gEjPtKOpVCqHKV6ThfqA43hnJfslk5IzO1IyveYjCb86pf5qV5VApLOVkp8WoAC9rFYkaO33S/rcz+YX3QsAwXBrf1BT871YhAK3KgFXJ/sB+HHVf3cWPwB+QNE/2jofvxQp4N+SW4Hm9d68iGh7MhxApPWRifE1WfwQ6Sq1wut0X6kUnKDClild3cuFRAUhtmo6jtdnfxokllI2/mXabsqcpig0nW8qDBH0EagduJskp5FXRwGbib0p0A61UMW+J0gCr5j/IFFnU4Hfwv+Hh5cencVH7gNlQhOR6dAmpdgLpmiMjNSOBeDv9DVfKvHJAPRm+J/rBjaLj451EGm9td6CeJFMKts83/QC1vK5OtYH4J/FEdXvtzoVa6Uilwj41gkPJfwWuQdMLwHgX9mYKJ6wqPZgSrQ5B4AeSh9HW/r2BCUvL4lEJSnMGhFGMqpGHZUWS/20A/0GiUL7Inwi1XxEp5u726Ax9uFqtXpwX4WPK+ItAOCn9XGa3Xa+WD4ph4AZFEjlrlF+oRXiqPKcJCvcaDu/3wagx+S2ttbuP9PyzmIWpoH/AeaUFWmyMuZqAAAAAElFTkSuQmCC" style="width:22px">`;
const newHtml = `
<div class="top-section top">
<span class="max"><a href="https://www.sooplive.co.kr/live/all">인기 채널</a></span>
<span class="min"><a href="https://www.sooplive.co.kr/live/all">${topIcon}</a></span>
</div>
<div class="users-section top"></div>
`;
const serviceLnbElement = document.getElementById('sidebar');
if (serviceLnbElement && !update) {
serviceLnbElement.insertAdjacentHTML('beforeend', newHtml);
}
const openList = document.body.querySelectorAll('.users-section.top .user:not(.show-more)').length;
try {
const [hiddenBjList, broadListResponse] = await Promise.all([getHiddenbjList(), fetchBroadList('https://live.sooplive.co.kr/api/main_broad_list_api.php?selectType=action&orderType=view_cnt&pageNo=1&lang=ko_KR')]);
HIDDEN_BJ_LIST.length = 0;
HIDDEN_BJ_LIST.push(...hiddenBjList);
const channels = broadListResponse.broad;
const usersSection = document.querySelector('.users-section.top');
let temp_html = '';
channels.forEach(channel => {
const isBlocked = blockedWords.some(word => channel.broad_title.toLowerCase().includes(word.toLowerCase())) ||
HIDDEN_BJ_LIST.includes(channel.user_id) || isCategoryBlocked(channel.broad_cate_no) || isUserBlocked(channel.user_id);
if (!isBlocked) {
const userElement = createUserElement(channel, 0, 0);
temp_html += userElement.outerHTML;
}
});
if (isChzzkTopChannelsEnabled) {
const chzzkTopChannelsData = await fetchBroadList('https://api.chzzk.naver.com/service/v1/lives?size=50&sortType=POPULAR');
const chzzkChannels = chzzkTopChannelsData.content.data;
chzzkChannels.forEach(channel => {
const userElement = createUserElementChzzk(channel, 0);
temp_html += userElement.outerHTML;
});
}
if (update) {
updateElementWithContent(usersSection, temp_html);
} else {
usersSection.insertAdjacentHTML('beforeend', temp_html);
}
desc_order('.users-section.top');
showMore('.users-section.top', 'toggleButton3', update ? openList : displayTop, displayTop);
makeThumbnailTooltip();
} catch (error) {
console.error("Error:", error);
}
};
const extractFollowUserIds = (response) => {
allFollowUserIds = response.data.map(item => item.user_id); // 모든 user_id를 추출하여 전역 배열에 저장
GM_setValue("allFollowUserIds", allFollowUserIds);
};
const insertFavoriteChannels = async (update) => {
let followingListSoop;
let followingListChzzk;
if (isChzzkFollowChannelsEnabled) {
[followingListSoop, followingListChzzk] = await Promise.all([fetchBroadList('https://myapi.sooplive.co.kr/api/favorite'), fetchBroadList('https://api.chzzk.naver.com/service/v1/channels/followings/live',3000)]);
} else {
followingListSoop = await fetchBroadList('https://myapi.sooplive.co.kr/api/favorite');
}
const isSooploggedIn = followingListSoop?.data;
const isChzzkloggedIn = followingListChzzk?.code === 200;
if (!isSooploggedIn && !isChzzkloggedIn) {
return;
}
if (isSooploggedIn){
extractFollowUserIds(followingListSoop);
}
const followIcon = IS_DARK_MODE ?
`<img src="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=" style="width:20px">`
: `<img src="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" style="width:20px">`;
if (!update) {
const newHtml = `
<div class="top-section follow">
<span class="max"><a href="https://www.sooplive.co.kr/my/favorite">즐겨찾기 채널</a></span>
<span class="min"><a href="https://www.sooplive.co.kr/my/favorite">${followIcon}</a></span>
</div>
<div class="users-section follow"></div>
`;
const serviceLnbElement = document.getElementById('sidebar');
serviceLnbElement?.insertAdjacentHTML('beforeend', newHtml);
}
const openList = document.body.querySelectorAll('.users-section.follow .user:not(.show-more)').length;
try {
let tempHtmlArray = [];
const usersSection = document.querySelector('.users-section.follow');
if (isSooploggedIn){
const feedData = await getStationFeed(); // 피드 데이터를 비동기적으로 가져옴
const feedUserIdSet = new Set(feedData.map(feedItem => feedItem.station_user_id));
tempHtmlArray = followingListSoop.data.reduce((acc, item) => {
const { is_live, user_id, broad_info } = item;
const is_mobile_push = isPinnedStreamWithNotificationEnabled === 1 ? item.is_mobile_push : "N";
const is_pin = isPinnedStreamWithPinEnabled === 1 ? item.is_pin : false;
if (is_live) { // 생방송 중
broad_info.forEach(channel => {
const userElement = createUserElement(channel, is_mobile_push, is_pin);
acc.push(userElement.outerHTML);
});
} else if (feedUserIdSet.has(user_id)) { // 비방 + 방송국 새 글
const feedItems = feedData.filter(feedItem => feedItem.station_user_id === user_id);
feedItems.forEach(feedItem => {
if (feedItem?.reg_timestamp && checkIfTimeover(feedItem.reg_timestamp)) {
return; // 타임오버된 경우 넘어감
}
const userElement = createUserElement_offline(item, feedItem);
acc.push(userElement.outerHTML);
});
} else if (is_pin && !isPinnedOnlineOnlyEnabled ) { // 비방 + 상단 고정 + 설정값
const userElement = createUserElement_offline(item, null);
acc.push(userElement.outerHTML);
}
return acc;
}, []);
}
if (isChzzkloggedIn){
// 기존의 tempHtmlArray에 추가하도록 변경
followingListChzzk?.content?.followingList.forEach(item => {
const is_mobile_push = isPinnedStreamWithNotificationEnabled === 1 ? (item?.channel?.personalData?.following?.notification === true ? "Y" : "N") : "N";
const userElement = createUserElementChzzk(item, is_mobile_push);
tempHtmlArray.push(userElement.outerHTML); // 기존 배열에 추가
});
}
if (update) {
updateElementWithContent(usersSection, tempHtmlArray.join(''));
} else {
usersSection.insertAdjacentHTML('beforeend', tempHtmlArray.join(''));
}
desc_order('.users-section.follow');
showMore('.users-section.follow', 'toggleButton2', update ? openList : displayFollow, displayFollow);
makeThumbnailTooltip();
} catch (error) {
console.error("Error in insertFavoriteChannels:", error);
}
};
const insertMyplusChannels = async (update) => {
try {
const response = await fetchBroadList('https://live.sooplive.co.kr/api/myplus/preferbjLiveVodController.php?nInitCnt=6&szRelationType=C');
if (!response || typeof response !== 'object' || response.RESULT === -1 || !response.DATA) {
return;
}
const { DATA } = response;
const myplusIcon = IS_DARK_MODE ?
`<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAABkUlEQVR4nO2Wu0oDQRSGExQsBO/BC2Kl4CNY+ABW4hMIFpZi4RuI4gtYCRYm3eE/m4th5uiKKQWDoiAiIqKlqIjaCBYjQyaYFIYsJruI+eHAzuwM/zdnDjMTi7X0l0XkdwNyBOgnQG+lUqorNPN0utADyDGzmIpIhmKez+d7mXWxZKpvmWXGfgPy2nRzIukD5MQZ3hD5Y0QyXmrrh6aae57fzyynbuXXzGrUGBNn1tsOYKdp5rlcbgDQZ26vrzKZ/RHbzyxLDugd2J9oijmRSjDLuVvlJVF+qPwP0Lu23/NksWGGXF3ZlXGRzR4MlscVCoV2QN5qjLeZeQZknYjafg1ApBLV49RUbfPvAGQ1MEDsh3YQeZ6adgCPkQAw62FXOy9RAcy6WjiMCmDNAWxEBCC+q4G50AGMMXG793Zu+eAKFQCQSZf++0ATuUEAzDLv0k+RAACy6QBWIgFg92awh1HoAEqpDkA+AP2ZTO51hgpgjInb29EVYDGQuVW9F0ydsRALKm6M8R2glwObt9TSv9MXwO1y9weCI98AAAAASUVORK5CYII=" style="width:24px">`
: `<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAABmElEQVR4nO2Wu0oDQRSGNyhYCN6DF8RKwUewENKmyWXO2VMkuznHGLQLFr6BKL6AlWChpa2lYCsYFAUREREtRUXURrBQVk+ICpEsJruI+WFgZ3aG/5t/Z2fGspr6yyKa7UTkXUS5BeBVx3E6AjNPpaQLkfcQ5bVSeCMQ82w22w0gJc8UQC6Mkbg+PzTcnKjQg8j7OutzotwIkTuq9euGmqfTuV4AOdC4z4xxhi3LigDwmiaw3jDzTCbTh8iHH0Z8mkxOD3ntxkhRzZ8ApsYaYk6UjwLIkcZ8QiQD5XcAsqXtM3UzxC8ru1IA5DiRKPSX+8VisVZEfqzWX9O6A5AlImr5NQBRPvq5nzEy8ZP5N5AF3wBWlbofEeUmNb2bUACMcQb1r7kPCYCTOn4nFABEXtQElkMCkG1dA+kwACLet/fGljeuQAEAZFxnf+VrINYJwLaZdQ/YDAUAgFc0gfmQAOT9zuBtRoEDxOPFNkR+RpQX13XbgwaIeKejxl/yZe6p1gOmlmLbnLf8CutizpcAPOfbvKmm/p3eAMYsiMeMK5ANAAAAAElFTkSuQmCC" style="width:24px">`;
if (!update) {
const newHtml = `
<div class="top-section myplus">
<span class="max">추천 채널</span>
<span class="min">${myplusIcon}</span>
</div>
<div class="users-section myplus"></div>
<div class="top-section myplusvod">
<span class="max">추천 VOD</span>
<span class="min">${myplusIcon}</span>
</div>
<div class="users-section myplusvod"></div>
`;
document.getElementById('sidebar')?.insertAdjacentHTML('beforeend', newHtml);
}
const openList = document.querySelectorAll('.users-section.myplus .user:not(.show-more)').length;
const openListvod = document.querySelectorAll('.users-section.myplusvod .user:not(.show-more)').length;
const { live_list: channels, vod_list: vods } = DATA;
const usersSection = document.querySelector('.users-section.myplus');
const usersSection_vod = document.querySelector('.users-section.myplusvod');
const tempHtmlArray = [];
const tempHtmlVodArray = [];
const addChannelElements = (channelList, isVod = false) => {
for (const channel of channelList) {
const isWordBlocked = channel.broad_title &&
blockedWords.some(word => channel.broad_title.toLowerCase().includes(word.toLowerCase()));
// 조건 추가: allFollowUserIds와 channel.user_id 비교
if (
allFollowUserIds.includes(channel.user_id) && // allFollowUserIds에 user_id가 포함된 경우
!isVod && // isVod가 false일 때
isDuplicateRemovalEnabled // 중복 제거 기능이 활성화되어 있을 때
) {
continue; // 조건이 충족되면 다음 루프 반복
}
if (
isCategoryBlocked(isVod ? channel.category : channel.broad_cate_no) ||
isUserBlocked(channel.user_id) ||
isWordBlocked ||
(update && isDuplicateRemovalEnabled && isUserInFollowSection(channel.user_id))
) {
continue; // 다른 조건에 따라 건너뛰기
}
const userElement = isVod ? createUserElement_vod(channel) : createUserElement(channel, 0, 0);
(isVod ? tempHtmlVodArray : tempHtmlArray).push(userElement.outerHTML);
}
};
if (isDuplicateRemovalEnabled && displayFollow) await waitForNonEmptyArray();
addChannelElements(channels);
addChannelElements(vods, true);
if (update) {
updateElementWithContent(usersSection, tempHtmlArray.join(''));
updateElementWithContent(usersSection_vod, tempHtmlVodArray.join(''));
} else {
usersSection.insertAdjacentHTML('beforeend', tempHtmlArray.join(''));
usersSection_vod.insertAdjacentHTML('beforeend', tempHtmlVodArray.join(''));
}
makeThumbnailTooltip();
if (!myplusOrder) {
desc_order('.users-section.myplus');
}
const showMoreHandler = () => {
showMore('.users-section.myplus', 'toggleButton', update ? openList : displayMyplus, displayMyplus);
showMore('.users-section.myplusvod', 'toggleButton4', update ? openListvod : displayMyplusvod, displayMyplusvod);
};
showMoreHandler();
} catch (error) {
console.error("Error fetching or processing data:", error);
}
};
const makeThumbnailTooltip = () => {
try {
const elements = document.querySelectorAll('#sidebar a.user');
const tooltipContainer = document.querySelector('.tooltip-container');
const sidebar = document.getElementById('sidebar');
const hoverTimeouts = new Map();
elements.forEach(element => {
const isOffline = element.getAttribute('data-tooltip-listener') === 'false';
if (isOffline) return;
const hasEventListener = element.getAttribute('data-tooltip-listener') === 'true';
if (!hasEventListener) {
element.addEventListener('mouseenter', (e) => {
const uniqueId = `tooltip-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
element.setAttribute('data-hover-tooltip-id', uniqueId);
const timeoutId = setTimeout(() => {
if (element.matches(':hover') && element.getAttribute('data-hover-tooltip-id') === uniqueId) {
showTooltip(element, uniqueId);
}
}, 20);
hoverTimeouts.set(element, timeoutId);
});
element.addEventListener('mouseleave', (e) => {
element.removeAttribute('data-hover-tooltip-id');
const timeoutId = hoverTimeouts.get(element);
if (timeoutId) {
clearTimeout(timeoutId);
hoverTimeouts.delete(element);
}
const to = e.relatedTarget;
const isGoingToAnotherElement = Array.from(elements).some(el => {
const isOffline = el.getAttribute('data-tooltip-listener') === 'false';
return el !== element && el.contains(to) && !isOffline;
});
if (!isGoingToAnotherElement) {
tooltipContainer.classList.remove('visible');
tooltipContainer.removeAttribute('data-tooltip-id');
tooltipContainer.innerHTML = ''; // 초기화
}
});
window.addEventListener('mouseout', (e) => {
if (!e.relatedTarget && !e.toElement) {
tooltipContainer.classList.remove('visible');
tooltipContainer.innerHTML = '';
}
});
element.setAttribute('data-tooltip-listener', 'true');
}
});
async function showTooltip(element, uniqueId) {
// hover 중인지 다시 검사
if (element.getAttribute('data-hover-tooltip-id') !== uniqueId) return;
tooltipContainer.setAttribute('data-tooltip-id', uniqueId);
const topBarHeight = document.getElementById('serviceHeader')?.offsetHeight ?? 0;
const isScreenMode = document.body.classList.contains('screen_mode');
const { left: elementX, top: elementY } = element.getBoundingClientRect();
const offsetX = elementX + sidebar.offsetWidth;
const offsetY = Math.max(elementY - 260, isScreenMode ? 0 : topBarHeight);
let imgSrc = element.getAttribute('broad_thumbnail');
const broadTitle = element.getAttribute('tooltip');
let broadStart = element.getAttribute('broad_start');
const vodDuration = element.getAttribute('vod_duration');
const randomTimeCode = Date.now();
const userId = element.getAttribute('user_id');
if (broadStart === "NotAvailable") {
try {
const getThumbnailJson = await fetchBroadList(`https://api.chzzk.naver.com/service/v1/channels/${userId}/data?fields=topExposedVideos`);
if (getThumbnailJson?.code === 200) {
const topExposedVideos = getThumbnailJson.content?.topExposedVideos;
if (topExposedVideos?.openLive?.liveImageUrl) {
const newThumbnail = topExposedVideos.openLive.liveImageUrl.split('{type}').join('360');
const newBroadStart = topExposedVideos.openLive.openDate;
if (
tooltipContainer.getAttribute('data-tooltip-id') === uniqueId &&
element.getAttribute('data-hover-tooltip-id') === uniqueId
) {
element.setAttribute('broad_thumbnail', newThumbnail);
element.setAttribute('broad_start', newBroadStart);
imgSrc = newThumbnail;
broadStart = newBroadStart;
}
}
}
} catch (error) {
console.error("Error in fetching thumbnail:", error);
}
}
if (element.getAttribute('data-hover-tooltip-id') !== uniqueId) return;
// 방송 시간 && 이미지 && !게시판이미지
if (broadStart && imgSrc?.startsWith("http") && !imgSrc?.startsWith('https://stimg.')) {
imgSrc += `?${Math.floor(randomTimeCode / 10000)}`;
}
let durationText = broadStart
? getElapsedTime(broadStart, "HH:MM")
: vodDuration;
let tooltipText = '';
if (sidebar.offsetWidth === 52) {
const username = element.querySelector('span.username')?.textContent ?? '';
const description = element.querySelector('span.description')?.textContent ?? '';
let watchers = element.querySelector('span.watchers')?.textContent ?? '';
watchers = watchers.replace('●', '').trim();
tooltipText = `${username} · ${description} · ${watchers}<br>${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 = `<img src="${imgSrc}">`;
if (durationText) {
tooltipContent += `<div class="duration-overlay">${durationText}</div>`;
}
tooltipContent += `<div class="tooltiptext">${tooltipText}</div>`;
tooltipContainer.innerHTML = tooltipContent;
}
Object.assign(tooltipContainer.style, {
left: `${offsetX}px`,
top: `${offsetY}px`
});
tooltipContainer.classList.add('visible');
}
} catch (error) {
console.error('makeThumbnailTooltip 함수에서 오류가 발생했습니다:', error);
}
};
const showMore = (containerSelector, buttonId, n, fixed_n) => {
const userContainer = document.body.querySelector(containerSelector);
const users = Array.from(userContainer?.querySelectorAll('.user') || []);
const displayPerClick = 10;
// n보다 목록이 적으면 함수를 끝낸다
if (users.length <= fixed_n) return false;
// n개를 넘는 모든 요소를 숨긴다
users.slice(n).forEach(user => user.classList.add('show-more'));
const toggleButton = document.createElement('button');
toggleButton.textContent = users.length > n ? `더 보기 (${users.length - n})` : '접기';
toggleButton.id = buttonId;
toggleButton.title = "우클릭시 접기(초기화)";
userContainer.appendChild(toggleButton);
toggleButton.addEventListener('click', () => {
const hiddenUsers = users.filter(user => user.classList.contains('show-more'));
const hiddenCount = hiddenUsers.length;
if (hiddenCount > 0) {
hiddenUsers.slice(0, displayPerClick).forEach(user => user.classList.remove('show-more'));
const remainingHidden = hiddenUsers.length - displayPerClick;
toggleButton.textContent = remainingHidden > 0 ? `더 보기 (${remainingHidden})` : '접기';
} else {
users.slice(fixed_n).forEach(user => user.classList.add('show-more'));
toggleButton.textContent = `더 보기 (${users.length - fixed_n})`;
}
});
toggleButton.addEventListener('contextmenu', event => {
event.preventDefault();
users.slice(fixed_n).forEach(user => user.classList.add('show-more'));
toggleButton.textContent = `더 보기 (${users.length - fixed_n})`;
});
};
const generateBroadcastElements = async (update) => {
console.log(`방송 목록 갱신: ${new Date().toLocaleString()}`);
try {
if (displayFollow) insertFavoriteChannels(update);
if (displayTop) insertTopChannels(update);
if (displayMyplus || displayMyplusvod) insertMyplusChannels(update);
} catch (error) {
console.error('Error:', error);
}
};
const addModalSettings = (serviceUtilDiv) => {
const openModalBtn = document.createElement("div");
openModalBtn.setAttribute("id", "openModalBtn");
const link = document.createElement("button");
link.setAttribute("class", "btn-settings-ui");
openModalBtn.appendChild(link);
serviceUtilDiv.prepend(openModalBtn);
// 모달 컨텐츠를 담고 있는 HTML 문자열
const modalContentHTML = `
<div id="myModal" class="modal">
<div class="modal-content">
<span class="myModalClose">×</span>
<h2 style="font-size: 24px;">확장 프로그램 설정</h2>
<div class="divider"></div>
<h3 style="margin-bottom: 15px; font-size: 16px;">방송 목록 옵션</h3>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchOpenExternalPlayer">
<span class="slider_95642 round"></span>
</label>
<label for="switchOpenExternalPlayer">[썸네일] 오른쪽 클릭시 외부 재생기 열기</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchReplaceEmptyThumbnail">
<span class="slider_95642 round"></span>
</label>
<label for="switchReplaceEmptyThumbnail">[썸네일] 마우스 오버시 연령 제한 썸네일 보기</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchRemoveRedistributionTag">
<span class="slider_95642 round"></span>
</label>
<label for="switchRemoveRedistributionTag">[썸네일] 탐방 허용 태그 숨기기</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchRemoveWatchLaterButton">
<span class="slider_95642 round"></span>
</label>
<label for="switchRemoveWatchLaterButton">[썸네일] 나중에 보기 버튼 숨기기 👍</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchRemoveBroadStartTimeTag">
<span class="slider_95642 round"></span>
</label>
<label for="switchRemoveBroadStartTimeTag">[썸네일] 방송 시작 시간 숨기기 👍</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchRemoveCarousel">
<span class="slider_95642 round"></span>
</label>
<label for="switchRemoveCarousel">[메인 페이지] 자동 재생되는 채널 전광판 숨기기</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchBroadTitleTextEllipsis">
<span class="slider_95642 round"></span>
</label>
<label for="switchBroadTitleTextEllipsis">방송 제목이 긴 경우 ...으로 생략하기 👍</label>
</div>
<div class="divider"></div>
<h3 style="margin-bottom: 15px; font-size: 16px;">사이드바 옵션</h3>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchCustomSidebar">
<span class="slider_95642 round"></span>
</label>
<label for="switchCustomSidebar">사이드바 사용 (해제시 기본 사이드바)</label>
</div>
<div class="option">
<label for="favoriteChannelsDisplay" title="0 = 숨김">[즐겨찾기 채널] 표시 수</label>
<input type="range" id="favoriteChannelsDisplay" min="0" max="40" title="0 = 숨김">
<span id="favoriteChannelsDisplayValue">${displayFollow}</span>
</div>
<div class="option">
<label for="myPlusChannelsDisplay" title="0 = 숨김">[추천 채널] 표시 수</label>
<input type="range" id="myPlusChannelsDisplay" min="0" max="40" title="0 = 숨김">
<span id="myPlusChannelsDisplayValue">${displayMyplus}</span>
</div>
<div class="option">
<label for="myPlusVODDisplay" title="0 = 숨김">[추천 VOD] 표시 수</label>
<input type="range" id="myPlusVODDisplay" min="0" max="40" title="0 = 숨김">
<span id="myPlusVODDisplayValue">${displayMyplusvod}</span>
</div>
<div class="option">
<label for="popularChannelsDisplay" title="0 = 숨김">[인기 채널] 표시 수</label>
<input type="range" id="popularChannelsDisplay" min="0" max="40" title="0 = 숨김">
<span id="popularChannelsDisplayValue">${displayTop}</span>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchSmallUserLayout">
<span class="slider_95642 round"></span>
</label>
<label for="switchSmallUserLayout">미니 방송 목록</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchOpenExternalPlayerFromSidebar">
<span class="slider_95642 round"></span>
</label>
<label for="switchOpenExternalPlayerFromSidebar">오른쪽 클릭시 외부 재생기 열기</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="sendLoadBroadCheck">
<span class="slider_95642 round"></span>
</label>
<label for="sendLoadBroadCheck">새로고침 없는 방송 전환 사용 👍</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchRandomSort">
<span class="slider_95642 round"></span>
</label>
<label for="switchRandomSort">[즐겨찾기 채널] 랜덤 정렬 (해제시 시청자 많은 순)</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchChannelFeed">
<span class="slider_95642 round"></span>
</label>
<label for="switchChannelFeed">[즐겨찾기 채널] 오프라인 채널의 최신 글 보기 👍</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchBlockedCategorySorting">
<span class="slider_95642 round"></span>
</label>
<label for="switchBlockedCategorySorting">[즐겨찾기 채널] 차단된 카테고리를 하단으로 이동</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="fixNotificationChannel">
<span class="slider_95642 round"></span>
</label>
<label for="fixNotificationChannel">[즐겨찾기 채널] 알림 설정된 채널을 상단 고정</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="fixFixedChannel">
<span class="slider_95642 round"></span>
</label>
<label for="fixFixedChannel" title="MY 페이지에서 스트리머 고정 버튼(핀 모양)을 누르면 사이드바에 고정이 됩니다.">[즐겨찾기 채널] 스트리머 관리에서 고정된 채널을 상단 고정<sup>1)</sup></label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchPinnedOnlineOnly">
<span class="slider_95642 round"></span>
</label>
<label for="switchPinnedOnlineOnly">[즐겨찾기 채널] 온라인일 때만 상단 고정하기</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="popularChannelsFirst">
<span class="slider_95642 round"></span>
</label>
<label for="popularChannelsFirst">[추천 채널]을 [인기 채널]보다 위에 표시</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="mpSortByViewers">
<span class="slider_95642 round"></span>
</label>
<label for="mpSortByViewers">[추천 채널] 정렬을 추천순으로 변경 (해제시 시청자순)</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="removeDuplicates">
<span class="slider_95642 round"></span>
</label>
<label for="removeDuplicates">[추천 채널] 즐겨찾기 중복 제거</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="openInNewTab">
<span class="slider_95642 round"></span>
</label>
<label for="openInNewTab">방송 목록 클릭 시 새 탭으로 열기 👎🏼</label>
</div>
<div class="divider"></div>
<h3 style="margin-bottom: 15px; font-size: 16px;">LIVE 플레이어 옵션</h3>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchNoAutoVOD">
<span class="slider_95642 round"></span>
</label>
<label for="switchNoAutoVOD">방송 종료 후 자동으로 VOD 재생하는 기능 차단 👍</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchHideEsportsInfo">
<span class="slider_95642 round"></span>
</label>
<label for="switchHideEsportsInfo">E-Sports 정보 숨기기</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="autoClaimGem">
<span class="slider_95642 round"></span>
</label>
<label for="autoClaimGem">젬 자동 획득 👍</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="showPauseButton">
<span class="slider_95642 round"></span>
</label>
<label for="showPauseButton">[플레이어] 일시정지 버튼 표시 👍</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchCaptureButton">
<span class="slider_95642 round"></span>
</label>
<label for="switchCaptureButton">[플레이어] LIVE / VOD 스크린샷 버튼 표시 👍</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchStreamDownload">
<span class="slider_95642 round"></span>
</label>
<label for="switchStreamDownload">[플레이어] LIVE 스트림 다운로드 버튼 표시<sup>5)</sup> 👍</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchNo1440p">
<span class="slider_95642 round"></span>
</label>
<label for="switchNo1440p">[플레이어] 방송 진입시 1440p가 선택되면 화질을 한 단계 낮추기</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="showBufferTime">
<span class="slider_95642 round"></span>
</label>
<label for="showBufferTime">[채팅창] 방송 딜레이 (남은 버퍼 시간) 표시 👍</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchVideoSkipHandler">
<span class="slider_95642 round"></span>
</label>
<label for="switchVideoSkipHandler">[단축키] 좌/우 방향키를 눌러 1초 전/후로 이동 👍</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchSharpmodeShortcut">
<span class="slider_95642 round"></span>
</label>
<label for="switchSharpmodeShortcut">[단축키] '선명한 모드'(e) 활성화 👍</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchLLShortcut">
<span class="slider_95642 round"></span>
</label>
<label for="switchLLShortcut">[단축키] '시차 단축'(d) 활성화** 👍</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchAdjustDelayNoGrid">
<span class="slider_95642 round"></span>
</label>
<label for="switchAdjustDelayNoGrid">[단축키] (d)를 '앞당기기'로 변경 👎🏼<br>(위 옵션** 활성화 필수, 비 그리드 사용자만)</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchQualityChangeShortcut">
<span class="slider_95642 round"></span>
</label>
<label for="switchQualityChangeShortcut">[단축키] 화질 변경(숫자) 활성화 🆕</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="mutedInactiveTabs">
<span class="slider_95642 round"></span>
</label>
<label for="mutedInactiveTabs">[브라우저 탭] 전환 시 방송 음소거</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchAutoChangeQuality">
<span class="slider_95642 round"></span>
</label>
<label for="switchAutoChangeQuality">[브라우저 탭] 전환 시 방송 화질 낮추기</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchDocumentTitleUpdate">
<span class="slider_95642 round"></span>
</label>
<label for="switchDocumentTitleUpdate">[브라우저 탭] 제목에 시청자 수 표시</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchShowSidebarOnScreenModeAlways">
<span class="slider_95642 round"></span>
</label>
<label for="switchShowSidebarOnScreenModeAlways">[스크린 모드] 항상 사이드바 보기</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="mouseOverSideBar">
<span class="slider_95642 round"></span>
</label>
<label for="mouseOverSideBar">[스크린 모드] 좌측 마우스 오버시 사이드바 사용 👍</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="chatPosition">
<span class="slider_95642 round"></span>
</label>
<label for="chatPosition">[스크린 모드] 세로로 긴 화면에서 채팅창을 아래에 위치 👍</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchAutoScreenMode">
<span class="slider_95642 round"></span>
</label>
<label for="switchAutoScreenMode">[스크린 모드] 자동 스크린 모드</label>
</div>
<div class="divider"></div>
<h3 style="margin-bottom: 15px; font-size: 16px;">VOD 플레이어 옵션</h3>
<div class="option">
<label class="switch">
<input type="checkbox" id="selectBestQuality">
<span class="slider_95642 round"></span>
</label>
<label for="selectBestQuality">최고화질 자동 선택 👍</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchRemoveShadowsFromCatch">
<span class="slider_95642 round"></span>
</label>
<label for="switchRemoveShadowsFromCatch">[플레이어] CATCH 플레이어 하단의 그림자 효과 없애기</label>
</div>
<div class="divider"></div>
<h3 style="margin-bottom: 15px; font-size: 16px;">채팅창 옵션</h3>
<div class="option">
<label for="nicknameWidthDisplay">[닉네임] 가로 크기 (채팅 메시지 정렬시)</label>
<input type="range" id="nicknameWidthDisplay" min="86" max="186">
<span id="nicknameWidthDisplayValue">${nicknameWidth}</span>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchAlignNicknameRight">
<span class="slider_95642 round"></span>
</label>
<label for="switchAlignNicknameRight">[닉네임] 우측 정렬하기 (채팅 메시지 정렬시)</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="selectHideSupporterBadge">
<span class="slider_95642 round"></span>
</label>
<label for="selectHideSupporterBadge">[닉네임] 서포터 배지 숨기기</label>
<label class="switch">
<input type="checkbox" id="selectHideFanBadge">
<span class="slider_95642 round"></span>
</label>
<label for="selectHideFanBadge">[닉네임] 팬 배지 숨기기</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="selectHideSubBadge">
<span class="slider_95642 round"></span>
</label>
<label for="selectHideSubBadge">[닉네임] 구독팬 배지 숨기기</label>
<label class="switch">
<input type="checkbox" id="selectHideVIPBadge">
<span class="slider_95642 round"></span>
</label>
<label for="selectHideVIPBadge">[닉네임] 열혈팬 배지 숨기기</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="selectHideMngrBadge">
<span class="slider_95642 round"></span>
</label>
<label for="selectHideMngrBadge">[닉네임] 매니저 배지 숨기기</label>
<label class="switch">
<input type="checkbox" id="selectHideStreamerBadge">
<span class="slider_95642 round"></span>
</label>
<label for="selectHideStreamerBadge">[닉네임] 스트리머 배지 숨기기</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchUnlockCopyPaste">
<span class="slider_95642 round"></span>
</label>
<label for="switchUnlockCopyPaste">[채팅 입력란] 복사/붙여넣기 기능 복원</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchHideButtonsAboveChatInput">
<span class="slider_95642 round"></span>
</label>
<label for="switchHideButtonsAboveChatInput">[채팅 입력란] 상단 버튼 탭 숨기기</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="selectBlockWords">
<span class="slider_95642 round"></span>
</label>
<label for="selectBlockWords">[메시지] 단어로 차단<sup>2)</sup></label>
</div>
<div class="option">
<textarea id="blockWordsInput" placeholder="콤마(,)로 구분하여 단어 입력" style="width: 100%; height: 34px; border: 1px solid #ccc;">${registeredWords}</textarea>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchShowSelectedMessages">
<span class="slider_95642 round"></span>
</label>
<label for="switchShowSelectedMessages">[메시지] 유저 채팅 모아보기</label>
</div>
<div class="option">
<textarea id="selectedUsersInput" placeholder="대상 유저 추가: 콤마(,)로 구분하여 유저 아이디 입력하세요 \n즐겨찾기는 자동 등록이므로 따로 입력할 필요 없음" style="width: 100%; height: 34px; border: 1px solid #ccc;">${selectedUsers}</textarea>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchShowDeletedMessages">
<span class="slider_95642 round"></span>
</label>
<label for="switchShowDeletedMessages">[메시지] 강제퇴장 된 유저의 채팅 모아보기</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchChatCounter">
<span class="slider_95642 round"></span>
</label>
<label for="switchChatCounter">[메시지] 초당 채팅 수 카운터 표시<sup>3)</sup></label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchExpandLiveChatArea">
<span class="slider_95642 round"></span>
</label>
<label for="switchExpandLiveChatArea">세로로 긴 화면에서 LIVE 채팅창 확장 버튼 추가 👍</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchExpandVODChatArea">
<span class="slider_95642 round"></span>
</label>
<label for="switchExpandVODChatArea">세로로 긴 화면에서 VOD 채팅창 확장 버튼 추가 👍</label>
</div>
<div class="divider"></div>
<h3 style="margin-bottom: 15px; font-size: 16px;">기타 옵션</h3>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchChzzkFollowChannels">
<span class="slider_95642 round"></span>
</label>
<label for="switchChzzkFollowChannels">치지직 팔로우 채널 통합<sup>4)</sup></label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchChzzkTopChannels">
<span class="slider_95642 round"></span>
</label>
<label for="switchChzzkTopChannels">치지직 인기 채널 통합</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="useInterFont">
<span class="slider_95642 round"></span>
</label>
<label for="useInterFont">트위치 폰트 (Inter) 사용</label>
</div>
<div class="option">
<label class="switch">
<input type="checkbox" id="switchMonthlyRecap">
<span class="slider_95642 round"></span>
</label>
<label for="switchMonthlyRecap">월별 리캡 활성화<sup>6)</sup></label>
</div>
<div class="divider"></div>
<h3 style="margin-bottom: 15px; font-size: 16px;">차단 관리</h3>
<span style="margin-bottom: 15px; font-size: 12px; display: block;">채널 차단: 본문 방송 목록 -> 점 세개 버튼 -> [이 브라우저에서 ... 숨기기]</span>
<span style="margin-bottom: 15px; font-size: 12px; display: block;">차단 해제: Tampermonkey 아이콘을 눌러서 가능합니다.</span>
<span style="margin-bottom: 15px; font-size: 12px; display: block;">단어 등록: Tampermonkey 아이콘을 눌러서 가능합니다.</span>
<div class="divider"></div>
<h3 style="margin-bottom: 15px; font-size: 16px;">부가 설명</h3>
<span style="margin-bottom: 15px; font-size: 12px; display: block;">* 👍 (대부분 추천), 👎🏼 (대부분 비추천)</span>
<span style="margin-bottom: 15px; font-size: 12px; display: block;">1) MY 페이지에서 스트리머 고정 버튼(핀 모양)을 누르면 사이드바에 고정이 됩니다.</span>
<span style="margin-bottom: 15px; font-size: 12px; display: block;">2) 해당 단어를 포함하는 메시지 숨김. 완전 일치할 때만 숨김은 단어 앞에 e:를 붙이기. <br>예시) ㄱㅇㅇ,ㅔㅔ,e:ㅇㅇ,e:ㅇㅎ,e:극,e:나,e:락</span>
<span style="margin-bottom: 15px; font-size: 12px; display: block;">3) [메시지] 유저 채팅 모아보기, [메시지] 강제퇴장 된 유저의 채팅 모아보기 둘 중 하나를 먼저 켜야 작동 함</span>
<span style="margin-bottom: 15px; font-size: 12px; display: block;">4) 치지직 로그인이 되어있지 않으면 응답지연이 생겨서 느려집니다</span>
<span style="margin-bottom: 15px; font-size: 12px; display: block;">5) 본문 방송 목록 or 플레이어 페이지 -> 점 세개 버튼 -> [스트림 다운로드]</span>
<span style="margin-bottom: 15px; font-size: 12px; display: block;">6) 상단 바의 프로필 사진을 클릭하면 메뉴가 보입니다</span>
<span style="margin-bottom: 15px; font-size: 12px; display: block;">버그 신고는 <a href="https://gf.qytechs.cn/ko/scripts/484713" target="_blank">https://gf.qytechs.cn/ko/scripts/484713</a>에서 가능합니다.</span>
</div>
</div>
`;
// 모달 컨텐츠를 body에 삽입
document.body.insertAdjacentHTML("beforeend", modalContentHTML);
// 모달 열기 버튼에 이벤트 리스너 추가
let isFirstClick = true; // 첫 클릭 여부를 저장하는 변수
openModalBtn.addEventListener("click", () => {
// 모달을 표시
document.getElementById("myModal").style.display = "block";
// 첫 클릭인 경우에만 updateSettingsData 호출
if (isFirstClick) {
updateSettingsData();
isFirstClick = false; // 첫 클릭 후에는 false로 변경
}
});
// 모달 닫기 버튼에 이벤트 리스너 추가
const closeModalBtn = document.body.querySelector(".myModalClose");
closeModalBtn.addEventListener("click", () => {
// 모달을 숨김
const modal = document.getElementById("myModal");
if (modal) {
modal.style.display = "none";
}
});
// 모달 외부를 클릭했을 때 닫기
document.getElementById("myModal").addEventListener("click", (event) => {
const modalContent = document.querySelector('div.modal-content');
const modal = document.getElementById("myModal");
// 모달 콘텐츠가 아닌 곳을 클릭한 경우에만 모달 닫기
if (modal && !modalContent.contains(event.target)) {
modal.style.display = "none";
}
});
};
const updateSettingsData = () => {
const setCheckboxAndSaveValue = (elementId, storageVariable, storageKey) => {
const checkbox = document.getElementById(elementId);
// elementId가 유효한 경우에만 체크박스를 설정
if (checkbox) {
checkbox.checked = (storageVariable === 1);
checkbox.addEventListener("change", (event) => {
GM_setValue(storageKey, event.target.checked ? 1 : 0);
storageVariable = event.target.checked ? 1 : 0;
});
} else {
console.warn(`Checkbox with id "${elementId}" not found.`);
}
}
// 함수를 사용하여 각 체크박스를 설정하고 값을 저장합니다.
setCheckboxAndSaveValue("fixFixedChannel", isPinnedStreamWithPinEnabled, "isPinnedStreamWithPinEnabled");
setCheckboxAndSaveValue("fixNotificationChannel", isPinnedStreamWithNotificationEnabled, "isPinnedStreamWithNotificationEnabled");
setCheckboxAndSaveValue("showBufferTime", isRemainingBufferTimeEnabled, "isRemainingBufferTimeEnabled");
setCheckboxAndSaveValue("mutedInactiveTabs", isAutoChangeMuteEnabled, "isAutoChangeMuteEnabled");
setCheckboxAndSaveValue("switchAutoChangeQuality", isAutoChangeQualityEnabled, "isAutoChangeQualityEnabled");
setCheckboxAndSaveValue("switchNo1440p", isNo1440pEnabled, "isNo1440pEnabled");
setCheckboxAndSaveValue("popularChannelsFirst", myplusPosition, "myplusPosition");
setCheckboxAndSaveValue("mpSortByViewers", myplusOrder, "myplusOrder");
setCheckboxAndSaveValue("removeDuplicates", isDuplicateRemovalEnabled, "isDuplicateRemovalEnabled");
setCheckboxAndSaveValue("openInNewTab", isOpenNewtabEnabled, "isOpenNewtabEnabled");
setCheckboxAndSaveValue("mouseOverSideBar", showSidebarOnScreenMode, "showSidebarOnScreenMode");
setCheckboxAndSaveValue("switchShowSidebarOnScreenModeAlways", showSidebarOnScreenModeAlways, "showSidebarOnScreenModeAlways");
setCheckboxAndSaveValue("chatPosition", isBottomChatEnabled, "isBottomChatEnabled");
setCheckboxAndSaveValue("showPauseButton", isMakePauseButtonEnabled, "isMakePauseButtonEnabled");
setCheckboxAndSaveValue("switchCaptureButton", isCaptureButtonEnabled, "isCaptureButtonEnabled");
setCheckboxAndSaveValue("switchStreamDownload", isStreamDownloadEnabled, "isStreamDownloadEnabled");
setCheckboxAndSaveValue("switchSharpmodeShortcut", isMakeSharpModeShortcutEnabled, "isMakeSharpModeShortcutEnabled");
setCheckboxAndSaveValue("switchLLShortcut", isMakeLowLatencyShortcutEnabled, "isMakeLowLatencyShortcutEnabled");
setCheckboxAndSaveValue("switchQualityChangeShortcut", isMakeQualityChangeShortcutEnabled, "isMakeQualityChangeShortcutEnabled");
setCheckboxAndSaveValue("sendLoadBroadCheck", isSendLoadBroadEnabled, "isSendLoadBroadEnabled");
setCheckboxAndSaveValue("selectBestQuality", isSelectBestQualityEnabled, "isSelectBestQualityEnabled");
setCheckboxAndSaveValue("selectHideSupporterBadge", isHideSupporterBadgeEnabled, "isHideSupporterBadgeEnabled");
setCheckboxAndSaveValue("selectHideFanBadge", isHideFanBadgeEnabled, "isHideFanBadgeEnabled");
setCheckboxAndSaveValue("selectHideSubBadge", isHideSubBadgeEnabled, "isHideSubBadgeEnabled");
setCheckboxAndSaveValue("selectHideVIPBadge", isHideVIPBadgeEnabled, "isHideVIPBadgeEnabled");
setCheckboxAndSaveValue("selectHideMngrBadge", isHideManagerBadgeEnabled, "isHideManagerBadgeEnabled");
setCheckboxAndSaveValue("selectHideStreamerBadge", isHideStreamerBadgeEnabled, "isHideStreamerBadgeEnabled");
setCheckboxAndSaveValue("selectBlockWords", isBlockWordsEnabled, "isBlockWordsEnabled");
setCheckboxAndSaveValue("useInterFont", isChangeFontEnabled, "isChangeFontEnabled");
setCheckboxAndSaveValue("autoClaimGem", isAutoClaimGemEnabled, "isAutoClaimGemEnabled");
setCheckboxAndSaveValue("switchVideoSkipHandler", isVideoSkipHandlerEnabled, "isVideoSkipHandlerEnabled");
setCheckboxAndSaveValue("switchSmallUserLayout", isSmallUserLayoutEnabled, "isSmallUserLayoutEnabled");
setCheckboxAndSaveValue("switchChannelFeed", isChannelFeedEnabled, "isChannelFeedEnabled");
setCheckboxAndSaveValue("switchCustomSidebar", isCustomSidebarEnabled, "isCustomSidebarEnabled");
setCheckboxAndSaveValue("switchRemoveCarousel", isRemoveCarouselEnabled, "isRemoveCarouselEnabled");
setCheckboxAndSaveValue("switchDocumentTitleUpdate", isDocumentTitleUpdateEnabled, "isDocumentTitleUpdateEnabled");
setCheckboxAndSaveValue("switchRemoveRedistributionTag", isRemoveRedistributionTagEnabled, "isRemoveRedistributionTagEnabled");
setCheckboxAndSaveValue("switchRemoveWatchLaterButton", isRemoveWatchLaterButtonEnabled, "isRemoveWatchLaterButtonEnabled");
setCheckboxAndSaveValue("switchBroadTitleTextEllipsis", isBroadTitleTextEllipsisEnabled, "isBroadTitleTextEllipsisEnabled");
setCheckboxAndSaveValue("switchRemoveBroadStartTimeTag", isRemoveBroadStartTimeTagEnabled, "isRemoveBroadStartTimeTagEnabled");
setCheckboxAndSaveValue("switchUnlockCopyPaste", isUnlockCopyPasteEnabled, "isUnlockCopyPasteEnabled");
setCheckboxAndSaveValue("switchAlignNicknameRight", isAlignNicknameRightEnabled, "isAlignNicknameRightEnabled");
setCheckboxAndSaveValue("switchReplaceEmptyThumbnail", isReplaceEmptyThumbnailEnabled, "isReplaceEmptyThumbnailEnabled");
setCheckboxAndSaveValue("switchAutoScreenMode", isAutoScreenModeEnabled, "isAutoScreenModeEnabled");
setCheckboxAndSaveValue("switchAdjustDelayNoGrid", isAdjustDelayNoGridEnabled, "isAdjustDelayNoGridEnabled");
setCheckboxAndSaveValue("switchHideButtonsAboveChatInput", ishideButtonsAboveChatInputEnabled, "ishideButtonsAboveChatInputEnabled");
setCheckboxAndSaveValue("switchExpandVODChatArea", isExpandVODChatAreaEnabled, "isExpandVODChatAreaEnabled");
setCheckboxAndSaveValue("switchExpandLiveChatArea", isExpandLiveChatAreaEnabled, "isExpandLiveChatAreaEnabled");
setCheckboxAndSaveValue("switchOpenExternalPlayer", isOpenExternalPlayerEnabled, "isOpenExternalPlayerEnabled");
setCheckboxAndSaveValue("switchOpenExternalPlayerFromSidebar", isOpenExternalPlayerFromSidebarEnabled, "isOpenExternalPlayerFromSidebarEnabled");
setCheckboxAndSaveValue("switchRemoveShadowsFromCatch", isRemoveShadowsFromCatchEnabled, "isRemoveShadowsFromCatchEnabled");
setCheckboxAndSaveValue("switchChzzkFollowChannels", isChzzkFollowChannelsEnabled, "isChzzkFollowChannelsEnabled");
setCheckboxAndSaveValue("switchChzzkTopChannels", isChzzkTopChannelsEnabled, "isChzzkTopChannelsEnabled");
setCheckboxAndSaveValue("switchShowSelectedMessages", isShowSelectedMessagesEnabled, "isShowSelectedMessagesEnabled");
setCheckboxAndSaveValue("switchShowDeletedMessages", isShowDeletedMessagesEnabled, "isShowDeletedMessagesEnabled");
setCheckboxAndSaveValue("switchNoAutoVOD", isNoAutoVODEnabled, "isNoAutoVODEnabled");
setCheckboxAndSaveValue("switchHideEsportsInfo", isHideEsportsInfoEnabled, "isHideEsportsInfoEnabled");
setCheckboxAndSaveValue("switchBlockedCategorySorting", isBlockedCategorySortingEnabled, "isBlockedCategorySortingEnabled");
setCheckboxAndSaveValue("switchChatCounter", isChatCounterEnabled, "isChatCounterEnabled");
setCheckboxAndSaveValue("switchRandomSort", isRandomSortEnabled, "isRandomSortEnabled");
setCheckboxAndSaveValue("switchPinnedOnlineOnly", isPinnedOnlineOnlyEnabled, "isPinnedOnlineOnlyEnabled");
setCheckboxAndSaveValue("switchMonthlyRecap", isMonthlyRecapEnabled, "isMonthlyRecapEnabled");
const handleRangeInput = (inputId, displayId, currentValue, storageKey) => {
const input = document.getElementById(inputId);
input.value = currentValue;
input.addEventListener("input", (event) => {
const newValue = parseInt(event.target.value); // event.target.value로 변경
if (newValue !== currentValue) {
GM_setValue(storageKey, newValue);
currentValue = newValue;
document.getElementById(displayId).textContent = newValue;
if (inputId === "nicknameWidthDisplay") setWidthNickname(newValue);
}
});
}
handleRangeInput("favoriteChannelsDisplay", "favoriteChannelsDisplayValue", displayFollow, "displayFollow");
handleRangeInput("myPlusChannelsDisplay", "myPlusChannelsDisplayValue", displayMyplus, "displayMyplus");
handleRangeInput("myPlusVODDisplay", "myPlusVODDisplayValue", displayMyplusvod, "displayMyplusvod");
handleRangeInput("popularChannelsDisplay", "popularChannelsDisplayValue", displayTop, "displayTop");
handleRangeInput("nicknameWidthDisplay", "nicknameWidthDisplayValue", nicknameWidth, "nicknameWidth");
// 채팅 단어 차단 입력 상자 설정
const blockWordsInputBox = document.getElementById('blockWordsInput');
blockWordsInputBox.addEventListener('input', () => {
const inputValue = blockWordsInputBox.value.trim();
registeredWords = inputValue;
GM_setValue("registeredWords", inputValue);
});
// 유저 채팅 모아보기 입력 상자 설정
const selectedUsersinputBox = document.getElementById('selectedUsersInput');
selectedUsersinputBox.addEventListener('input', () => {
const inputValue = selectedUsersinputBox.value.trim();
selectedUsers = inputValue;
GM_setValue("selectedUsers", inputValue);
});
};
const openHlsStream = (nickname, m3u8Url) => {
// HTML과 JavaScript 코드 생성
const htmlContent = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${nickname}</title>
<style>
body {
background-color: black;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
overflow: hidden;
position: relative; /* 자식 요소 위치 조정을 위해 추가 */
}
#video {
position: absolute;
width: 100%;
height: 100%;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
max-height: 100%;
max-width: 100%;
}
#overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5); /* 반투명 배경 */
display: flex;
justify-content: center;
align-items: center;
z-index: 5; /* 비디오보다 위에 보이도록 설정 */
}
#muteButton {
background-color: rgba(255, 255, 255, 0.8);
border: none;
border-radius: 50%;
padding: 30px; /* 버튼 크기 증가 */
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
font-size: 36px; /* 아이콘 크기 증가 */
z-index: 10; /* 버튼이 다른 요소 위에 보이도록 설정 */
}
</style>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
</head>
<body>
<video id="video" controls autoplay muted></video>
<div id="overlay">
<button id="muteButton"><i class="fas fa-volume-mute"></i></button>
</div>
<script>
const video = document.getElementById("video");
const muteButton = document.getElementById("muteButton");
const overlay = document.getElementById("overlay");
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource("${m3u8Url}");
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function () {
video.play();
});
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = "${m3u8Url}";
video.addEventListener("loadedmetadata", function () {
video.play();
});
}
const toggleMute = () => {
video.muted = !video.muted;
muteButton.innerHTML = video.muted ? '<i class="fas fa-volume-mute"></i>' : '<i class="fas fa-volume-up"></i>';
overlay.style.display = 'none'; // 버튼 클릭 후 레이어 사라지도록 설정
};
// 버튼 클릭 시 음소거 해제
muteButton.addEventListener("click", (event) => {
event.stopPropagation(); // 클릭 이벤트 전파 방지
toggleMute();
});
// 문서의 아무 곳을 클릭해도 음소거 해제
overlay.addEventListener("click", toggleMute);
</script>
</body>
</html>
`;
// Blob 생성
const blob = new Blob([htmlContent], { type: 'text/html' });
const blobUrl = URL.createObjectURL(blob);
// 새로운 창으로 Blob URL 열기
window.open(blobUrl, "_blank");
};
unsafeWindow.openHlsStream = openHlsStream;
const openStreamDownloader = async (id, broadNumber) => {
const aid = await getBroadAid2(id, broadNumber);
if (!aid) return;
const m3u8Url = `https://live-global-cdn-v02.sooplive.co.kr/live-stm-12/auth_playlist.m3u8?aid=${aid}`;
const baseUrl = m3u8Url.split('/').slice(0, -1).join('/') + '/';
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>다운로드 준비 중... ${id}</title>
<style>
body { font-family: sans-serif; padding: 20px; }
#info { margin-top: 10px; padding: 10px; border: 1px solid #ccc; border-radius: 8px; }
#info p { margin: 4px 0; }
label { display: block; margin-top: 10px; }
input[type="number"] { width: 60px; }
</style>
</head>
<body>
<h2>📡 실시간 저장 중...</h2>
<button id="saveBtn">💾 저장</button>
<label>
<input type="checkbox" id="autoSaveCheckbox">
자동 저장 (⚠️저장을 너무 오래 하지 않을 시 작업을 잃을 수 있습니다!)
</label>
<label>
간격 (분):
<input type="number" id="autoSaveInterval" min="1" value="30">
</label>
<div id="info">
<p>⏱️ <span id="elapsedTime">00:00:00</span></p>
<p>📦 <span id="downloadedSegments">0</span> / <span id="totalSegments">0</span></p>
<p>🍰 최신 조각: <span id="currentSegment">0</span></p>
<p>🍰 마지막 저장: <span id="lastSavedSegment">0</span></p>
</div>
<label>
⚠️ 디스크에 저장을 하기 전에는 메모리에 임시로 쌓입니다.<br>
⚠️ 메모리가 부족한 환경에서는 자동 저장 주기를 짧게 설정하세요.<br>
⚠️ 페이지를 새로고침하거나 종료하면 저장하지 않은 작업은 전부 잃습니다!
</label>
<script>
const m3u8Url = ${JSON.stringify(m3u8Url)};
const baseUrl = ${JSON.stringify(baseUrl)};
const id = ${JSON.stringify(id)};
const broadNumber = ${JSON.stringify(broadNumber)};
let fetchedSet = new Set();
let downloaded = new Set();
let stopped = false;
let lastSavedSegment = 0;
let segmentQueue = [];
let autoSave = false;
let autoSaveInterval = 30 * 60 * 1000; // 30분
let startTime = Date.now();
let lastAutoSaveTime = Date.now();
let maxSegNum = 0;
const checkbox = document.getElementById("autoSaveCheckbox");
const intervalInput = document.getElementById("autoSaveInterval");
checkbox.addEventListener("change", e => {
autoSave = e.target.checked;
lastAutoSaveTime = Date.now();
});
intervalInput.addEventListener("input", () => {
const minutes = parseInt(intervalInput.value);
if (!isNaN(minutes) && minutes > 0) {
autoSaveInterval = minutes * 60 * 1000;
}
});
document.getElementById("saveBtn").addEventListener("click", () => {
saveToFile();
});
function getTimeString(d = new Date()) {
d = new Date(d.getTime() + 9 * 60 * 60 * 1000); // KST 보정
return d.toISOString().slice(0, 19).replace(/[-T:]/g, '');
}
function updateInfo() {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const h = String(Math.floor(elapsed / 3600)).padStart(2, '0');
const m = String(Math.floor((elapsed % 3600) / 60)).padStart(2, '0');
const s = String(elapsed % 60).padStart(2, '0');
const downloadedCount = downloaded.size;
const totalCount = fetchedSet.size;
// 탭 제목 업데이트
document.title = \`[\${downloadedCount}/\${totalCount}] \${id}\`;
// 정보 패널 업데이트
document.getElementById("elapsedTime").textContent = \`\${h}:\${m}:\${s}\`;
document.getElementById("totalSegments").textContent = totalCount;
document.getElementById("downloadedSegments").textContent = downloadedCount;
document.getElementById("lastSavedSegment").textContent = lastSavedSegment;
document.getElementById("currentSegment").textContent = maxSegNum;
}
async function fetchSegments() {
try {
const res = await fetch(m3u8Url + '&_=' + Date.now());
const text = await res.text();
const lines = text.split('\\n').map(l => l.trim()).filter(l => l && !l.startsWith('#') && l.includes('.TS'));
for (const line of lines) {
const tsUrl = new URL(line, baseUrl).href;
if (!fetchedSet.has(tsUrl)) {
fetchedSet.add(tsUrl);
const match = line.match(/hls_(\\d+)_/i);
const segNum = match ? parseInt(match[1], 10) : null;
if (segNum !== null && segNum > lastSavedSegment) {
maxSegNum = Math.max(maxSegNum, segNum);
downloadSegment(tsUrl, segNum);
}
}
}
} catch (err) {
console.error('m3u8 fetch error', err);
}
}
async function downloadSegment(url, segNum) {
try {
const res = await fetch(url);
if (!res.ok) return;
const buf = await res.arrayBuffer();
segmentQueue.push({ segNum, buf });
downloaded.add(segNum);
// 다운로드 후 즉시 정보 업데이트를 호출하여 더 빠르게 반영
updateInfo();
} catch (err) {
console.error('segment download error', err);
}
}
function saveToFile() {
if (segmentQueue.length === 0) {
alert("저장할 세그먼트가 없습니다.");
return;
}
segmentQueue.sort((a, b) => a.segNum - b.segNum);
const start = segmentQueue[0].segNum;
const end = segmentQueue[segmentQueue.length - 1].segNum;
const timeString = getTimeString();
const blob = new Blob(segmentQueue.map(s => s.buf), { type: 'video/MP2T' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = \`\${id}_\${broadNumber}_\${timeString}_\${start}_to_\${end}.ts\`;
a.click();
lastSavedSegment = end;
segmentQueue = [];
updateInfo();
}
setInterval(fetchSegments, 2000);
setInterval(updateInfo, 1000);
setInterval(() => {
if (autoSave && Date.now() - lastAutoSaveTime >= autoSaveInterval) {
saveToFile();
lastAutoSaveTime = Date.now();
}
}, 5000);
</script>
</body>
</html>
`;
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
};
unsafeWindow.openStreamDownloader = openStreamDownloader;
const makeExternalLinks = (thumbsBoxLinks) => {
for (const thumbsBoxLink of thumbsBoxLinks) {
if (!thumbsBoxLink.classList.contains("externalPlayer-checked")) {
thumbsBoxLink.classList.add("externalPlayer-checked");
const hrefValue = thumbsBoxLink.getAttribute('href');
if (hrefValue?.includes("play.sooplive.co.kr")) {
const [ , , , id, broadNumber] = hrefValue.split('/');
thumbsBoxLink.addEventListener('contextmenu', async (event) => {
event.preventDefault();
event.stopPropagation();
const nickname = thumbsBoxLink.parentNode.parentNode.querySelector('.nick').innerText;
const aid = await getBroadAid2(id, broadNumber);
if (aid){
openHlsStream(nickname, `https://live-global-cdn-v02.sooplive.co.kr/live-stm-12/auth_playlist.m3u8?aid=${aid}`);
}
});
}
}
}
};
const captureLatestFrame = (videoElement) => {
return new Promise((resolve) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 캔버스 크기 설정 (480x270)
const canvasWidth = 480;
const canvasHeight = 270;
canvas.width = canvasWidth;
canvas.height = canvasHeight;
// 원본 비디오의 비율을 유지하면서 크기 계산
const videoRatio = videoElement.videoWidth / videoElement.videoHeight;
const canvasRatio = canvasWidth / canvasHeight;
let drawWidth, drawHeight;
let offsetX = 0, offsetY = 0;
if (videoRatio > canvasRatio) {
drawWidth = canvasWidth;
drawHeight = canvasWidth / videoRatio;
offsetY = (canvasHeight - drawHeight) / 2;
} else {
drawHeight = canvasHeight;
drawWidth = canvasHeight * videoRatio;
offsetX = (canvasWidth - drawWidth) / 2;
}
// 배경을 검은색으로 채우기
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
// 비디오의 현재 프레임을 캔버스에 그림
ctx.drawImage(videoElement, offsetX, offsetY, drawWidth, drawHeight);
// webp 형식으로 변환 후 반환
const dataURL = canvas.toDataURL('image/webp');
resolve(dataURL); // 데이터 URL 반환
});
};
const replaceThumbnails = (thumbsBoxLinks) => {
for (const thumbsBoxLink of thumbsBoxLinks) {
if (!thumbsBoxLink.classList.contains("thumbnail-checked")) {
thumbsBoxLink.classList.add("thumbnail-checked");
const hrefValue = thumbsBoxLink.getAttribute('href');
if (hrefValue && hrefValue.includes("sooplive.co.kr")) {
const [ , , , id, broadNumber] = hrefValue.split('/');
thumbsBoxLink.dataset.lastMouseEnterTime = 0;
thumbsBoxLink.addEventListener('mouseenter', async function(event) {
event.preventDefault();
event.stopPropagation();
const currentTime = Date.now();
const lastMouseEnterTime = Number(thumbsBoxLink.dataset.lastMouseEnterTime);
if (currentTime - lastMouseEnterTime >= 30000) {
thumbsBoxLink.dataset.lastMouseEnterTime = currentTime;
const frameData = await getLatestFrameData(id, broadNumber);
let imgElement = thumbsBoxLink.querySelector('img');
if (!imgElement) {
imgElement = document.createElement('img');
thumbsBoxLink.appendChild(imgElement);
}
imgElement.src = frameData;
}
});
}
}
}
};
const removeUnwantedTags = () =>{
if (isRemoveCarouselEnabled) {
GM_addStyle(`
div[class^="player_player_wrap"] {
display: none !important;
}
`);
}
if (isRemoveRedistributionTagEnabled) {
GM_addStyle(`
[data-type=cBox] .thumbs-box .allow {
display: none !important;
}
`);
}
if (isRemoveWatchLaterButtonEnabled) {
GM_addStyle(`
[data-type=cBox] .thumbs-box .later {
display: none !important;
}
`);
}
if (isRemoveBroadStartTimeTagEnabled) {
GM_addStyle(`
[data-type=cBox] .thumbs-box .time {
display: none !important;
}
`);
}
if (isBroadTitleTextEllipsisEnabled) {
GM_addStyle(`
[data-type=cBox] .cBox-info .title a {
white-space: nowrap;
text-overflow: ellipsis;
display: inline-block;
}
`);
}
};
const createStreamDownloadButton_LivePage = () => {
const moreDotLayers = document.querySelectorAll('._moreDot_layer');
moreDotLayers.forEach(moreDotLayer => {
// 이미 버튼이 있으면 중복 삽입 방지
if (moreDotLayer.querySelector('.stream-download')) return;
const downloadButton = document.createElement('button');
downloadButton.type = 'button';
downloadButton.textContent = '스트림 다운로드';
downloadButton.className = 'stream-download';
downloadButton.addEventListener('click', () => {
const match = location.href.match(/\/([^/]+)\/(\d+)/);
if (!match) {
console.warn("URL에서 id 또는 broadNumber를 추출할 수 없습니다.");
return;
}
const id = match[1];
const broadNumber = match[2];
openStreamDownloader(id, broadNumber);
});
moreDotLayer.insertBefore(downloadButton, moreDotLayer.firstChild);
});
};
const appendPauseButton = async () => {
try {
// 기존 버튼이 있다면 제거
const existingButton = document.body.querySelector("#closeStream");
if (existingButton) {
existingButton.remove();
}
// time_shift_play 버튼이 숨겨져 있을 때만 버튼 생성
const timeShiftButton = await waitForElementAsync('button#time_shift_play');
if (window.getComputedStyle(timeShiftButton).display !== 'none') return;
const ctrlDiv = document.body.querySelector('div.ctrl');
if (!ctrlDiv) return;
const newCloseStreamButton = document.createElement("button");
newCloseStreamButton.type = "button";
newCloseStreamButton.id = "closeStream";
newCloseStreamButton.className = "pause on";
const tooltipDiv = document.createElement("div");
tooltipDiv.className = "tooltip";
const spanElement = document.createElement("span");
spanElement.textContent = "일시정지";
tooltipDiv.appendChild(spanElement);
newCloseStreamButton.appendChild(tooltipDiv);
ctrlDiv.insertBefore(newCloseStreamButton, ctrlDiv.firstChild);
newCloseStreamButton.addEventListener("click", (e) => {
e.preventDefault();
toggleStream(newCloseStreamButton, spanElement);
});
} catch (error) {
console.error("스트리밍 종료 버튼 생성 실패:", error);
}
};
const toggleStream = (button, spanElement) => {
try {
if (button.classList.contains("on")) {
unsafeWindow.livePlayer.closeStreamConnector();
button.classList.remove("on", "pause");
button.classList.add("off", "play");
spanElement.textContent = "재생";
} else {
unsafeWindow.livePlayer._startBroad();
button.classList.remove("off", "play");
button.classList.add("on", "pause");
spanElement.textContent = "일시정지";
}
} catch (error) {
console.log(error);
}
};
const updateBjIdIfMismatch = () => {
const currentUrl = window.location.href;
const urlBjId = currentUrl.split('/')[3];
const infoNickName = document.querySelector('#infoNickName');
const dataBjId = infoNickName.getAttribute('data-bj_id');
const streamerNick = document.querySelector('#streamerNick');
if (dataBjId !== urlBjId) {
infoNickName.setAttribute('data-bj_id', urlBjId);
streamerNick.setAttribute('data-bj_id', urlBjId);
}
};
const emptyViewStreamer = () => {
const viewStreamer = document.getElementById('view_streamer');
if (viewStreamer) {
viewStreamer.innerHTML = '';
}
};
const setWidthNickname = (wpx) => {
if (typeof wpx === 'number' && wpx > 0) { // wpx가 유효한 값인지 확인
GM_addStyle(`
.starting-line .chatting-list-item .message-container .username {
width: ${wpx}px !important;
}
`);
} else {
console.warn('Invalid width value provided for setWidthNickname.'); // 유효하지 않은 값 경고
}
};
const hideBadges = () => {
const badgeSettings = [
{ key: 'isHideSupporterBadgeEnabled', className: 'support' },
{ key: 'isHideFanBadgeEnabled', className: 'fan' },
{ key: 'isHideSubBadgeEnabled', className: 'sub' },
{ key: 'isHideVIPBadgeEnabled', className: 'vip' },
{ key: 'isHideManagerBadgeEnabled', className: 'manager' },
{ key: 'isHideStreamerBadgeEnabled', className: 'streamer' }
];
// 각 배지 숨김 설정 값 가져오기
const settings = badgeSettings.map(setting => ({
key: setting.key,
enabled: GM_getValue(setting.key),
className: setting.className
}));
// 모든 배지 숨김 설정이 비활성화된 경우 종료
if (!settings.some(setting => setting.enabled)) {
return;
}
// 활성화된 설정에 대한 CSS 규칙 생성
let cssRules = settings
.filter(setting => setting.enabled)
.map(setting => `[class^="grade-badge-${setting.className}"] { display: none !important; }`)
.join('\n');
// 서브 배지용 CSS 규칙 추가
if (settings.find(s => s.className === 'sub' && s.enabled)) {
const thumbSpanSelector = CURRENT_URL.startsWith("https://play.sooplive.co.kr/")
? '#chat_area div.username > button > span.thumb'
: '#chatMemo div.username > button > span.thumb';
cssRules += `\n${thumbSpanSelector} { display: none !important; }`;
}
// CSS 규칙 한 번만 적용
GM_addStyle(cssRules);
};
const unlockCopyPaste = (targetDiv) => {
const writeArea = document.getElementById('write_area');
// 복사 기능
const handleCopy = (event) => {
event.preventDefault(); // 기본 복사 동작 막기
const selectedText = window.getSelection().toString(); // 선택된 텍스트 가져오기
if (selectedText) {
event.clipboardData.setData('text/plain', selectedText); // 클립보드에 텍스트 쓰기
}
};
// 잘라내기 기능
const handleCut = (event) => {
event.preventDefault(); // 기본 잘라내기 동작 막기
const selectedText = window.getSelection().toString(); // 선택된 텍스트 가져오기
if (selectedText) {
event.clipboardData.setData('text/plain', selectedText); // 클립보드에 텍스트 쓰기
document.execCommand("delete"); // 선택된 텍스트 삭제
}
};
// 붙여넣기 기능
const handlePaste = (event) => {
event.preventDefault(); // 기본 붙여넣기 동작 막기
const text = (event.clipboardData || window.clipboardData).getData('text'); // 클립보드에서 텍스트 가져오기
document.execCommand("insertText", false, text); // 텍스트를 수동으로 삽입
};
// 이벤트 리스너 등록
writeArea.addEventListener('copy', handleCopy);
writeArea.addEventListener('cut', handleCut);
writeArea.addEventListener('paste', handlePaste);
};
const alignNicknameRight = () => {
GM_addStyle(`
.starting-line .chatting-list-item .message-container .username > button {
float: right !important;
white-space: nowrap;
}
`);
};
const hideButtonsAboveChatInput = () => {
const style = `
.chatbox .actionbox .chat_item_list {
display: none !important;
}
.chatbox .actionbox {
height: auto !important;
}
`;
GM_addStyle(style);
};
const addStyleExpandLiveChat = () => {
const style = `
body.expandLiveChat:not(.screen_mode,.fullScreen_mode) #serviceHeader,
body.expandLiveChat:not(.screen_mode,.fullScreen_mode) .broadcast_information,
body.expandLiveChat:not(.screen_mode,.fullScreen_mode) .section_selectTab,
body.expandLiveChat:not(.screen_mode,.fullScreen_mode) .wrapping.player_bottom{
display: none !important;
}
body.expandLiveChat:not(.screen_mode,.fullScreen_mode) #webplayer_contents,
body.expandLiveChat:not(.screen_mode,.fullScreen_mode) #sidebar {
top: 0 !important;
margin-top: 0 !important;
min-height: 100vh !important;
}
body.expandLiveChat:not(.screen_mode,.fullScreen_mode) #webplayer #webplayer_contents .wrapping.side {
padding: 0 !important;
}
`;
GM_addStyle(style);
};
const makeExpandChatButton = (el, css_class) => {
if (!el) return;
// li 요소 생성
const li = document.createElement('li');
li.className = 'expand-toggle-li';
// a 요소 생성
const a = document.createElement('a');
a.href = 'javascript:;';
a.setAttribute('tip', '확장/축소(x)');
a.textContent = '확장/축소(x)';
// 클릭 이벤트 등록 (a에 등록해도 되고 li에 등록해도 됨)
a.addEventListener('click', () => {
document.body.classList.toggle(css_class);
});
// li에 a 추가, 그리고 el에 li 추가
li.appendChild(a);
el.appendChild(li);
};
const makeCaptureButton = () => {
const svgDataUrl = 'data:image/svg+xml,%3Csvg%20width%3D%2264%22%20height%3D%2264%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20stroke%3D%22%23fff%22%3E%3Cg%20stroke-width%3D%220%22%2F%3E%3Cg%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke%3D%22%23CCC%22%20stroke-width%3D%22.048%22%2F%3E%3Cg%20stroke-width%3D%221.488%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M21%2013c0-2.667-.5-5-1-5.333-.32-.214-1.873-.428-4-.553C14.808%207.043%2017%205%2012%205S9.192%207.043%208%207.114c-2.127.125-3.68.339-4%20.553C3.5%208%203%2010.333%203%2013s.5%205%201%205.333S8%2019%2012%2019s7.5-.333%208-.667c.5-.333%201-2.666%201-5.333%22%2F%3E%3Cpath%20d%3D%22M12%2016a3%203%200%201%200%200-6%203%203%200%200%200%200%206%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E';
// 1. CSS 삽입
const style = document.createElement('style');
style.textContent = `
#player .imageCapture {
overflow: visible;
color: rgba(0, 0, 0, 0);
width: 32px;
height: 32px;
margin: 0;
font-size: 0;
opacity: 0.9;
background: url("${svgDataUrl}") 50% 50% no-repeat;
background-size: 82%;
border: none;
padding: 0;
cursor: pointer;
position: relative;
}
#player .imageCapture:hover {
opacity: 1;
}
`;
document.head.appendChild(style);
const captureVideoFrame = (shouldDownloadImmediately = false) => {
const video = document.getElementById('livePlayer') || document.getElementById('video');
if (!video) {
console.error('비디오 요소를 찾을 수 없습니다.');
return;
}
// 캔버스 생성 및 비디오의 현재 프레임 그리기
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// 파일명을 위한 타임스탬프 생성
const now = new Date();
const pad = n => String(n).padStart(2, '0');
const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
const filename = `capture_${timestamp}.jpg`;
// 캔버스 이미지를 JPEG Blob 객체로 변환
canvas.toBlob(blob => {
if (!blob) {
console.error('Blob 데이터를 생성하는데 실패했습니다.');
return;
}
// --- 여기서 인자에 따라 동작이 분기됩니다 ---
if (shouldDownloadImmediately) {
// [분기 1] 즉시 다운로드 로직
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url); // 다운로드 후 즉시 URL 해제
} else {
// [분기 2] 새 탭에서 열기 로직
const imgURL = URL.createObjectURL(blob);
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>ScreenShot (${video.videoWidth}x${video.videoHeight})</title>
<style>
body { margin: 0; background: #000; display: flex; justify-content: center; align-items: center; height: 100vh; position: relative; }
img { max-width: 100%; max-height: 100%; }
#downloadBtn { position: absolute; top: 16px; right: 16px; padding: 8px 12px; background-color: #ffffffcc; border: none; border-radius: 4px; font-size: 14px; cursor: pointer; font-weight: bold; }
</style>
</head>
<body>
<img id="capturedImg" src="${imgURL}" alt="영상 캡쳐 이미지">
<button id="downloadBtn">다운로드 ${filename}</button>
<script>
// 새 탭 안의 다운로드 버튼 클릭 이벤트
document.getElementById('downloadBtn').addEventListener('click', () => {
const a = document.createElement('a');
a.href = document.getElementById('capturedImg').src;
a.download = '${filename}';
a.click();
});
// 새 탭이 닫힐 때 Blob URL을 해제하여 메모리 누수 방지
window.addEventListener('beforeunload', () => {
URL.revokeObjectURL("${imgURL}");
});
</script>
</body>
</html>`;
const blobURL = URL.createObjectURL(new Blob([html], { type: 'text/html;charset=UTF-8' }));
window.open(blobURL, '_blank');
// 여기서 imgURL을 해제하면 새 탭에서 이미지가 보이지 않으므로, 새 탭 내부에서 해제합니다.
}
}, 'image/jpeg', 0.92);
};
// 2. 버튼 생성
const createButton = () => {
const btn = document.createElement('button');
btn.className = 'imageCapture';
btn.type = 'button';
btn.title = '클릭: 새 탭에서 보기 / 우클릭: 바로 다운로드';
// 좌클릭: 새 탭에서 열기
btn.addEventListener('click', () => {
try {
// 인자를 false 또는 생략하여 호출
captureVideoFrame(false);
} catch (err) {
console.error('캡처 실패:', err);
}
});
// 우클릭: 즉시 다운로드
btn.addEventListener('contextmenu', (event) => {
event.preventDefault(); // 기본 컨텍스트 메뉴 방지
try {
// 인자를 true로 전달하여 호출
captureVideoFrame(true);
} catch (err) {
console.error('캡처 및 다운로드 실패:', err);
}
});
return btn;
};
// 3. 버튼 삽입
const insertButton = async () => {
try {
const container = await waitForElementAsync('#player .player_ctrlBox .ctrlBox .right_ctrl');
if (container && !container.querySelector('.imageCapture')) {
const btn = createButton();
container.insertBefore(btn, container.firstChild);
}
} catch (error) {
console.error("버튼 추가 실패! 원인:", error.message);
}
};
insertButton();
};
const addStyleExpandVODChat = () => {
const style = `
.expandVODChat:not(.screen_mode,.fullScreen_mode) #serviceHeader,
.expandVODChat:not(.screen_mode,.fullScreen_mode) .broadcast_information,
.expandVODChat:not(.screen_mode,.fullScreen_mode) .section_selectTab,
.expandVODChat:not(.screen_mode,.fullScreen_mode) .wrapping.player_bottom{
display: none !important;
}
.expandVODChat:not(.screen_mode,.fullScreen_mode) #webplayer_contents {
margin: 0 auto !important;
min-height: 100vh !important;
}
`;
GM_addStyle(style);
};
const addStyleRemoveShadowsFromCatch = () => {
const style = `
.catch_webplayer_wrap .vod_player:after {
background-image: none !important;
}
`;
GM_addStyle(style);
};
const setupExpandVODChatFeature = async() => {
try {
const element = await waitForElementAsync('#chatting_area div.area_header > div.chat_title > ul', 15000); // 15초 타임아웃
addStyleExpandVODChat();
makeExpandChatButton(element, 'expandVODChat'); // `await`로 받은 element를 사용
toggleExpandChatShortcut();
updateBodyClass('expandVODChat');
window.addEventListener('resize', debounce(() => updateBodyClass('expandVODChat'), 500));
} catch (error) {
console.error("VOD 채팅 확장 기능 설정에 실패했습니다:", error.message);
}
};
const setupExpandLiveChatFeature = async () => {
try {
// 1. 첫 번째 조건: 채팅창 헤더가 나타날 때까지 기다립니다.
const element = await waitForElementAsync('#chatting_area div.area_header > div.chat_title > ul');
// 2. 두 번째 조건: body에 'ratio169_mode' 클래스가 추가될 때까지 기다립니다.
//await waitForConditionAsync(() => document.body.classList.contains('ratio169_mode'));
// 3. 모든 조건이 충족되었으므로, 이제 기능들을 순서대로 실행합니다.
addStyleExpandLiveChat();
makeExpandChatButton(element, 'expandLiveChat');
toggleExpandChatShortcut();
updateBodyClass('expandLiveChat');
window.addEventListener('resize', debounce(() => updateBodyClass('expandLiveChat'), 500));
} catch (error) {
// console.error("setupExpandLiveChatFeature 실패:", error.message);
}
}
const setupSettingButtonTopbar = async () => {
const serviceUtilDiv = await waitForElementAsync('div.serviceUtil');
addModalSettings(serviceUtilDiv);
const openModalBtnDiv = await waitForElementAsync('#openModalBtn > button');
manageRedDot(openModalBtnDiv);
};
// --- 리캡 관련 유틸리티 함수 ---
function formatDate(date) { const y = date.getFullYear(), m = String(date.getMonth() + 1).padStart(2, '0'), d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; }
function formatSecondsToHMS(totalSeconds) { if (totalSeconds === 0) return '0초'; const h = Math.floor(totalSeconds / 3600), m = Math.floor((totalSeconds % 3600) / 60), s = totalSeconds % 60; let p = []; if (h > 0) p.push(h + '시간'); if (m > 0) p.push(m + '분'); if (s > 0 || p.length === 0) p.push(s + '초'); return p.join(' '); }
function formatSecondsToHM(seconds) { const totalMinutes = Math.round(seconds / 60); if (totalMinutes < 1) return '1분 미만'; const h = Math.floor(totalMinutes / 60), m = totalMinutes % 60; let p = []; if (h > 0) p.push(h + '시간'); if (m > 0) p.push(m + '분'); return p.join(' ') || '0분'; }
function formatAxisSeconds(seconds) {
if (seconds === 0) return '0';
if (seconds >= 3600) return Math.round(seconds / 3600) + '시간'; // Show hours
if (seconds >= 60) return Math.round(seconds / 60) + '분'; // Show minutes
return seconds + '초'; // Show seconds
}
function createPlaceholderSvg(text) { const svg = `<svg width="150" height="150" xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" fill="#2e2e33" /><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="sans-serif" font-size="60" fill="#efeff1">${text}</text></svg>`; return `data:image/svg+xml,${encodeURIComponent(svg)}`; }
// --- 리캡 관련 API 호출 및 데이터 처리 함수 ---
function getUserInfo() { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: INFO_API_URL, onload: (res) => { try { const d = JSON.parse(res.responseText); if (d?.CHANNEL?.IS_LOGIN === 1 && d.CHANNEL.LOGIN_ID) { resolve({ id: d.CHANNEL.LOGIN_ID, nick: d.CHANNEL.LOGIN_NICK }); } else { reject(new Error('로그인 정보를 찾을 수 없습니다.')); } } catch (e) { reject(new Error('로그인 정보 파싱 실패')); } }, onerror: (err) => { reject(new Error('로그인 정보 API 요청 실패')); } }); }); }
function fetchData(userId, startDate, endDate, module) { return new Promise((resolve, reject) => { const p = new URLSearchParams({ szModule: module, szMethod: 'watch', szStartDate: startDate, szEndDate: endDate, nPage: 1, szId: userId }); GM_xmlhttpRequest({ method: "POST", url: STATS_API_URL, data: p.toString(), headers: { "Content-Type": "application/x-www-form-urlencoded" }, onload: (res) => { if (res.status >= 200 && res.status < 300) { resolve(JSON.parse(res.responseText)); } else { reject(new Error(`통계 데이터 요청 실패: ${res.statusText}`)); } }, onerror: (err) => { reject(new Error(`통계 API 요청 실패`)); } }); }); }
async function getStreamerProfileUrl(originalNick) { const search = (searchTerm) => new Promise(resolve => { const params = new URLSearchParams({ m: 'searchHistory', service: 'list', d: searchTerm }); GM_xmlhttpRequest({ method: "GET", url: `${SEARCH_API_URL}?${params.toString()}`, onload: (res) => { try { const data = JSON.parse(res.responseText); const exactMatch = data?.suggest_bj?.find(s => s.user_nick === originalNick); resolve(exactMatch ? exactMatch.station_logo : null); } catch { resolve(null); } }, onerror: () => resolve(null) }); }); let logoUrl = await search(originalNick); if (logoUrl) return logoUrl; const sanitizedNick = originalNick.replace(/[^\p{L}\p{N}\s]/gu, ''); if (sanitizedNick !== originalNick) { logoUrl = await search(sanitizedNick); if (logoUrl) return logoUrl; } return null; }
function imageToDataUri(url) { return new Promise(resolve => { if (!url) { resolve(null); return; } GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'blob', onload: function(response) { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.onerror = () => resolve(null); reader.readAsDataURL(response.response); }, onerror: () => resolve(null) }); }); }
async function getCategoryImageMap() { if (categoryImageMap) return categoryImageMap; return new Promise((resolve) => { const params = new URLSearchParams({ m: 'categoryList', szOrder: 'prefer', nListCnt: 200 }); GM_xmlhttpRequest({ method: "GET", url: `${CATEGORY_API_URL}?${params.toString()}`, onload: (res) => { try { const data = JSON.parse(res.responseText); const map = new Map(); data?.data?.list?.forEach(cat => map.set(cat.category_name, cat.cate_img)); categoryImageMap = map; resolve(map); } catch { resolve(new Map()); } }, onerror: () => resolve(new Map()) }); }); }
// --- UI 렌더링 함수 ---
async function renderAll(streamerRawData, categoryRawData, userInfo, categoryImages) {
const wrapper = document.getElementById('recap-content-wrapper');
wrapper.innerHTML = '';
const streamerData = streamerRawData?.data || {};
const categoryData = categoryRawData?.data || {};
const stats = streamerData?.broad_cast_info?.data || { average_watch_time: 0, cumulative_watch_time: 0 };
const visitedDays = streamerData?.table1?.data?.filter(d => d.total_watch_time !== '00:00:00').length || 0;
const allStreamersRaw = streamerData?.chart?.data_stack?.map(s => ({ nick: s.bj_nick, total: s.data.reduce((a, b) => a + b, 0) })) || [];
const otherEntry = allStreamersRaw.find(s => s.nick === '기타');
const sortedStreamers = allStreamersRaw.filter(s => s.nick !== '기타').sort((a, b) => b.total - a.total);
const allStreamersSorted = otherEntry ? [...sortedStreamers, otherEntry] : sortedStreamers;
const top4Streamers = sortedStreamers.slice(0, 4);
const rankedCategories = categoryData?.table2?.data || [];
const profilePicUrl = `https://profile.img.sooplive.co.kr/LOGO/${userInfo.id.substring(0, 2)}/${userInfo.id}/${userInfo.id}.jpg`;
const profileDataUri = await imageToDataUri(profilePicUrl);
const placeholderUserAvatar = createPlaceholderSvg(userInfo.nick.substring(0,1));
const profileHeader = document.createElement('div');
profileHeader.className = 'recap-profile-header';
profileHeader.innerHTML = `<img src="${profileDataUri || placeholderUserAvatar}" class="profile-pic" onerror="this.src='${placeholderUserAvatar}'"><span class="profile-name">${userInfo.nick}님</span>`;
wrapper.appendChild(profileHeader);
const keyStatsGrid = document.createElement('div');
keyStatsGrid.className = 'key-stats-grid';
keyStatsGrid.innerHTML = `<div class="stat-card time"><div class="label">평균 ${formatSecondsToHM(stats.average_watch_time)}</div><div class="value">${formatSecondsToHM(stats.cumulative_watch_time).replace(/(\d+)([가-힣]+)/g, '$1<span class="unit">$2</span>')}</div></div><div class="stat-card days"><div class="label">이 달의 출석</div><div class="value">${visitedDays}<span class="unit">일</span></div></div>`;
wrapper.appendChild(keyStatsGrid);
const topStreamersSection = document.createElement('div');
topStreamersSection.innerHTML = `<div class="section-title">많이 본 방송</div>`;
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 `<div class="streamer-card-bg" style="background-image: url(${avatarUri || placeholder})"></div><div class="streamer-card-content"><img src="${avatarUri || placeholder}" class="streamer-card-avatar" onerror="this.src='${placeholder}'"><div class="streamer-card-name">${streamer.nick}</div><div class="streamer-card-time">${formatSecondsToHM(streamer.total)}</div></div>`; };
const [s1, s2, s3, s4] = top4Streamers;
if (s1) topContainer.innerHTML += `<div class="streamer-col-1 streamer-card" data-rank="1">${streamerCardHTML(s1, avatarDataUris[0])}</div>`;
if (s2) topContainer.innerHTML += `<div class="streamer-col-2 streamer-card" data-rank="2">${streamerCardHTML(s2, avatarDataUris[1])}</div>`;
if (s3) topContainer.innerHTML += `<div class="streamer-col-3"><div class="streamer-card" data-rank="3">${streamerCardHTML(s3, avatarDataUris[2])}</div>${s4 ? `<div class="streamer-card" data-rank="4">${streamerCardHTML(s4, avatarDataUris[3])}</div>` : ''}</div>`;
const streamerCards = wrapper.querySelectorAll('.top-streamers-container .streamer-card');
streamerCards.forEach(card => {
// 카드의 등급(rank)을 먼저 확인합니다.
const rank = parseInt(card.dataset.rank, 10);
// ▼▼▼ [핵심 수정] 1등 또는 2등 카드일 경우에만 이벤트 리스너를 추가합니다. ▼▼▼
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;
// rank 변수는 이미 바깥에서 선언했으므로 그대로 사용합니다.
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;
// CSS 변수 업데이트
card.style.setProperty('--mouse-x', `${mouseX}px`);
card.style.setProperty('--mouse-y', `${mouseY}px`);
card.style.setProperty('--mouse-x-percent', `${(mouseX / rect.width) * 200 - 50}%`);
card.style.setProperty('--mouse-active', '1');
const angle = Math.atan2(y, x) * (180 / Math.PI) + 90;
card.style.setProperty('--angle', `${angle}deg`);
card.style.transform = `perspective(1200px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) translateZ(${translateZ}px)`;
});
card.addEventListener('mouseleave', () => {
card.style.transform = 'perspective(1200px) rotateX(0) rotateY(0) translateZ(0)';
card.style.setProperty('--mouse-active', '0');
});
}
});
const rankExpandButton = document.createElement('button'); rankExpandButton.className = 'expand-button'; rankExpandButton.textContent = '전체 채널 순위 보기 ▾'; wrapper.appendChild(rankExpandButton);
const fullRankContainer = document.createElement('div'); fullRankContainer.id = 'full-ranking-chart-container'; fullRankContainer.style.display = 'none'; wrapper.appendChild(fullRankContainer);
let isRankChartRendered = false;
const renderAndToggleRankChart = () => { const isHidden = fullRankContainer.style.display === 'none'; fullRankContainer.style.display = isHidden ? 'block' : 'none'; rankExpandButton.textContent = isHidden ? '숨기기 ▴' : '전체 채널 순위 보기 ▾'; if (isHidden && !isRankChartRendered) { const colors = ['#8e44ad', '#3498db', '#e74c3c', '#16a085', '#f39c12', '#2c3e50', '#d35400', '#c0392b', '#1abc9c', '#f1c40f']; const container = document.getElementById('full-ranking-chart-container'); const chartHeight = Math.max(400, allStreamersSorted.length * 28); container.style.height = `${chartHeight}px`; const canvas = document.createElement('canvas'); container.appendChild(canvas); activeCharts.push(new Chart(canvas, { type: 'bar', data: { labels: allStreamersSorted.map(s => s.nick), datasets: [{ label: '총 시청 시간', data: allStreamersSorted.map(s => s.total), backgroundColor: colors }] }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (c) => formatSecondsToHMS(c.parsed.x) } } }, scales: { x: { ticks: { color: '#efeff1', callback: (value) => formatAxisSeconds(value) }, grid: { color: 'rgba(239, 239, 241, 0.1)' } }, y: { ticks: { color: '#efeff1', autoSkip: false }, grid: { color: 'rgba(239, 239, 241, 0.1)' } } } } })); isRankChartRendered = true; } };
rankExpandButton.addEventListener('click', renderAndToggleRankChart);
const dailyExpandButton = document.createElement('button'); dailyExpandButton.className = 'expand-button'; dailyExpandButton.textContent = '일별 통계 보기 ▾'; wrapper.appendChild(dailyExpandButton);
const dailyStatsContainer = document.createElement('div'); dailyStatsContainer.id = 'daily-stats-container'; dailyStatsContainer.style.display = 'none';
const tableData = streamerData?.table1;
if (tableData?.data) {
let tableHTML = `<table><thead><tr><th>${tableData.column_name.day}</th><th>${tableData.column_name.total_watch_time}</th></tr></thead><tbody>`;
tableData.data.forEach(row => { tableHTML += `<tr><td>${row.day}</td><td>${row.total_watch_time}</td></tr>`; });
dailyStatsContainer.innerHTML = tableHTML + '</tbody></table>';
}
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 = `<div class="section-title" style="margin-top:20px;">자주 본 카테고리</div>`;
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 `<div class="category-card" style="background-image: url(${imgUrl})"><div class="category-info"><div class="rank">#${cat.rank}</div><div>${cat.skey}</div><div class="percent">${percentage}%</div></div></div>`; };
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 = `<div class="section-title" style="margin-top: 20px;">기타 정보</div>`;
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?.bj_type){
const typeCanvas = createCard('스트리머 유형 분포');
const typeLabels = Object.keys(streamerData.barchart.bj_type).map(key => typeTranslations[key] || key);
activeCharts.push(new Chart(typeCanvas, { type: 'bar', data: { labels: typeLabels, datasets: [{ data: Object.values(streamerData.barchart.bj_type), backgroundColor: ['#ff6b6b', '#feca57', '#1dd1a1'] }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: '#efeff1' } }, y: {} } } }));
}
}
function mergeData(liveData, vodData, type) {
if (!liveData || liveData.result !== 1) return vodData || { result: 1, data: {} };
if (!vodData || vodData.result !== 1) return liveData || { result: 1, data: {} };
const merged = JSON.parse(JSON.stringify(liveData)); // Deep copy
if (type === 'streamer') {
const liveInfo = liveData.data.broad_cast_info.data;
const vodInfo = vodData.data.broad_cast_info.data;
const mergedInfo = merged.data.broad_cast_info.data;
if (vodInfo) {
mergedInfo.cumulative_watch_time += vodInfo.cumulative_watch_time;
mergedInfo.top_watch_time = Math.max(liveInfo.top_watch_time, vodInfo.top_watch_time);
// 평균 시청 시간은 누적 시간을 기준으로 재계산 (방문일수 데이터가 없으므로 단순 합산은 부정확)
// 여기서는 일단 누적 시간 합산에 집중합니다.
}
const streamerTotals = new Map();
// ▼▼▼ [버그 수정] s.nick -> s.bj_nick 으로 변경 ▼▼▼
liveData.data.chart.data_stack?.forEach(s => {
if (s.bj_nick !== '기타') streamerTotals.set(s.bj_nick, s.data.reduce((a, b) => a + b, 0));
});
vodData.data.chart.data_stack?.forEach(s => {
if (s.bj_nick !== '기타') streamerTotals.set(s.bj_nick, (streamerTotals.get(s.bj_nick) || 0) + s.data.reduce((a, b) => a + b, 0));
});
const sortedStreamers = Array.from(streamerTotals.entries()).sort((a, b) => b[1] - a[1]);
// renderAll 함수가 기대하는 data_stack 포맷으로 재구성
merged.data.chart.data_stack = sortedStreamers.map(([nick, total]) => ({
bj_nick: nick,
data: [total] // 합산된 총 시간을 data 배열에 넣음
}));
} else if (type === 'category') {
const categoryTotals = new Map();
liveData.data.table2?.data?.forEach(c => categoryTotals.set(c.skey, parseInt(c.cnt, 10)));
vodData.data.table2?.data?.forEach(c => categoryTotals.set(c.skey, (categoryTotals.get(c.skey) || 0) + parseInt(c.cnt, 10)));
const sortedCategories = Array.from(categoryTotals.entries()).sort((a, b) => b[1] - a[1]);
merged.data.table2.data = sortedCategories.map(([skey, cnt], index) => ({
rank: index + 1,
skey: skey,
cnt: String(cnt)
}));
}
return merged;
}
// --- 이벤트 핸들러 함수 ---
async function handleFetchButtonClick() {
const loader = document.getElementById('recap-loader');
const wrapper = document.getElementById('recap-content-wrapper');
loader.style.display = 'block';
wrapper.innerHTML = '';
wrapper.style.display = 'none';
activeCharts.forEach(chart => chart.destroy());
activeCharts = [];
try {
const userInfo = await getUserInfo();
const typeSelector = document.getElementById('recap-type-selector');
const monthSelector = document.getElementById('recap-month-selector');
const selectedType = typeSelector.value;
const [year, month] = monthSelector.value.split('-').map(Number);
const today = new Date();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth() + 1;
let startDate, endDate;
if (year === currentYear && month === currentMonth) {
startDate = new Date(year, month - 1, 1);
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
endDate = yesterday;
} else {
startDate = new Date(year, month - 1, 1);
endDate = new Date(year, month, 0);
}
const formattedStartDate = formatDate(startDate);
const formattedEndDate = formatDate(endDate);
const modules = {
live: { streamer: 'UserLiveWatchTimeData', category: 'UserLiveSearchKeywordData' },
vod: { streamer: 'UserVodWatchTimeData', category: 'UserVodSearchKeywordData' }
};
if (selectedType === 'live' || selectedType === 'vod') {
const [streamerResponse, categoryResponse, categoryImages] = await Promise.all([
fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules[selectedType].streamer),
fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules[selectedType].category),
getCategoryImageMap()
]);
if (streamerResponse.result === 1 && categoryResponse.result === 1) {
await renderAll(streamerResponse, categoryResponse, userInfo, categoryImages);
} else {
wrapper.innerHTML = `<p style="text-align: center;">데이터를 불러오는 데 실패했습니다.</p>`;
}
} else if (selectedType === 'combined') {
const [liveStreamer, liveCategory, vodStreamer, vodCategory, categoryImages] = await Promise.all([
fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules.live.streamer),
fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules.live.category),
fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules.vod.streamer),
fetchData(userInfo.id, formattedStartDate, formattedEndDate, modules.vod.category),
getCategoryImageMap()
]);
const mergedStreamerData = mergeData(liveStreamer, vodStreamer, 'streamer');
const mergedCategoryData = mergeData(liveCategory, vodCategory, 'category');
await renderAll(mergedStreamerData, mergedCategoryData, userInfo, categoryImages);
}
} catch (error) {
console.error("[리캡 스크립트] Error:", error);
wrapper.innerHTML = `<p style="text-align: center;">오류 발생: ${error.message}</p>`;
} finally {
loader.style.display = 'none';
wrapper.style.display = 'block';
}
}
async function captureScreenshot(options = {}) {
const modalBody = document.querySelector('.recap-modal-body');
const modalPanel = document.getElementById('recap-modal-panel');
const button = document.getElementById('recap-screenshot-btn');
const originalButtonContent = button.innerHTML;
button.innerHTML = '...';
button.disabled = true;
// --- 원본 스타일 및 요소 상태 저장 ---
const originalPanelHeight = modalPanel.style.height;
const originalBodyOverflow = modalBody.style.overflowY;
const cardElements = modalBody.querySelectorAll('.top-streamers-container .streamer-card');
const originalCardStyles = [];
const profileHeader = modalBody.querySelector('.recap-profile-header');
let originalProfileDisplay = '';
// --- 스크린샷용 임시 요소 생성 및 수정 ---
const typeSelector = document.getElementById('recap-type-selector');
const monthSelector = document.getElementById('recap-month-selector');
const selectedType = typeSelector.value;
const selectedTypeText = typeSelector.options[typeSelector.selectedIndex].text;
const screenshotTitle = document.createElement('div');
screenshotTitle.id = 'screenshot-title-temp';
// [수정] 제목에 데이터 타입을 괄호로 묶어 바로 추가합니다.
screenshotTitle.textContent = `${monthSelector.options[monthSelector.selectedIndex].text} 시청 요약 (${selectedTypeText})`;
// --- 스크린샷 전처리 ---
cardElements.forEach((el, i) => {
const bgChild = el.querySelector('.streamer-card-bg');
originalCardStyles.push({ card: el, bgChild: bgChild, cardBg: el.style.background, childDisplay: bgChild?.style.display });
el.style.background = screenshotGradientPalette[i % screenshotGradientPalette.length];
if (bgChild) bgChild.style.display = 'none';
});
if (options.hideProfile && profileHeader) {
originalProfileDisplay = profileHeader.style.display;
profileHeader.style.display = 'none';
}
try {
modalBody.prepend(screenshotTitle); // 수정된 제목 추가
modalPanel.style.height = 'auto';
modalBody.style.overflowY = 'visible';
const canvas = await html2canvas(modalBody, {
allowTaint: true, useCORS: true,
backgroundColor: '#18181b', logging: false,
});
const link = document.createElement('a');
const date = new Date();
const timestamp = `${date.getFullYear()}${(date.getMonth()+1).toString().padStart(2,'0')}${date.getDate().toString().padStart(2,'0')}`;
link.download = `recap-${selectedType}-${timestamp}.png`;
link.href = canvas.toDataURL("image/png");
link.click();
} catch(err) {
console.error("스크린샷 생성 오류:", err);
alert("스크린샷 생성에 실패했습니다.");
} finally {
// --- 모든 변경사항 원래대로 복구 ---
button.innerHTML = originalButtonContent;
button.disabled = false;
modalPanel.style.height = originalPanelHeight;
modalBody.style.overflowY = originalBodyOverflow;
originalCardStyles.forEach(item => {
item.card.style.background = item.cardBg;
if (item.bgChild) item.bgChild.style.display = item.childDisplay;
});
if (options.hideProfile && profileHeader) {
profileHeader.style.display = originalProfileDisplay;
}
screenshotTitle.remove(); // 임시 제목 요소 제거
}
}
function createRecapModule() {
// 이미 UI가 생성되었다면 함수를 즉시 종료하여 중복 생성을 방지합니다.
if (recapModalBackdrop) {
console.warn("Recap module UI is already created. Skipping creation.");
return;
}
// --- 1. 스타일(CSS) 주입 ---
GM_addStyle(`
#recap-modal-backdrop { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.6); z-index: 10000; display: flex; justify-content: center; align-items: center; }
#recap-modal-panel { background-color: #18181b; color: #efeff1; width: 90%; max-width: 1000px; height: 90vh; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); display: flex; flex-direction: column; overflow: hidden; }
.recap-modal-header { padding: 15px 25px; border-bottom: 1px solid #4f4f54; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; }
.recap-modal-header h1 { margin: 0; font-size: 20px; color: #5dade2; }
#recap-screenshot-btn, #recap-modal-close-btn { background: none; border: none; color: #efeff1; font-size: 24px; cursor: pointer; width: 36px; height: 36px; display: grid; place-items: center; border-radius: 50%; }
#recap-screenshot-btn:hover, #recap-modal-close-btn:hover { background-color: #2e2e33; }
.recap-modal-header-buttons { display: flex; align-items: center; gap: 10px; }
.recap-modal-controls { display: flex; justify-content: center; align-items: center; gap: 15px; padding: 20px; border-bottom: 1px solid #4f4f54; flex-shrink: 0; }
.recap-modal-controls select, .recap-modal-controls button { padding: 10px 15px; border-radius: 6px; border: 1px solid #4f4f54; background-color: #2e2e33; color: #efeff1; font-size: 16px; }
.recap-modal-controls button { background-color: #5dade2; border-color: #5dade2; cursor: pointer; }
.recap-modal-controls button:hover { background-color: #4a9fce; }
.recap-modal-body { padding: 20px; overflow-y: auto; flex-grow: 1; }
#recap-loader { text-align: center; padding: 40px; font-size: 18px; }
.recap-profile-header { display: flex; align-items: center; gap: 20px; margin-bottom: 20px; }
.profile-pic { width: 70px; height: 70px; border-radius: 50%; border: 3px solid #5dade2; }
.profile-name { font-size: 24px; font-weight: bold; }
.key-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; }
.stat-card { border-radius: 8px; padding: 20px; text-align: center; color: #333; }
.stat-card.time { background: linear-gradient(135deg, #ff9a9e 0%, #fad0c4 100%); }
.stat-card.days { background: linear-gradient(135deg, #a1c4fd 0%, #c2e9fb 100%); }
.stat-card .label { font-size: 14px; opacity: 0.8; }
.stat-card .value { font-size: 48px; font-weight: bold; line-height: 1.1; }
.stat-card .unit { font-size: 24px; margin-left: 5px; }
.section-title { font-size: 20px; font-weight: bold; margin-bottom: 15px; }
.top-streamers-container { display: flex; gap: 15px; height: 320px; margin-bottom: 15px; }
.streamer-card { border-radius: 8px; padding: 15px; position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: center; align-items: center; transition: background-color 0.3s; }
.streamer-card-bg { position: absolute; top: -10%; left: -10%; width: 120%; height: 120%; background-size: cover; background-position: center; filter: blur(8px) brightness(0.7); z-index: 1; transition: all 0.2s; }
.streamer-card-content { z-index: 2; color: white; text-align: center; }
.streamer-card-avatar { border-radius: 50%; border: 2px solid white; transition: all 0.2s; }
.streamer-card-name, .streamer-card-time { text-shadow: 1px 1px 4px rgba(0,0,0,0.8); }
.streamer-card-name { font-weight: bold; transition: all 0.2s; }
.streamer-card-time { opacity: 0.9; transition: all 0.2s; }
.streamer-col-1 { flex: 2; }
.streamer-col-2 { flex: 1; }
.streamer-col-3 { flex: 1; display: flex; flex-direction: column; gap: 15px; }
.streamer-col-3 .streamer-card { flex: 1; }
.streamer-col-1 .streamer-card-avatar, .streamer-col-2 .streamer-card-avatar { width: 100px; height: 100px; margin-bottom: 10px; }
.streamer-col-1 .streamer-card-name { font-size: 40px; } .streamer-col-1 .streamer-card-time { font-size: 25px; margin-top: 5px; }
.streamer-col-2 .streamer-card-name { font-size: 30px; } .streamer-col-2 .streamer-card-time { font-size: 20px; margin-top: 5px; }
.streamer-col-3 .streamer-card-avatar { width: 70px; height: 70px; margin-bottom: 5px; }
.streamer-col-3 .streamer-card-name { font-size: 20px; } .streamer-col-3 .streamer-card-time { font-size: 16px; }
.category-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; }
.category-card { width: 180px; height: 240px; border-radius: 8px; background-size: cover; background-position: center; position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: flex-end; padding: 10px; background-color: #2e2e33; }
.category-card::after { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 50%); border-radius: 8px;}
.category-info { z-index: 2; color: white; font-weight: bold; font-size: 14px; text-shadow: 1px 1px 3px rgba(0,0,0,0.7); }
.category-info .rank { font-size: 18px; }
.category-info .percent { font-size: 12px; opacity: 0.8; }
.expand-button { width: 100%; padding: 10px; background-color: #2e2e33; color: #efeff1; border: 1px solid #4f4f54; border-radius: 6px; cursor: pointer; margin-top: 15px; margin-bottom: 10px; }
#full-ranking-chart-container { margin-bottom: 20px; }
#daily-stats-container table { width: 100%; border-collapse: collapse; }
#daily-stats-container th, #daily-stats-container td { text-align: left; padding: 12px; border-bottom: 1px solid #4f4f54; }
#daily-stats-container th { background-color: #3a3a40; }
.recap-container { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.recap-card { background-color: #2e2e33; border-radius: 8px; padding: 20px; display: flex; flex-direction: column; min-height: 300px; }
.recap-card h2 { flex-shrink: 0; margin-top: 0; border-bottom: 1px solid #4f4f54; padding-bottom: 10px; }
.chart-wrapper { position: relative; flex-grow: 1; min-height: 0; }
#screenshot-title-temp { font-size: 24px; font-weight: bold; text-align: center; margin-bottom: 20px; color: #efeff1; }
.streamer-card { border-radius: 8px; padding: 15px; position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: center; align-items: center; transition: background-color 0.3s; }
.streamer-card-bg { position: absolute; top: -10%; left: -10%; width: 120%; height: 120%; background-size: cover; background-position: center; filter: blur(8px) brightness(0.7); z-index: 1; transition: all 0.2s; }
.streamer-card-content { z-index: 2; color: white; text-align: center; }
.streamer-card-avatar { border-radius: 50%; border: 2px solid white; transition: all 0.2s; }
.streamer-card-name, .streamer-card-time { text-shadow: 1px 1px 4px rgba(0,0,0,0.8); }
.streamer-card-name { font-weight: bold; transition: all 0.2s; }
.streamer-card-time { opacity: 0.9; transition: all 0.2s; }
.streamer-col-1 { flex: 2; }
.streamer-col-2 { flex: 1; }
.streamer-col-3 { flex: 1; display: flex; flex-direction: column; gap: 15px; }
.streamer-col-3 .streamer-card { flex: 1; }
.streamer-col-1 .streamer-card-avatar, .streamer-col-2 .streamer-card-avatar { width: 100px; height: 100px; margin-bottom: 10px; }
.streamer-col-1 .streamer-card-name { font-size: 40px; } .streamer-col-1 .streamer-card-time { font-size: 25px; margin-top: 5px; }
.streamer-col-2 .streamer-card-name { font-size: 30px; } .streamer-col-2 .streamer-card-time { font-size: 20px; margin-top: 5px; }
.streamer-col-3 .streamer-card-avatar { width: 70px; height: 70px; margin-bottom: 5px; }
.streamer-col-3 .streamer-card-name { font-size: 20px; } .streamer-col-3 .streamer-card-time { font-size: 16px; }
@keyframes rank1-avatar-glow { 0% { box-shadow: 0 0 10px 2px rgba(255, 215, 0, 0.5); } 50% { box-shadow: 0 0 16px 5px rgba(255, 235, 150, 0.7); } 100% { box-shadow: 0 0 10px 2px rgba(255, 215, 0, 0.5); } }
.streamer-card[data-rank="1"] .streamer-card-avatar { border-color: #ffbf00c9; animation: rank1-avatar-glow 5s ease-in-out infinite; }
`);
// --- 2. UI 요소 생성 및 DOM에 추가 ---
recapModalBackdrop = document.createElement('div');
recapModalBackdrop.id = 'recap-modal-backdrop';
// [수정] 데이터 타입 선택 UI 추가
recapModalBackdrop.innerHTML = `
<div id="recap-modal-panel">
<div class="recap-modal-header"><h1>월별 방송 데이터 리캡</h1><div class="recap-modal-header-buttons"><button id="recap-screenshot-btn" title="스크린샷 다운로드 (우클릭: 닉네임 숨기기)">🖼️</button><button id="recap-modal-close-btn">×</button></div></div>
<div class="recap-modal-controls">
<select id="recap-type-selector">
<option value="live" selected>LIVE</option>
<option value="vod">VOD</option>
<option value="combined">LIVE + VOD</option>
</select>
<select id="recap-month-selector"></select>
<button id="recap-fetch-button">데이터 조회</button>
</div>
<div class="recap-modal-body"><div id="recap-loader" style="display: none;"><p>데이터를 불러오는 중입니다...</p></div><div id="recap-content-wrapper"><p style="text-align: center;">조회할 타입을 선택하고 '데이터 조회' 버튼을 눌러주세요.</p></div></div>
</div>`;
document.body.appendChild(recapModalBackdrop);
// --- 3. 초기 이벤트 리스너 연결 ---
const monthSelector = document.getElementById('recap-month-selector');
populateMonthSelector(monthSelector); // monthSelector를 인자로 전달하여 해당 select 요소에 옵션 채우기
document.getElementById('recap-modal-close-btn').addEventListener('click', () => { recapModalBackdrop.style.display = 'none'; });
const screenshotBtn = document.getElementById('recap-screenshot-btn');
// 기본 좌클릭: 프로필 포함하여 스크린샷
screenshotBtn.addEventListener('click', () => captureScreenshot());
// 우클릭: 프로필 숨기고 스크린샷
screenshotBtn.addEventListener('contextmenu', (e) => {
e.preventDefault(); // 브라우저 기본 우클릭 메뉴 방지
captureScreenshot({ hideProfile: true }); // hideProfile 옵션을 주어 호출
});
recapModalBackdrop.addEventListener('click', (e) => {
// 모달 배경 클릭 시 닫기
if (e.target === recapModalBackdrop) {
recapModalBackdrop.style.display = 'none';
}
});
document.getElementById('recap-fetch-button').addEventListener('click', handleFetchButtonClick);
// 모달을 기본적으로 숨김 상태로 시작
recapModalBackdrop.style.display = 'none';
};
function populateMonthSelector(selectorElement) {
selectorElement.innerHTML = '';
const today = new Date();
const limitDate = new Date();
limitDate.setDate(today.getDate() - 90); // 90일(3개월) 제한
for (let i = 0; i < 12; i++) { // 최대 12개월 전까지
const dateOption = new Date(today.getFullYear(), today.getMonth() - i, 1);
const lastDayOfMonth = new Date(dateOption.getFullYear(), dateOption.getMonth() + 1, 0);
// 현재 날짜로부터 90일 이내의 월만 표시
if (lastDayOfMonth < limitDate) {
break;
}
const year = dateOption.getFullYear();
const month = dateOption.getMonth() + 1;
const option = document.createElement('option');
option.value = `${year}-${String(month).padStart(2, '0')}`;
option.textContent = `${year}년 ${month}월`;
selectorElement.appendChild(option);
}
};
function toggleRecapModule(forceShow = false) {
// UI가 아직 생성되지 않았다면 안전하게 먼저 생성합니다.
if (!recapModalBackdrop) {
console.log("Recap module UI not found, creating it now.");
createRecapModule();
}
// 모달의 현재 표시 상태를 확인합니다.
const isModalVisible = recapModalBackdrop.style.display === 'flex';
if (forceShow || !isModalVisible) {
// 모달을 열거나 강제로 열어야 하는 경우:
recapModalBackdrop.style.display = 'flex'; // 모달 표시
// 컨텐츠 영역 초기화 및 로더 숨김
const wrapper = document.getElementById('recap-content-wrapper');
const loader = document.getElementById('recap-loader');
wrapper.innerHTML = `<p style="text-align: center;">조회할 월을 선택하고 '데이터 조회' 버튼을 눌러주세요.</p>`;
wrapper.style.display = 'block';
loader.style.display = 'none';
// 이전에 생성된 Chart.js 인스턴스가 있다면 모두 파괴하여 메모리 누수 방지
activeCharts.forEach(chart => chart.destroy());
activeCharts = [];
console.log("Recap module opened and initialized.");
} else {
// 모달을 닫아야 하는 경우:
recapModalBackdrop.style.display = 'none';
console.log("Recap module closed.");
}
};
function createRecapMenuButton() {
const targetMenu = document.querySelector('#userArea ul.menuList:nth-child(1)');
if (!targetMenu || targetMenu.querySelector('a.my_recap')) {
return;
}
if (!document.getElementById('recap-menu-icon-style')) {
const styleEl = document.createElement('style');
styleEl.id = 'recap-menu-icon-style';
styleEl.textContent = `
#userArea .menuList li a.my_recap::before {
content: ''; display: inline-block; width: 24px; height: 24px; margin-right: 12px;
vertical-align: middle;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%235dade2' d='M4 9h4v11H4zM10 4h4v16h-4zM16 12h4v8h-4z'/%3E%3C/svg%3E");
background-position: 50% 50%; background-repeat: no-repeat; background-size: 100% 100%;
}
`;
document.head.appendChild(styleEl);
}
const listItem = document.createElement('li');
const link = document.createElement('a');
link.href = '#';
link.className = 'my_recap';
link.innerHTML = '<span>월별 리캡</span>';
listItem.appendChild(link);
targetMenu.appendChild(listItem);
};
const observeAndAppendRecapButton = async () => {
document.body.addEventListener('click', async (e) => {
const recapLink = e.target.closest('a.my_recap');
if (!recapLink) return;
e.preventDefault();
if (recapInitialized) { // 두번째 이상 실행
toggleRecapModule();
return;
}
const span = recapLink.querySelector('span');
const originalText = span.textContent;
span.textContent = '로딩 중...';
try { // 첫번째 실행
await loadScript('https://cdn.jsdelivr.net/npm/chart.js');
await loadScript('https://html2canvas.hertzen.com/dist/html2canvas.min.js');
createRecapModule();
recapInitialized = true;
toggleRecapModule();
} catch (error) {
alert('리캡 기능에 필요한 라이브러리를 로드하는 데 실패했습니다.');
console.error('Recap Script Load Error:', error);
} finally {
span.textContent = originalText;
}
});
// --- DOM 변경을 감시하여 버튼이 사라지면 다시 생성하는 로직 ---
const parentSelector = '#logArea';
const targetSelector = await waitForElementAsync(parentSelector);
if (targetSelector) {
const handleLogAreaChange = async () => {
const userAreaSelector = await waitForElementAsync('#userArea ul.menuList:nth-child(1)');
if (userAreaSelector) {
createRecapMenuButton();
}
};
observeElementChanges(parentSelector, handleLogAreaChange);
}
}
// 3.5. 이벤트 핸들러 및 옵저버 (Event Handlers & Observers)
const checkSidebarVisibility = () => {
let intervalId = null;
let lastExecutionTime = Date.now(); // 마지막 실행 시점 기록
const handleVisibilityChange = () => {
const body = document.body;
const isScreenmode = body.classList.contains('screen_mode');
const isShowSidebar = body.classList.contains('showSidebar');
const isFullScreenmode = body.classList.contains('fullScreen_mode');
const isSidebarHidden = (isScreenmode ? !isShowSidebar : false) || isFullScreenmode;
const webplayer = document.getElementById('webplayer');
const webplayerStyle = webplayer?.style;
const sidebar = document.getElementById('sidebar');
// 스크린 모드에서 사이드바 항상 보이는 옵션
if (webplayer && isScreenmode && showSidebarOnScreenModeAlways && !isShowSidebar) {
body.classList.add('showSidebar');
webplayer.style.left = '0px';
webplayer.style.left = sidebar.offsetWidth + 'px';
webplayer.style.width = `calc(100vw - ${sidebar.offsetWidth}px)`;
}
// 사이드바가 보이는 상태에서 스크린 모드 종료할 때
if (webplayer && !isScreenmode && isShowSidebar) {
body.classList.remove('showSidebar');
webplayerStyle.removeProperty('width');
webplayerStyle.removeProperty('left');
}
if (document.visibilityState === 'visible' && isSidebarHidden) {
console.log('#sidebar는 숨겨져 있음');
return;
}
const currentTime = Date.now();
const timeSinceLastExecution = (currentTime - lastExecutionTime) / 1000; // 초 단위로 변환
if (document.visibilityState === 'visible' && timeSinceLastExecution >= 60) {
console.log('탭 활성화됨');
generateBroadcastElements(1);
lastExecutionTime = currentTime; // 갱신 시점 기록
restartInterval(); // 인터벌 재시작
} else if (document.visibilityState === 'visible') {
console.log('60초 미만 경과: 방송 목록 갱신하지 않음');
} else {
console.log(`탭 비활성화됨: 마지막 갱신 = ${parseInt(timeSinceLastExecution)}초 전`);
}
};
const restartInterval = () => {
if (intervalId) clearInterval(intervalId); // 기존 인터벌 중단
intervalId = setInterval(() => {
handleVisibilityChange();
}, 60 * 1000); // 60초마다 실행
};
(async () => {
const sidebarDiv = await waitForElementAsync('#sidebar');
observeClassChanges('body', handleVisibilityChange);
restartInterval(); // 인터벌 시작
document.addEventListener('visibilitychange', handleVisibilityChange);
})();
};
const processStreamers = () => {
const processedLayers = new Set(); // 처리된 레이어를 추적
const createStreamDownloadButton_MainPage = (listItem, optionsLayer) => {
const downloadButton = document.createElement('button'); // "스트림 다운로드" 버튼 생성
downloadButton.type = 'button';
const span = document.createElement('span');
span.textContent = '스트림 다운로드';
downloadButton.appendChild(span);
// 클릭 이벤트 추가
downloadButton.addEventListener('click', () => {
const userIdElement = listItem.querySelector('.cBox-info .title a');
if (userIdElement) {
const urlParts = userIdElement.href.split('/');
const id = urlParts[3];
const broadNumber = urlParts[4];
if (id) {
if (id === "player") alert('VOD는 아직 지원하지 않습니다.');
openStreamDownloader(id, broadNumber); // 스트리밍 다운로드 함수 호출
} else {
console.log("ID를 찾을 수 없습니다.");
}
} else {
console.log("userIdElement 또는 broadNumber를 찾을 수 없습니다.");
}
});
// 리스트 맨 앞에 삽입
optionsLayer.insertBefore(downloadButton, optionsLayer.firstChild);
};
// 버튼 생성 및 클릭 이벤트 처리
const createHideButton = (listItem, optionsLayer) => {
const hideButton = document.createElement('button'); // "숨기기" 버튼 생성
hideButton.type = 'button';
const span = document.createElement('span');
span.textContent = '이 브라우저에서 스트리머 숨기기';
hideButton.appendChild(span);
// 클릭 이벤트 추가
hideButton.addEventListener('click', () => {
const userNameElement = listItem.querySelector('a.nick > span'); // 사용자 이름 요소
const userIdElement = listItem.querySelector('.cBox-info > a'); // 사용자 ID 요소
if (userNameElement && userIdElement) {
const userId = userIdElement.href.split('/')[3]; // 사용자 ID 추출
const userName = userNameElement.innerText; // 사용자 이름 추출
console.log(`Blocking user: ${userName}, ID: ${userId}`); // 로그 추가
if (userId && userName) {
blockUser(userName, userId); // 사용자 차단 함수 호출
listItem.style.display = 'none';
}
} else {
console.log("User elements not found."); // 요소가 없을 경우 로그 추가
}
});
optionsLayer.appendChild(hideButton); // 옵션 레이어에 버튼 추가
};
/* */
const createCategoryHideButton = (listItem, optionsLayer) => {
const hideButton = document.createElement('button'); // "숨기기" 버튼 생성
hideButton.type = 'button';
const span = document.createElement('span');
span.textContent = '이 브라우저에서 해당 카테고리 숨기기';
hideButton.appendChild(span);
// 클릭 이벤트 추가 [data-type=cBox] .cBox-info .tag_wrap a.category
hideButton.addEventListener('click', () => {
const categoryElement = listItem.querySelector('.cBox-info .tag_wrap a.category');
if (categoryElement) {
const categoryName = categoryElement.textContent;
const categoryNo = getCategoryNo(categoryName);
if (categoryName && categoryNo) {
blockCategory(categoryName, categoryNo);
}
} else {
console.log("User elements not found."); // 요소가 없을 경우 로그 추가
}
});
optionsLayer.appendChild(hideButton); // 옵션 레이어에 버튼 추가
}
// DOM 변경 감지 및 처리
const handleDOMChange = (mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
const moreOptionsContainer = document.querySelector('div._moreDot_wrapper'); // 추가 옵션 컨테이너
const optionsLayer = moreOptionsContainer ? moreOptionsContainer.querySelector('div._moreDot_layer') : null; // 옵션 레이어
if (optionsLayer && optionsLayer.style.display !== 'none' && !processedLayers.has(optionsLayer)) {
const activeButton = document.querySelector('button.more_dot.on'); // 활성화된 버튼
const listItem = activeButton.closest('li[data-type="cBox"]'); // 가장 가까운 리스트 아이템 찾기
if (listItem) {
if (isStreamDownloadEnabled) createStreamDownloadButton_MainPage(listItem, optionsLayer);
createHideButton(listItem, optionsLayer); // 숨기기 버튼 생성
createCategoryHideButton(listItem, optionsLayer);
processedLayers.add(optionsLayer); // 이미 처리된 레이어로 추가
}
} else if (!optionsLayer) {
processedLayers.clear(); // 요소가 없을 때 처리된 레이어 초기화
}
// cBox-list의 리스트 아이템 처리
const cBoxListItems = document.querySelectorAll('div.cBox-list li[data-type="cBox"]:not(.hide-checked)');
// cBoxListItems를 for...of 루프로 반복
for (const listItem of cBoxListItems) {
listItem.classList.add('hide-checked');
const userIdElement = listItem.querySelector('.cBox-info > a'); // 사용자 ID 요소
const categoryElement = listItem.querySelector('.cBox-info .tag_wrap a.category');
const titleElement = listItem.querySelector('.cBox-info .title a');
if (userIdElement) {
const userId = userIdElement.href.split('/')[3]; // 사용자 ID 추출
// 차단된 사용자일 경우 li 삭제
if (isUserBlocked(userId)) {
listItem.style.display = 'none';
console.log(`Removed blocked user with ID: ${userId}`); // 로그 추가
}
}
if (categoryElement) {
const categoryName = categoryElement.textContent;
if (isCategoryBlocked(getCategoryNo(categoryName))) {
listItem.style.display = 'none';
console.log(`Removed blocked category with Name: ${categoryName}`); // 로그 추가
}
}
if (titleElement) {
const broadTitle = titleElement.textContent;
// blockedWords에 포함된 단어가 broadTitle에 있는지 체크
for (const word of blockedWords) {
if (broadTitle.toLowerCase().includes(word.toLowerCase())) {
listItem.style.display = 'none';
console.log(`Removed item with blocked word in title: ${broadTitle}`); // 로그 추가
break; // 하나의 차단 단어가 발견되면 더 이상 확인할 필요 없음
}
}
}
}
// 외부 재생기 사용
if (isOpenExternalPlayerEnabled) {
const allThumbsBoxLinks = document.querySelectorAll('[data-type=cBox] .thumbs-box > a[href]:not([href^="https://vod.sooplive.co.kr"])');
if (allThumbsBoxLinks.length) makeExternalLinks(allThumbsBoxLinks);
}
// 빈 썸네일 대체
if (isReplaceEmptyThumbnailEnabled){
const noThumbsBoxLinks = document.querySelectorAll('[data-type=cBox] .thumbs-box > a[href].thumb-adult:not([href^="https://vod.sooplive.co.kr"])');
if (noThumbsBoxLinks.length) replaceThumbnails(noThumbsBoxLinks);
}
}
}
};
const observer = new MutationObserver(handleDOMChange); // DOM 변경 감지기
// 감지할 옵션 설정
const config = { childList: true, subtree: true };
// 관찰 시작
observer.observe(document.body, config);
};
const observeChat = (elementSelector, elem) => {
hideBadges();
if (!isBlockWordsEnabled) return;
if (REG_WORDS.length === 0) return;
const observer = new MutationObserver((mutations) => {
mutations.forEach(({ addedNodes }) => {
addedNodes.forEach(node => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
// 메시지 요소를 직접 감지하거나 하위에서 찾음
const message = node.matches?.('div.message-text > p.msg')
? node
: node.querySelector?.('div.message-text > p.msg');
if (message) {
deleteMessages([message]);
}
});
});
});
observer.observe(elem, { childList: true, subtree: true });
};
const deleteMessages = (messages) => {
if (!Array.isArray(messages) || messages.length === 0) return;
// 필터 단어가 없으면 아무것도 하지 않음
if (REG_WORDS.length === 0) return;
for (const message of messages) {
const messageText = message.textContent.trim();
let shouldRemove = false;
for (const word of REG_WORDS) {
const isExactCheck = word.startsWith("e:");
const wordToCheck = isExactCheck ? word.slice(2) : word;
if ((isExactCheck && messageText === wordToCheck) ||
(!isExactCheck && messageText.includes(wordToCheck))) {
shouldRemove = true;
break;
}
}
if (shouldRemove) {
const listItem = message.closest('.chatting-list-item');
if (listItem) {
listItem.remove();
}
}
}
};
const setupChatMessageTrackers = (element) => {
const OriginalWebSocket = window.WebSocket;
const targetUrlPattern = /^wss:\/\/chat-[\w\d]+\.sooplive\.co\.kr/;
const MAX_MESSAGES = 500;
const messageHistory = [];
const bannedMessages = [];
const targetUserMessages = [];
let bannedWindow = null;
let targetWindow = null;
let banIcon = null;
let highlightIcon = null;
const selectedUsersArray = selectedUsers ? selectedUsers.split(',').map(user_id => user_id.trim()).filter(Boolean) : [];
const targetUserIdSet = new Set([...allFollowUserIds, ...selectedUsersArray]);
const highlightPosition = isShowDeletedMessagesEnabled ? "40px" : "10px" ;
let totalChatCount = 0;
let lastChatCount = 0;
let last10Intervals = [];
if (isChatCounterEnabled) {
// 채팅 속도 디스플레이
// 1. CPS 표시용 div 생성 및 삽입
const container = document.querySelector('.chatting-item-wrap');
const cpsDisplay = document.createElement('div');
cpsDisplay.id = 'cps_display';
container.appendChild(cpsDisplay);
// 2. 스타일 적용
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'
});
// 3. CPS 계산 및 표시
setInterval(() => {
const delta = totalChatCount - lastChatCount;
lastChatCount = totalChatCount;
last10Intervals.push(delta);
if (last10Intervals.length > 10) {
last10Intervals.shift();
}
const sum = last10Intervals.reduce((a, b) => a + b, 0);
const avg = sum / 5;
cpsDisplay.textContent = `${Math.round(avg)}개/s`;
}, 500);
// 채팅 속도 디스플레이 끝
}
// 스타일 추가
GM_addStyle(`
#cps_display {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 14px;
padding: 4px 8px;
border-radius: 4px;
z-index: 10;
pointer-events: none;
}
.chat-icon {
position: absolute;
bottom: 10px;
right: 6px;
width: 24px;
height: 24px;
cursor: pointer;
z-index: 1000;
background-size: contain;
background-repeat: no-repeat;
}
.chat-icon.highlight {
right: 7px;
width: 22px;
height: 22px;
bottom: ${highlightPosition};
}
html:not([dark="true"]) .trash-icon {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2264%22%20height%3D%2264%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20stroke%3D%22%23000%22%20stroke-width%3D%220%22%3E%3Cg%2F%3E%3Cg%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke%3D%22%23CCC%22%20stroke-width%3D%22.192%22%2F%3E%3Cg%20fill%3D%22%236A6A75%22%20stroke%3D%22none%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M10.31%202.25h3.38c.217%200%20.406%200%20.584.028a2.25%202.25%200%200%201%201.64%201.183c.084.16.143.339.212.544l.111.335.03.085a1.25%201.25%200%200%200%201.233.825h3a.75.75%200%200%201%200%201.5h-17a.75.75%200%200%201%200-1.5h3.09a1.25%201.25%200%200%200%201.173-.91l.112-.335c.068-.205.127-.384.21-.544a2.25%202.25%200%200%201%201.641-1.183c.178-.028.367-.028.583-.028Zm-1.302%203a3%203%200%200%200%20.175-.428l.1-.3c.091-.273.112-.328.133-.368a.75.75%200%200%201%20.547-.395%203%203%200%200%201%20.392-.009h3.29c.288%200%20.348.002.392.01a.75.75%200%200%201%20.547.394c.021.04.042.095.133.369l.1.3.039.112q.059.164.136.315z%22%2F%3E%3Cpath%20d%3D%22M5.915%208.45a.75.75%200%201%200-1.497.1l.464%206.952c.085%201.282.154%202.318.316%203.132.169.845.455%201.551%201.047%202.104s1.315.793%202.17.904c.822.108%201.86.108%203.146.108h.879c1.285%200%202.324%200%203.146-.108.854-.111%201.578-.35%202.17-.904.591-.553.877-1.26%201.046-2.104.162-.813.23-1.85.316-3.132l.464-6.952a.75.75%200%200%200-1.497-.1l-.46%206.9c-.09%201.347-.154%202.285-.294%202.99-.137.685-.327%201.047-.6%201.303-.274.256-.648.422-1.34.512-.713.093-1.653.095-3.004.095h-.774c-1.35%200-2.29-.002-3.004-.095-.692-.09-1.066-.256-1.34-.512-.273-.256-.463-.618-.6-1.302-.14-.706-.204-1.644-.294-2.992z%22%2F%3E%3Cpath%20d%3D%22M9.425%2010.254a.75.75%200%200%201%20.821.671l.5%205a.75.75%200%200%201-1.492.15l-.5-5a.75.75%200%200%201%20.671-.821m5.15%200a.75.75%200%200%201%20.671.82l-.5%205a.75.75%200%200%201-1.492-.149l.5-5a.75.75%200%200%201%20.82-.671Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
html[dark="true"] .trash-icon {
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2264%22%20height%3D%2264%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20stroke%3D%22%23000%22%20stroke-width%3D%220%22%3E%3Cg%2F%3E%3Cg%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke%3D%22%23CCC%22%20stroke-width%3D%22.192%22%2F%3E%3Cg%20fill%3D%22%2394949C%22%20stroke%3D%22none%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M10.31%202.25h3.38c.217%200%20.406%200%20.584.028a2.25%202.25%200%200%201%201.64%201.183c.084.16.143.339.212.544l.111.335.03.085a1.25%201.25%200%200%200%201.233.825h3a.75.75%200%200%201%200%201.5h-17a.75.75%200%200%201%200-1.5h3.09a1.25%201.25%200%200%200%201.173-.91l.112-.335c.068-.205.127-.384.21-.544a2.25%202.25%200%200%201%201.641-1.183c.178-.028.367-.028.583-.028Zm-1.302%203a3%203%200%200%200%20.175-.428l.1-.3c.091-.273.112-.328.133-.368a.75.75%200%200%201%20.547-.395%203%203%200%200%201%20.392-.009h3.29c.288%200%20.348.002.392.01a.75.75%200%200%201%20.547.394c.021.04.042.095.133.369l.1.3.039.112q.059.164.136.315z%22%2F%3E%3Cpath%20d%3D%22M5.915%208.45a.75.75%200%201%200-1.497.1l.464%206.952c.085%201.282.154%202.318.316%203.132.169.845.455%201.551%201.047%202.104s1.315.793%202.17.904c.822.108%201.86.108%203.146.108h.879c1.285%200%202.324%200%203.146-.108.854-.111%201.578-.35%202.17-.904.591-.553.877-1.26%201.046-2.104.162-.813.23-1.85.316-3.132l.464-6.952a.75.75%200%200%200-1.497-.1l-.46%206.9c-.09%201.347-.154%202.285-.294%202.99-.137.685-.327%201.047-.6%201.303-.274.256-.648.422-1.34.512-.713.093-1.653.095-3.004.095h-.774c-1.35%200-2.29-.002-3.004-.095-.692-.09-1.066-.256-1.34-.512-.273-.256-.463-.618-.6-1.302-.14-.706-.204-1.644-.294-2.992z%22%2F%3E%3Cpath%20d%3D%22M9.425%2010.254a.75.75%200%200%201%20.821.671l.5%205a.75.75%200%200%201-1.492.15l-.5-5a.75.75%200%200%201%20.671-.821m5.15%200a.75.75%200%200%201%20.671.82l-.5%205a.75.75%200%200%201-1.492-.149l.5-5a.75.75%200%200%201%20.82-.671Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
html:not([dark="true"]) .highlight-icon {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAsTAAALEwEAmpwYAAADyUlEQVR4nO1YTWhdVRA+/TH+Ba3gorgQFBS0ulEsqItXFCWLmuZ8k0GSvDtzo/C0YFu0+IfIU7rQnSKCIIo7XYmRIiL+oCsRBbFimwr+ddOVWKy2aovK5OYl552Q9+59P7kp3A8O5OWe+c7MnJk5c45zFSpUqFBhLTA9PX3Z5KTMAjoHyDygfwByGtAfAX3Te/HMvKkbj80hSgHIW5nsAodxzRPJO4Cmu3bploEpzswXAvIkICeI9L/OQ44wJ7euxkWU3mZzcvD8BugTqnpBX8qPj993BaBfdl+wbZwxg51zG1o8zWZzI6BP2bciXIB+YTr0rDyRHIsIvyHSvcyz19fr9YvNQ/Y3oPuJ5OfIiy+1uAB5Ofr2E5E8PDGRXGccxkUk2wDZB8ihaO6xwkZkYRN6Xv6anJQHzJOdZEzpyIvPEMmz0f9e7BQazWZzI5E+aGuGO1EonCwEQuWZk1p+WX109ZBIHsnLQ6Q7IiMey11twoQ1z+dddHlxeW2l8vJqUR5AdoeJnas6WakMY75T2KyGnTsbFxHpt4HyhyzEivIw86b2nEi0q5DV4sBze12PmJhIrl2s7UeA9JpeeQDZFzji7a4CRHq0JWAVxpUMItkWGDDfVQDQk8sG8KgrGcw8GoT0ya4Cdqy3BGZmZi5xJYO5cWlBAyxulzL/blcyvNexICcPdxWwclcoaYYMQOcCfV7pKkCkdwYW/8ss211JYNabTYdAnx155DYs9jytMPq4l7OgXzSzluKTwPtf5xb2Pr0jauL2uzUGVrQkBfORSN4IrP9nLUPJ+/QmQP8O1n+9MEnW4urhsAVm1q1uyGDWrVm7vaT8d9aa9EiW3gjoqSCUvhrm4cbMo7ZGsN4p7+s39EUKKAF6NtiJ92q12mY3YNRqtc3GHSh/1u7OAyG3ljpK6oNjY3vOHwi5c67RaJxnZ064hve6xw0SRHogMmKOmUf65WXmkfCwWhwH3DAQG0Ek7/fzcsDMI0T6bsT5ghsmsntu20580MtlxUIQ0INrqnwLRPp0tOUfFil1NheQj0pRvgUieTzaiU/ztN82h0g/i5R/zpWBxUv3UrNlNXxqauryTg8GRPJ5ZPjzrkwA8lC7EXaBX3liZyfs8kXfZPygS2WvIJJ6+GyYPdrOXt36zpxcSSTfh4cUoPe79QQgudeavkDJX+xlgrl+FZH8EHj+DKCJW4/wXsbbX9PkuI3g92kgvcetZ1D2JPh7/DJHJH96n97lzgV4n94CyK+B508Aers7l8AL99mFxD1qF5Sy9alQoUIFNxT8D3rbJQHjF4hlAAAAAElFTkSuQmCC");
}
html[dark="true"] .highlight-icon {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAAsTAAALEwEAmpwYAAAD1ElEQVR4nO2YXYhVVRTHd6NZ1tAH9CA9CAYJZb4oCdVDUhS9GFQPvSgWPViQiklmRJzCh3orIgijGFAo2Gf9z5mud85aZ6Y69SSSEBrlFGTli0+hqGkf0sSac+/Mvnvy3nvu5wjnBxcunLP+e6191tp77W1MSUlJSckgqFartwLyLJGMAzIN8AWALwF8EpBPgOQJa+2SVjr6ThTxk0T8aW47q3GhphkT8TNxnN3SM8ettcuJ+FWAzwIy0/zHJ6KI77uSVhjK/fpOKx0iPkMke8fGsuu7cn58fPJ2gL9p7XjD4P9owDMzM9fUdYIgGCGS1/RZQa0j6kPHzhPxKU/0GMA7rE3uPnAgvVFnSP8TyW5AfvUGf6+uRSTvNz6TX4B0VxRN3qUaqhWGvIaIdxLJcU/nVOEgNG3cmSeSPwHepjPZzEad9lLqDYDf9Cbh3WapEQTBSBTx8/mY81+iUDrlOT/vfBQlDxawfblJSrzUrk4Y8kYviD0FVhu3YHmbKQggH/2P8x8W1SGSF9zCbmt10qXSzflmaXMlKpXKDUT8nfMVj2uKFdWx1i5xayIM061tBMCxM/s7TIdE0cTq2tp+gmjyzk51iHinMxFow0B+rBvoCmOGTBjyGicjplsaAHx+PoBs1AwZa7NRJyPOtzSobeuzBgcPJjeZIWPt1M0FA9C8nSuaRwfiZRMAecxZiX4wrdDlrlDR9BnKm8e6Px+0NAjD5GEn4n+jSDaYIRHH6Xr1YT4jeGNLI23C8p5nLogvOtkLuiUIghGAv3T8+LZtYyB9yGu+dpsBQ15LUrgeARlzqv/vQaYSwOsA+csZ/+PCItriatW7LbC11RWmz1hbXZG323Oz/722Jh2JxfHEWkAuOnl4tJ+bm7XZqI7hOH8RSO/pSpRIngLksiM6kWXZUtNjsixbqtrOOJf17NwTcW2pvdb4UJIk1/VE3Bizf//Ra3XP8Vrw7aaXALLPW5nGrbXLutW11i5zN6vab5/pBwuDYO7m5sDmzn/mab5j+kl+zm0496adHFY0BYn40ECdrwPw694nnyqy1Om7gHw+FOfrAPKKVxNftdN+6zsAf+19xbfMMNBDt9ts6RpeqVRua3ZhQCSHvZl/2wwTInmxMQg9wC/csfMd1j3oz9psN4uBMEw2N14b8kmiqTvqz62dWgnwT+4mRZQ8ZxYTRPK0Nn1OEL/pzYS16SqAf/buTreYxQjAj7u3aYCcrv3qQV0iSjeZxUyYXwmeW3gzJ38A8oi5GgCSewH+3Zn5s0TpA+ZqIo7T9Vq4+UUZrxu2PyUlJSWmL/wH5eEJ5sFzGH4AAAAASUVORK5CYII=");
}
`);
// 빨간 점 토글 함수
const toggleRedDot = (icon, shouldShow) => {
if (!icon) return;
const existingDot = icon.querySelector(".red-dot");
if (shouldShow && !existingDot) {
const redDot = document.createElement("div");
redDot.classList.add("red-dot");
Object.assign(redDot.style, {
position: "absolute",
top: "0px",
right: "0px",
width: "4px",
height: "4px",
borderRadius: "50%",
backgroundColor: "red",
zIndex: 1001
});
icon.appendChild(redDot);
} else if (!shouldShow && existingDot) {
existingDot.remove();
}
};
const recordMessage = (userId, userName, message, timestamp) => {
messageHistory.push({ userId, userName, message, timestamp });
if (messageHistory.length > MAX_MESSAGES) {
messageHistory.shift();
}
if (isShowSelectedMessagesEnabled && targetUserIdSet.has(userId)) {
targetUserMessages.push({ userId, userName, message, timestamp });
updateTargetMessages();
}
};
const decodeMessage = (data) => {
const decoder = new TextDecoder("utf-8");
const decodedText = decoder.decode(data);
const parts = decodedText.split("\x0c");
const now = new Date();
const timestamp = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`;
if (parts.length === 13 || parts.length === 14) {
const message = parts[1];
const userId = parts[2].split('(')[0];
const userName = parts[6];
if (userId.includes('|') || userName.includes('|') || !userId || !userName || userName === '0') return;
recordMessage(userId, userName, message, timestamp);
if(isChatCounterEnabled) totalChatCount++; // ✅ 여기서 1씩 증가
} else if (parts[1] === '-1' && parts[4] === '2') {
const userId = parts[2].split('(')[0];
const userName = parts[3];
if (userId.includes('|') || userName.includes('|') || !userId || !userName) return;
if (isShowDeletedMessagesEnabled) {
const userMessages = messageHistory.filter(msg => msg.userId === userId);
bannedMessages.push(...userMessages);
bannedMessages.push({
userId,
userName,
message: "[강제퇴장 됨]",
timestamp
});
updateBannedMessages();
}
}
};
// WebSocket Hook
unsafeWindow.WebSocket = function(url, protocols) {
const ws = new OriginalWebSocket(url, protocols);
if (targetUrlPattern.test(url)) {
ws.addEventListener("message", (event) => decodeMessage(event.data));
}
return ws;
};
unsafeWindow.WebSocket.prototype = OriginalWebSocket.prototype;
// 아이콘 생성
const createIcon = (type, onClick) => {
const icon = document.createElement("div");
icon.classList.add("chat-icon", type === "highlight" ? "highlight-icon" : "trash-icon", type);
icon.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
onClick();
});
element.appendChild(icon);
return icon;
};
const showBannedMessages = () => {
if (bannedWindow && !bannedWindow.closed) {
bannedWindow.focus();
return;
}
const width = 600;
const height = 600;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
const features = `width=${width},height=${height},left=${left},top=${top}`;
bannedWindow = window.open("", "_blank", features);
bannedWindow.document.write(`
<html>
<head>
<title>강제퇴장된 유저의 채팅</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f9;
color: #333;
margin: 20px;
}
#bannedMessages {
list-style: none;
padding: 0;
}
#bannedMessages li {
background-color: #fff;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.message-timestamp {
font-size: 0.9em;
color: #888;
}
</style>
</head>
<body>
<ul id='bannedMessages'></ul>
</body>
</html>
`);
bannedWindow.document.close();
updateBannedMessages();
};
const updateBannedMessages = () => {
if (!bannedWindow || bannedWindow.closed) {
toggleRedDot(banIcon, true);
return;
}
toggleRedDot(banIcon, false);
const messageList = bannedWindow.document.getElementById("bannedMessages");
messageList.replaceChildren();
if (!Array.isArray(bannedMessages) || bannedMessages.length === 0) {
const noMessageItem = document.createElement("li");
noMessageItem.textContent = "메시지가 없습니다.";
messageList.appendChild(noMessageItem);
} else {
bannedMessages.forEach(msg => {
const systemMessage = msg.message === `[강제퇴장 됨]`;
const listItem = document.createElement("li");
const timestampSpan = document.createElement("span");
timestampSpan.className = "message-timestamp";
timestampSpan.textContent = `[${msg.timestamp}]`;
const nameTag = systemMessage
? document.createElement("i")
: document.createElement("strong");
nameTag.textContent = systemMessage
? `${msg.userName} (${msg.userId}) 님이 강제 퇴장 되었습니다.`
: `${msg.userName} (${msg.userId})`;
const text = systemMessage ? '' : `: ${msg.message}`;
const messageText = document.createTextNode(text);
listItem.appendChild(timestampSpan);
listItem.appendChild(document.createTextNode(" "));
listItem.appendChild(nameTag);
listItem.appendChild(messageText);
messageList.insertBefore(listItem, messageList.firstChild);
});
}
};
const showTargetMessages = () => {
if (targetWindow && !targetWindow.closed) {
targetWindow.focus();
return;
}
const width = 600;
const height = 600;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
const features = `width=${width},height=${height},left=${left},top=${top}`;
targetWindow = window.open("", "_blank", features);
targetWindow.document.write(`
<html>
<head>
<title>지정 유저 채팅 | 즐겨찾기 ${allFollowUserIds.length}명 | 수동 입력 ${selectedUsersArray.length}명</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f9;
color: #333;
margin: 20px;
}
#targetUserMessages {
list-style: none;
padding: 0;
}
#targetUserMessages li {
background-color: #fff;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.message-timestamp {
font-size: 0.9em;
color: #888;
}
</style>
</head>
<body>
<ul id='targetUserMessages'></ul>
</body>
</html>
`);
targetWindow.document.close();
updateTargetMessages();
};
const updateTargetMessages = () => {
if (!targetWindow || targetWindow.closed) {
toggleRedDot(highlightIcon, true);
return;
}
toggleRedDot(highlightIcon, false);
const messageList = targetWindow.document.getElementById("targetUserMessages");
messageList.replaceChildren();
if (!Array.isArray(targetUserMessages) || targetUserMessages.length === 0) {
const noMessageItem = document.createElement("li");
noMessageItem.textContent = "메시지가 없습니다.";
messageList.appendChild(noMessageItem);
} else {
targetUserMessages.forEach(msg => {
const li = document.createElement("li");
li.textContent = `[${msg.timestamp}] ${msg.userName} (${msg.userId}): ${msg.message}`;
messageList.insertBefore(li, messageList.firstChild);
});
}
};
const resetChatData = () => {
messageHistory.length = 0;
bannedMessages.length = 0;
targetUserMessages.length = 0;
updateBannedMessages();
updateTargetMessages();
toggleRedDot(banIcon, false);
toggleRedDot(highlightIcon, false);
};
unsafeWindow.resetChatData = resetChatData;
// 조건부 실행
if (isShowDeletedMessagesEnabled) {
banIcon = createIcon("trash", showBannedMessages);
}
if (isShowSelectedMessagesEnabled) {
highlightIcon = createIcon("highlight", showTargetMessages);
}
};
const showSidebarOnMouseOver = () => {
const sidebar = document.getElementById('sidebar');
const videoLayer = document.getElementById('player');
const webplayerContents = document.getElementById('webplayer');
const body = document.body;
webplayerContents.style.left = '0px';
webplayerContents.style.width = '100vw';
const handleSidebarMouseOver = () => {
if (body.classList.contains('screen_mode') && !body.classList.contains('showSidebar')) {
body.classList.add('showSidebar');
webplayerContents.style.left = sidebar.offsetWidth + 'px';
webplayerContents.style.width = `calc(100vw - ${sidebar.offsetWidth}px)`;
}
};
const handleSidebarMouseOut = () => {
if (body.classList.contains('screen_mode') && body.classList.contains('showSidebar')) {
body.classList.remove('showSidebar');
webplayerContents.style.left = '0px';
webplayerContents.style.width = '100vw';
}
};
const mouseMoveHandler = (event) => {
const mouseX = event.clientX;
const mouseY = event.clientY;
if (!body.classList.contains('showSidebar')) {
if (mouseX < 52 && mouseY < (videoLayer.clientHeight / 3 * 2)) {
handleSidebarMouseOver();
}
} else {
if (mouseX < sidebar.clientWidth && mouseY < sidebar.clientHeight) {
handleSidebarMouseOver();
} else {
handleSidebarMouseOut();
}
}
};
const windowMouseOutHandler = (event) => {
if (!event.relatedTarget && !event.toElement) {
handleSidebarMouseOut();
}
};
document.addEventListener('mousemove', mouseMoveHandler);
window.addEventListener('mouseout', windowMouseOutHandler); // 창 벗어남 감지
};
const setupKeydownHandler = (targetCode, toggleFunction) => {
document.addEventListener('keydown', (event) => {
if (event.code === targetCode && !isUserTyping()) {
toggleFunction();
}
}, true);
};
const toggleExpandChatShortcut = () => {
setupKeydownHandler("KeyX", toggleExpandChat); // X 키
};
const toggleSharpModeShortcut = () => {
setupKeydownHandler("KeyE", togglesharpModeCheck); // E 키
updateLabel('clear_screen', '선명한 모드', '선명한 모드(e)');
};
const toggleLowLatencyShortcut = () => {
setupKeydownHandler("KeyD", toggleDelayCheck); // D 키
updateLabel('delay_check', '시차 단축', '시차 단축(d)');
};
const updateLabel = (forId, oldText, newText) => {
const labelElement = document.body.querySelector(`#player label[for="${forId}"]`);
if (labelElement) {
labelElement.innerHTML = labelElement.innerHTML.replace(oldText, newText);
} else {
console.error('Label element not found.');
}
};
const toggleExpandChat = async () => {
if (!isElementVisible('.expand-toggle-li')) return;
try {
const toggleLink = await waitForElementAsync('.expand-toggle-li a');
toggleLink.click();
} catch (error) {
console.error("채팅 확장 토글 링크 클릭 실패:", error);
}
};
const togglesharpModeCheck = () => {
const sharpModeCheckElement = document.getElementById('clear_screen');
if (sharpModeCheckElement) {
sharpModeCheckElement.click();
showPlayerBar('quality_box');
}
};
const toggleDelayCheck = () => {
if (isAdjustDelayNoGridEnabled) {
moveToLatestBufferedPoint();
} else {
const delayCheckElement = document.getElementById('delay_check');
if (delayCheckElement) {
delayCheckElement.click();
showPlayerBar('setting_box');
}
}
};
const showPlayerBar = (target) => {
const player = document.getElementById('player');
player.classList.add('mouseover');
let settingButton, settingBoxOn;
if (target === 'quality_box') {
settingButton = document.body.querySelector('#player button.btn_quality_mode');
settingBoxOn = document.body.querySelector('.quality_box.on');
} else if (target === 'setting_box') {
settingButton = document.body.querySelector('#player button.btn_setting');
settingBoxOn = document.body.querySelector('.setting_box.on');
}
if (settingButton) {
if (!settingBoxOn) {
settingButton.click();
}
setTimeout(() => {
// 현재 열려있는(on 클래스를 가진) 설정 박스를 찾습니다.
const openBox = document.body.querySelector('.quality_box.on, .setting_box.on');
// 만약 있다면 .on 클래스를 제거합니다.
if (openBox) {
openBox.classList.remove('on');
}
player.classList.remove('mouseover'); // 이 코드는 그대로 유지합니다.
}, 1500);
} else {
// 버튼을 못 찾았더라도 mouseover는 제거해줍니다.
setTimeout(() => {
player.classList.remove('mouseover');
}, 1500);
console.error('Setting button not found or not visible.');
}
};
const moveToLatestBufferedPoint = () => {
const video = document.querySelector('video');
const buffered = video.buffered;
if (buffered.length > 0) {
// 버퍼링된 구간의 마지막 시간
const bufferedEnd = buffered.end(buffered.length - 1);
const targetTime = bufferedEnd - 2; // 2초 전으로 설정
// targetTime이 현재 시간보다 뒤에 있을 경우에만 이동
if (targetTime > video.currentTime) {
video.currentTime = targetTime;
}
}
};
const checkPlayerPageHeaderAd = async () => {
try {
const headerAd = await waitForElementAsync('#header_ad', 5000);
headerAd.remove();
} catch (error) {
console.info("헤더 광고가 없습니다. (정상)");
}
};
const getRemainingBufferTime = (video) => {
const buffered = video.buffered;
if (buffered.length > 0) {
// 마지막 버퍼의 끝과 현재 시간의 차이를 계산
const remainingBufferTime = buffered.end(buffered.length - 1) - video.currentTime;
// 0초 또는 정수일 경우 소수점 한 자리로 반환
return remainingBufferTime >= 0
? remainingBufferTime.toFixed(remainingBufferTime % 1 === 0 ? 0 : 1)
: '';
}
return ''; // 버퍼가 없으면 빈 문자열 반환
};
const insertRemainingBuffer = (element) => {
const video = element;
const emptyChat = document.body.querySelector('#empty_chat');
// video의 onprogress 이벤트 핸들러
video.onprogress = () => {
const remainingBufferTime = getRemainingBufferTime(video); // remainingBufferTime 계산
if (emptyChat && remainingBufferTime !== '') {
emptyChat.innerText = `${remainingBufferTime}s 지연됨`;
}
};
};
const isVideoInPiPMode = () => {
const videoElement = document.body.querySelector('video');
return videoElement && document.pictureInPictureElement === videoElement;
};
const handleMuteByVisibility = () => {
if (!isAutoChangeMuteEnabled || isVideoInPiPMode()) return;
const button = document.body.querySelector("#btn_sound");
if (document.hidden) {
// 탭이 비활성화됨
if (!button.classList.contains("mute")) {
button.click();
// console.log("탭이 비활성화됨, 음소거");
}
} else {
// 탭이 활성화됨
if (button.classList.contains("mute")) {
button.click();
// console.log("탭이 활성화됨, 음소거 해제");
}
}
};
const registerVisibilityChangeHandler = () => {
document.addEventListener('visibilitychange', handleMuteByVisibility, true);
};
const handleVisibilityChangeForQuality = async () => {
if (!isAutoChangeQualityEnabled || isVideoInPiPMode()) return;
if (document.hidden) {
console.log("[탭 상태] 비활성화됨");
previousQualityBeforeDowngrade = getCurrentInternalQuality();
previousIsAutoMode = getIsAutoQualityMode();
if (!previousQualityBeforeDowngrade) {
console.warn("[현재 화질] 정보를 가져오지 못함");
} else {
console.log(`[현재 화질 저장] ${previousQualityBeforeDowngrade} (자동모드: ${previousIsAutoMode})`);
}
qualityChangeTimeout = setTimeout(async () => {
await changeQualityLivePlayer('LOW'); // LOW = 최저화질
didChangeToLowest = true;
console.log("[타이머 실행] 최저화질로 전환됨");
}, 6500);
console.log("[타이머] 몇 초 후 최저화질로 변경 예약됨");
} else {
console.log("[탭 상태] 활성화됨");
if (qualityChangeTimeout) {
clearTimeout(qualityChangeTimeout);
qualityChangeTimeout = null;
console.log("[타이머] 예약된 최저화질 변경 취소됨");
}
if (didChangeToLowest && previousQualityBeforeDowngrade) {
const current = getCurrentInternalQuality();
if (previousIsAutoMode) {
if (getIsAutoQualityMode()) {
console.log("[복귀] 이미 자동 모드이므로 변경 생략");
} else {
await changeQualityLivePlayer('AUTO');
console.log("[복귀] 자동 모드 복원됨");
}
} else {
if (current === previousQualityBeforeDowngrade) {
console.log(`[복귀] 현재 화질(${current})과 동일하여 복원 생략`);
} else {
await changeQualityLivePlayer(previousQualityBeforeDowngrade);
console.log(`[복귀] 수동 화질 복원됨 → ${previousQualityBeforeDowngrade}`);
}
}
} else {
console.log("[복귀] 화질 변경 없었으므로 복원 생략");
}
// 상태 초기화
didChangeToLowest = false;
previousQualityBeforeDowngrade = null;
previousIsAutoMode = null;
}
};
const registerVisibilityChangeHandlerForQuality = () => {
document.addEventListener('visibilitychange', handleVisibilityChangeForQuality, true);
};
const autoClaimGem = () => {
const element = document.querySelector('#actionbox > div.ic_gem');
if (element && getComputedStyle(element).display !== 'none') {
element.click();
}
};
const videoSkipHandler = (e) => {
const activeElement = document.activeElement;
const tagName = activeElement.tagName.toLowerCase();
// 입력란 활성화 여부 체크
const isInputActive = (tagName === 'input') ||
(tagName === 'textarea') ||
(activeElement.id === 'write_area') ||
(activeElement.contentEditable === 'true');
// 입력란이 활성화되어 있지 않은 경우 비디오 제어
if (!isInputActive) {
const video = document.querySelector('video');
if (video) {
switch (e.code) {
case 'ArrowRight':
// 오른쪽 방향키: 동영상을 1초 앞으로 이동
video.currentTime += 1;
break;
case 'ArrowLeft':
// 왼쪽 방향키: 동영상을 1초 뒤로 이동
video.currentTime -= 1;
break;
}
}
}
};
const homePageCurrentTab = async () => {
try {
const logoLink = await waitForElementAsync('#logo > a');
logoLink.removeAttribute("target");
} catch (error) {
console.error("로고 링크 처리 실패:", error);
}
};
const useBottomChat = () => {
const toggleBottomChat = () => {
const playerArea = document.querySelector('#player_area');
if (!playerArea) {
console.warn('#player_area 요소를 찾을 수 없습니다.');
return;
}
const playerHeight = playerArea.getBoundingClientRect().height;
const browserHeight = window.innerHeight;
const isPortrait = window.innerHeight * 1.1 > window.innerWidth;
document.body.classList.toggle('bottomChat', isPortrait);
};
window.addEventListener('resize', debounce(toggleBottomChat, 500));
toggleBottomChat();
};
const getViewersNumber = (raw = false) => {
const element = document.querySelector('#nAllViewer');
if (!element) return '0';
const rawNumber = element.innerText.replace(/,/g, '').trim();
if (Boolean(raw)) {
return rawNumber;
}
return addNumberSeparator(rawNumber);
};
const updateTitleWithViewers = () => {
const originalTitle = document.title.split(' ')[0]; // 기존 제목의 첫 번째 단어
const viewers = getViewersNumber(true); // 현재 시청자 수 갱신
const formattedViewers = addNumberSeparatorAll(viewers); // 형식화된 시청자 수
let title = originalTitle;
if (originalTitle !== previousTitle) {
previousViewers = 0; // 제목이 변경되면 이전 시청자 수 초기화
}
if (viewers && previousViewers) {
if (viewers > previousViewers) {
title += ` 🔺${formattedViewers}`;
} else if (viewers < previousViewers) {
title += ` 🔻${formattedViewers}`;
} else {
title += ` • ${formattedViewers}`; // 시청자 수가 변동 없을 때
}
} else {
title += ` • ${formattedViewers}`; // 시청자 수가 변동 없을 때
}
document.title = title; // 제목을 업데이트
previousViewers = viewers; // 이전 시청자 수 업데이트
previousTitle = originalTitle; // 현재 제목을 이전 제목으로 업데이트
};
const disableAutoVOD = () => {
const container = unsafeWindow.liveView?.aContainer?.[1];
if (container) {
if (container.autoPlayVodBanner) {
container.autoPlayVodBanner.show = () => {};
}
} else {
setTimeout(disableAutoVOD, 1000); // container가 없으면 재시도
}
};
const checkMediaInfo = async (mediaName, isAutoLevelEnabled) => {
if (mediaName !== 'original' || isAutoLevelEnabled) { // 원본 화질로 설정되지 않은 경우 or 자동 화질 선택인 경우
const player = await waitForElementAsync('#player');
player.className = 'video mouseover ctrl_output';
// 설정 버튼 클릭
const settingButton = await waitForElementAsync('#player > div.player_ctrlBox > div.ctrlBox > div.right_ctrl .setting_box > button.btn_setting');
settingButton.click();
// 화질 변경 리스트 대기
const settingList = await waitForElementAsync('#player > div.player_ctrlBox > div.ctrlBox > div.right_ctrl .setting_box.on .setting_list');
const spanElement = Array.from(settingList.querySelectorAll('span')).find(el => el.textContent.includes("화질 변경"));
const buttonElement = spanElement.closest('button');
buttonElement.click();
// 두 번째 설정 대기
const resolutionButton = await waitForElementAsync('#player > div.player_ctrlBox > div.ctrlBox > div.right_ctrl .setting_box .setting_list_subLayer ul > li:nth-child(2) > button');
resolutionButton.click();
resolutionButton.className = 'video';
}
};
const getCurrentInternalQuality = () => {
try {
const playerInfo = unsafeWindow.LivePlayer.getPlayerInfo();
return playerInfo?.quality || null;
} catch (e) {
console.warn("[getCurrentInternalQuality] 오류 발생:", e);
return null;
}
};
const getIsAutoQualityMode = () => {
try {
const playerInfo = unsafeWindow.LivePlayer.getPlayerInfo();
return !!playerInfo?.qualityInfo?.isAuto;
} catch (e) {
console.warn("[getIsAutoQualityMode] 오류 발생:", e);
return false;
}
};
const changeQualityLivePlayer = async (qualityName) => {
const current = getCurrentInternalQuality();
if (current === qualityName) {
console.log(`[화질 변경 스킵] 현재(${current}) = 요청(${qualityName})`);
return;
}
try {
unsafeWindow.livePlayer.changeQuality(qualityName);
console.log(`[화질 변경] → ${qualityName}`);
} catch (e) {
console.warn("[changeQualityLivePlayer] 변경 실패:", e);
}
};
const downgradeFrom1440p = async () => {
try {
const livePlayer = await waitForLivePlayer();
const info = await livePlayer.getLiveInfo();
const presets = info.CHANNEL.VIEWPRESET.filter(p => p.name !== 'auto' && p.bps);
const index1440 = presets.findIndex(p => p.label_resolution === '1440');
if (index1440 === -1) {
console.warn('1440p 화질 정보를 찾을 수 없습니다.');
return;
}
if (index1440 === 0) {
console.log('1440p가 최저 화질이라서 더 낮출 수 없습니다.');
return;
}
const lowerPreset = presets[index1440 - 1];
const targetName = qualityNameToInternalType[lowerPreset.name];
if (!targetName) {
console.warn(`하위 화질 ${lowerPreset.name}에 대한 매핑이 없습니다.`);
return;
}
console.log(`1440p에서 ${lowerPreset.label}(${targetName})로 다운그레이드 시도`);
livePlayer.changeQuality(targetName);
} catch (e) {
console.error(e.message);
}
};
const initializeQualityShortcuts = () => {
// --- 1. 상태 관리 변수 ---
let shortcutMap = new Map();
let isKeyListenerAdded = false;
// --- 2. 핵심 로직 함수 ---
const setupQualityShortcuts = async (targetDiv) => {
try {
const qualityBox = targetDiv || document.querySelector('.quality_box ul');
// 화질 목록이 없거나, 화질 목록의 li 요소가 없으면 실행 중단 (안정성 강화)
if (!qualityBox || !qualityBox.querySelector('li')) {
console.log('화질 목록을 찾을 수 없어 단축키 설정을 건너뜁니다.');
return;
}
console.log('화질 목록 변경 감지. 단축키를 업데이트합니다.');
const livePlayer = await waitForLivePlayer();
const info = await livePlayer.getLiveInfo();
const presets = info.CHANNEL.VIEWPRESET;
if (!presets || presets.length === 0) return;
// (이하 화질 정렬, 버튼 매핑, 단축키 설정 로직은 원본과 동일)
presets.sort((a, b) => {
if (a.name === 'auto') return -1;
if (b.name === 'auto') return 1;
return parseInt(b.label_resolution || 0) - parseInt(a.label_resolution || 0);
});
const buttonMap = new Map();
qualityBox.querySelectorAll('li button').forEach(btn => {
if (btn.closest('li')?.style.display !== 'none') {
const span = btn.querySelector('span');
if (span) {
const currentText = (span.textContent.split(' (')[0]).trim();
buttonMap.set(currentText, btn);
}
}
});
const newShortcutMap = new Map();
const shortcutKeys = ['`', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
presets.forEach((preset, index) => {
if (index >= shortcutKeys.length) return;
const button = buttonMap.get(preset.label);
if (button) {
const shortcutKey = shortcutKeys[index];
const internalType = qualityNameToInternalType[preset.name];
if (internalType) {
newShortcutMap.set(shortcutKey, internalType);
button.querySelector('span').textContent = `${preset.label} (${shortcutKey})`;
}
}
});
shortcutMap = newShortcutMap;
} catch (e) {
console.error('화질 단축키 설정 중 오류 발생:', e);
}
};
// --- 3. 이벤트 핸들러 ---
const handleQualityKeyDown = async (event) => {
if (isUserTyping()) return;
const key = event.key === '~' ? '`' : event.key;
if (shortcutMap.has(key)) {
event.preventDefault();
const targetQuality = shortcutMap.get(key);
try {
showPlayerBar();
const livePlayer = await waitForLivePlayer();
livePlayer.changeQuality(targetQuality);
} catch (e) {
console.error('화질 변경에 실패했습니다.', e);
}
}
};
// --- 4. 기능 설치 로직 ---
// 키보드 리스너는 한 번만 설치
if (!isKeyListenerAdded) {
document.addEventListener('keydown', handleQualityKeyDown, true);
isKeyListenerAdded = true;
}
// 디바운스가 적용된 단축키 설정 함수 생성
const debouncedSetup = debounce(setupQualityShortcuts, 1000);
(async () => {
const qualityBoxDiv = await waitForElementAsync('.quality_box ul');
setupQualityShortcuts(qualityBoxDiv);
})()
observeUrlChanges((newPath) => {
setTimeout(setupQualityShortcuts,2000);
});
}
// 3.6. 스크립트 실행 관리 함수 (Execution Management)
const runCommonFunctions = () => {
if (isCustomSidebarEnabled) {
orderSidebarSection();
hideUsersSection();
generateBroadcastElements(0);
checkSidebarVisibility();
}
// 본문 방송 목록의 새 탭 열기 방지
if(!isOpenNewtabEnabled){
setInterval(removeTargetFromLinks, 1000);
}
setupSettingButtonTopbar();
if (isMonthlyRecapEnabled) observeAndAppendRecapButton();
registerMenuBlockingWord();
blockedUsers.forEach(function(user) {
registerUnblockMenu(user);
});
blockedCategories.forEach(function(category) {
registerCategoryUnblockMenu(category);
});
blockedWords.forEach(function(word) {
registerWordUnblockMenu(word);
});
};
const hideUsersSection = () => {
const styles = [
!displayMyplus && '#sidebar .myplus { display: none !important; }',
!displayMyplusvod && '#sidebar .myplusvod { display: none !important; }',
!displayTop && '#sidebar .top { display: none !important; }'
].filter(Boolean).join(' '); // 빈 값 제거 및 합침
if (styles) {
GM_addStyle(styles);
}
};
const removeTargetFromLinks = () => {
try {
const links = document.querySelectorAll('#container a[target], .side_list a[target]');
links.forEach(link => {
link.removeAttribute('target');
});
} catch (error) {
console.error('target 속성 제거 중 오류 발생:', error);
}
};
const orderSidebarSection = () => {
const style =
`
#sidebar .top-section.top {
order: 3 !important;
}
#sidebar .users-section.top {
order: 4 !important;
}
#sidebar .top-section.myplus {
order: 5 !important;
}
#sidebar .users-section.myplus {
order: 6 !important;
}
#sidebar .top-section.myplusvod {
order: 7 !important;
}
#sidebar .users-section.myplusvod {
order: 8 !important;
}
`;
if (!myplusPosition) {
GM_addStyle(style);
}
};
//======================================
// 4. 메인 실행 로직 (Main Execution Logic)
//======================================
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
if (isChangeFontEnabled) applyFontStyles();
loadCategoryData();
});
} else {
if (isChangeFontEnabled) applyFontStyles();
loadCategoryData();
}
// 4.1. 메인 페이지 실행 (sooplive.co.kr)
if (CURRENT_URL.startsWith("https://www.sooplive.co.kr")) {
GM_addStyle(CommonStyles);
GM_addStyle(mainPageCommonStyles);
if (isReplaceEmptyThumbnailEnabled) {
loadHlsScript();
}
if (isCustomSidebarEnabled) document.body.classList.add('customSidebar');
(async () => {
const serviceLnbDiv = await waitForElementAsync('#serviceLnb');
if (isCustomSidebarEnabled) makeTopNavbarAndSidebar("main");
runCommonFunctions();
})()
removeUnwantedTags();
processStreamers();
return;
}
// 4.2. 플레이어 페이지 실행 (play.sooplive.co.kr)
if (CURRENT_URL.startsWith("https://play.sooplive.co.kr")) {
const blankA = !!document.getElementById("bannedMessages");
const blankB = !!document.getElementById("targetUserMessages");
// Embed 페이지에서는 실행하지 않음
const pattern = /^https:\/\/play.sooplive.co.kr\/.*\/.*\/embed(\?.*)?$/;
if (pattern.test(CURRENT_URL) || CURRENT_URL.includes("vtype=chat") || blankA || blankB) {
return;
}
GM_addStyle(CommonStyles);
GM_addStyle(playerCommonStyles);
if (isCustomSidebarEnabled) document.body.classList.add('customSidebar');
if (isCustomSidebarEnabled) {
makeTopNavbarAndSidebar("player");
insertFoldButton();
if(showSidebarOnScreenMode && !showSidebarOnScreenModeAlways) {
showSidebarOnMouseOver();
}
}
if(isBottomChatEnabled) useBottomChat();
if(isMakePauseButtonEnabled) {
appendPauseButton();
observeUrlChanges((newPath) => {
appendPauseButton();
});
};
if(isMakeSharpModeShortcutEnabled) toggleSharpModeShortcut();
if(isMakeLowLatencyShortcutEnabled) toggleLowLatencyShortcut();
if(isMakeQualityChangeShortcutEnabled) initializeQualityShortcuts();
if(isRemainingBufferTimeEnabled){
(async () => {
const livePlayerDiv = await waitForElementAsync('#livePlayer');
insertRemainingBuffer(livePlayerDiv);
})()
}
if(isCaptureButtonEnabled){
makeCaptureButton();
}
if(isStreamDownloadEnabled){
createStreamDownloadButton_LivePage();
}
if(isAutoClaimGemEnabled){
setInterval(autoClaimGem, 30000);
}
if(isVideoSkipHandlerEnabled){
(async () => {
const livePlayerDiv = await waitForElementAsync('#livePlayer');
window.addEventListener('keydown', videoSkipHandler);
})()
}
registerVisibilityChangeHandler();
registerVisibilityChangeHandlerForQuality();
if (isNo1440pEnabled) {
downgradeFrom1440p();
observeUrlChanges((newPath) => {
if (isNo1440pEnabled) setTimeout(downgradeFrom1440p, 4000);
});
}
checkPlayerPageHeaderAd();
if(!isOpenNewtabEnabled){
homePageCurrentTab();
}
if(isDocumentTitleUpdateEnabled){
setTimeout(updateTitleWithViewers, 10000);
setInterval(updateTitleWithViewers, 60000);
}
runCommonFunctions();
// LIVE 채팅창
(async () => {
const chatAreaDiv = await waitForElementAsync('#chat_area');
observeChat('#chat_area',chatAreaDiv);
})()
if (isUnlockCopyPasteEnabled) {
(async () => {
const writeArea = await waitForElementAsync('#write_area');
unlockCopyPaste(writeArea);
})()
};
if (isAlignNicknameRightEnabled) {
alignNicknameRight();
}
if (isAutoScreenModeEnabled) {
(async () => {
const btnScreenModeDiv = await waitForElementAsync('#livePlayer');
if (!document.body.classList.contains('screen_mode')) {
document.body.querySelector('#player .btn_screen_mode').click();
}
})()
}
if (ishideButtonsAboveChatInputEnabled) {
hideButtonsAboveChatInput();
}
if (isExpandLiveChatAreaEnabled) {
setupExpandLiveChatFeature();
}
if (isShowDeletedMessagesEnabled || isShowSelectedMessagesEnabled) {
(async () => {
const chattingItemWrapDiv = await waitForElementAsync('.chatting-item-wrap');
setupChatMessageTrackers(chattingItemWrapDiv);
})();
observeUrlChanges((newPath) => {
unsafeWindow.resetChatData();
});
}
if (isNoAutoVODEnabled) {
disableAutoVOD();
}
if (isHideEsportsInfoEnabled) {
GM_addStyle(`
body:not(.screen_mode,.fullScreen_mode,.embeded_mode)
#webplayer #webplayer_contents #player_area
.broadcast_information.detail_open .esports_info {
display: none !important;
}
.broadcast_information .esports_info {
display: none !important;
}
`
);
}
observeUrlChanges((newPath) => {
emptyViewStreamer();
updateBjIdIfMismatch();
});
return;
}
// 4.3. VOD 페이지 실행 (vod.sooplive.co.kr)
if (CURRENT_URL.startsWith("https://vod.sooplive.co.kr/player/")) {
const isBaseUrl = (url) => /https:\/\/vod\.sooplive\.co\.kr\/player\/\d+/.test(url) && !isCatchUrl(url);
const isCatchUrl = (url) => /https:\/\/vod\.sooplive\.co\.kr\/player\/\d+\/catch/.test(url) || /https:\/\/vod\.sooplive\.co\.kr\/player\/catch/.test(url);
// 다시보기 페이지
if (isBaseUrl(CURRENT_URL)) {
GM_addStyle(CommonStyles);
// vodCore 변수가 선언될 때까지 대기하는 함수
const waitForVodCore = () => {
const checkVodCore = setInterval(() => {
if (unsafeWindow.vodCore?.playerController?._currentMediaInfo?.name) {
clearInterval(checkVodCore); // setInterval 정지
checkMediaInfo(unsafeWindow.vodCore.playerController._currentMediaInfo.name, unsafeWindow.vodCore.playerController._currentMediaInfo.isAutoLevelEnabled); // vodCore 변수가 정의되면 미디어 정보 확인 함수 호출
}
}, 1000);
};
if(isSelectBestQualityEnabled){
waitForVodCore();
observeUrlChanges((newPath) => {
setTimeout(waitForVodCore, 2000);
});
}
if(isCaptureButtonEnabled){
makeCaptureButton();
}
// VOD 채팅창
(async () => {
const webplayerContentsDiv = await waitForElementAsync('#webplayer_contents');
observeChat('#webplayer_contents', webplayerContentsDiv);
})();
setupSettingButtonTopbar();
if (isAlignNicknameRightEnabled) {
alignNicknameRight();
}
if (isExpandVODChatAreaEnabled) {
setupExpandVODChatFeature();
}
if (isMonthlyRecapEnabled) observeAndAppendRecapButton();
// 캐치 페이지
} else if (isCatchUrl(CURRENT_URL)) {
GM_addStyle(CommonStyles);
GM_addStyle(mainPageCommonStyles);
if (isCustomSidebarEnabled) document.body.classList.add('customSidebar');
(async () => {
const serviceLnbDiv = await waitForElementAsync('#serviceLnb');
if (isCustomSidebarEnabled) makeTopNavbarAndSidebar("main");
runCommonFunctions();
})()
if (isRemoveShadowsFromCatchEnabled) addStyleRemoveShadowsFromCatch();
}
}
})();