Holotower Sidecar Mini

Embeds a Hololive stream monitor. Mini-Version: Compact UI, Full Features.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Holotower Sidecar Mini
// @namespace    http://tampermonkey.net/
// @version      1.2
// @license      MIT
// @description  Embeds a Hololive stream monitor. Mini-Version: Compact UI, Full Features.
// @author       hlggdev
// @match        *://boards.holotower.org/*
// @match        *://holotower.org/*
// @run-at       document-idle
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @connect      holodex.net
// ==/UserScript==

(function() {
    'use strict';
    console.log("Holotower Sidecar v3.8: Initializing...");

    // --- 1. Safe Data Loading ---
    let savedFavs = [];
    try {
        const raw = GM_getValue('holo_favorites', '[]');
        savedFavs = JSON.parse(raw);
    } catch (e) {
        console.error("Sidecar: Resetting corrupted favorites.", e);
        savedFavs = [];
        GM_setValue('holo_favorites', '[]');
    }

    const STATE = {
        apiKey: GM_getValue('holodex_key', ''),
        favorites: savedFavs,
        favoritesOnly: GM_getValue('favorites_only', false),
        activeTab: 'live',
        org: 'Hololive'
    };

    // --- 2. Styles ---
    GM_addStyle(`
        #holo-sidecar-btn {
            position: fixed !important;
            top: 50%;
            right: 0;
            transform: translateY(-50%);
            background: #2cb4ff;
            color: white;
            padding: 10px 5px;
            cursor: pointer;
            z-index: 2147483647 !important;
            border-radius: 5px 0 0 5px;
            font-family: sans-serif;
            writing-mode: vertical-rl;
            text-orientation: mixed;
            box-shadow: -2px 0 5px rgba(0,0,0,0.3);
            font-weight: bold;
            display: block !important;
            opacity: 1 !important;
            visibility: visible !important;
            width: 25px;
            height: 90px;
            text-align: center;
            line-height: 20px;
            font-size: 12px;
        }

        #holo-sidecar-panel {
            position: fixed !important;
            top: 50% !important;
            transform: translateY(-50%) !important;
            right: -320px;
            width: 300px;
            height: 550px;
            max-height: 90vh;
            background: #1e1e1e;
            color: #ddd;
            z-index: 2147483647 !important;
            transition: right 0.3s cubic-bezier(0.25, 1, 0.5, 1);
            box-shadow: -4px 0 15px rgba(0,0,0,0.6);
            display: flex;
            flex-direction: column;
            font-family: 'Segoe UI', sans-serif;
            font-size: 12px;
            border-radius: 8px 0 0 8px;
            border-left: 1px solid #333;
        }
        #holo-sidecar-panel.open { right: 0; }

        .holo-header {
            padding: 8px 10px;
            background: #2cb4ff;
            color: white;
            display: flex;
            justify-content: space-between;
            align-items: center;
            font-weight: bold;
            border-radius: 8px 0 0 0;
        }
        .holo-controls { display: flex; gap: 10px; }

        /* Interactive Star Button */
        .holo-fav-toggle {
            cursor: pointer;
            opacity: 0.5;
            transition: all 0.2s ease-in-out;
            font-size: 18px; /* Slightly larger for easier clicking */
            user-select: none;
            padding: 0 2px;
        }
        .holo-fav-toggle:hover {
            transform: scale(1.2);
            opacity: 1;
            text-shadow: 0 0 8px white;
        }
        .holo-fav-toggle.active {
            opacity: 1;
            text-shadow: 0 0 5px gold;
            color: #fff700;
        }

        .holo-tabs {
            display: flex;
            background: #252525;
            border-bottom: 1px solid #333;
        }
        .holo-tab {
            flex: 1;
            text-align: center;
            padding: 6px 0;
            cursor: pointer;
            color: #888;
            font-weight: bold;
            font-size: 11px;
            transition: color 0.2s;
            user-select: none;
        }
        .holo-tab:hover { color: #ccc; }
        .holo-tab.active {
            color: #2cb4ff;
            border-bottom: 2px solid #2cb4ff;
            background: #2a2a2a;
        }

        .holo-content { flex: 1; overflow-y: auto; padding: 8px; }

        .holo-stream-card {
            display: flex;
            background: #2a2a2a;
            margin-bottom: 6px;
            border-radius: 4px;
            overflow: hidden;
            cursor: pointer;
            transition: background 0.2s;
            height: 48px;
        }
        .holo-stream-card:hover { background: #3a3a3a; }
        .holo-thumb {
            width: 85px;
            height: 48px;
            object-fit: cover;
            flex-shrink: 0;
        }
        .holo-info {
            padding: 2px 6px;
            display: flex;
            flex-direction: column;
            justify-content: center;
            overflow: hidden;
            width: 100%;
        }
        .holo-title {
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            font-weight: bold;
            color: #fff;
            font-size: 11px;
            line-height: 1.2;
        }
        .holo-channel { font-size: 10px; color: #bbb; line-height: 1.2; }
        .holo-meta { font-size: 9px; margin-top: 2px; }
        .meta-live { color: #ff6b6b; }
        .meta-upcoming { color: #4cd137; }

        .holo-settings { padding: 10px; }
        .holo-input {
            width: 100%;
            padding: 6px;
            margin: 5px 0;
            background: #333;
            border: 1px solid #555;
            color: white;
            box-sizing: border-box;
            font-size: 11px;
        }
        .holo-btn {
            background: #2cb4ff;
            border: none;
            color: white;
            padding: 4px 8px;
            cursor: pointer;
            border-radius: 3px;
            width: 100%;
            margin-top: 5px;
            font-size: 11px;
        }
        .holo-btn.secondary { background: #444; }
        .holo-btn.delete { background: #ff4444; width: auto; font-size: 9px; padding: 2px 6px; margin: 0; }

        .fav-item {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 4px;
            background: #252525;
            border-bottom: 1px solid #333;
            font-size: 11px;
        }
        .search-result {
            padding: 6px;
            background: #333;
            border-bottom: 1px solid #444;
            cursor: pointer;
            font-size: 11px;
        }
        .search-result:hover { background: #444; }

        .holo-footer {
            padding: 8px;
            display: flex;
            gap: 5px;
            background: #1e1e1e;
            border-top: 1px solid #333;
            border-radius: 0 0 0 8px;
        }
        .close-btn { cursor: pointer; }
    `);

    // --- 3. UI Persistence ---
    function ensureUI() {
        if (!document.body) return;
        let btn = document.getElementById('holo-sidecar-btn');
        if (!btn) {
            btn = document.createElement('div');
            btn.id = 'holo-sidecar-btn';
            btn.innerText = 'HOLODEX';
            document.body.appendChild(btn);
            btn.addEventListener('click', togglePanel);
        }
        let panel = document.getElementById('holo-sidecar-panel');
        if (!panel) {
            panel = document.createElement('div');
            panel.id = 'holo-sidecar-panel';
            document.body.appendChild(panel);
            // Single Master Listener (No Duplicates)
            panel.addEventListener('click', handlePanelClick);
            renderMainUI(panel);
        }
    }

    // --- 4. Event Handling (Clean Delegation) ---
    function handlePanelClick(e) {
        // Find closest element matching selector to handle clicks on icons/text
        const target = e.target;

        // 1. Close Button
        if (target.closest('.close-btn')) {
            togglePanel(e);
            return;
        }

        // 2. Favorites Toggle (The problematic one)
        const favBtn = target.closest('#holo-fav-toggle');
        if (favBtn) {
            console.log("Sidecar: Fav toggle clicked");
            toggleFavorites(e);
            return;
        }

        // 3. Tabs
        if (target.closest('#tab-live')) { switchTab('live'); return; }
        if (target.closest('#tab-upcoming')) { switchTab('upcoming'); return; }

        // 4. Footer & Settings
        if (target.closest('#holo-refresh')) { loadStreams(); return; }
        if (target.closest('#holo-settings-btn')) { renderSettingsUI(); return; }
        if (target.closest('#holo-back-btn')) {
            renderMainUI(document.getElementById('holo-sidecar-panel'));
            loadStreams();
            return;
        }
        if (target.closest('#holo-save-key')) {
            const val = document.getElementById('holo-api-input').value.trim();
            if(val) { GM_setValue('holodex_key', val); STATE.apiKey = val; alert('Key Saved!'); }
            return;
        }
    }

    // --- Core Functions ---
    function togglePanel(e) {
        if(e) e.stopPropagation();
        const panel = document.getElementById('holo-sidecar-panel');
        if(!panel) return;
        if (panel.classList.contains('open')) {
            panel.classList.remove('open');
        } else {
            panel.classList.add('open');
            renderMainUI(panel);
            loadStreams();
        }
    }

    function switchTab(tabName) {
        STATE.activeTab = tabName;
        const panel = document.getElementById('holo-sidecar-panel');
        renderMainUI(panel);
        loadStreams();
    }

    function renderMainUI(panel) {
        if(!panel) panel = document.getElementById('holo-sidecar-panel');
        if(!panel) return;

        const titleText = STATE.favoritesOnly ? 'Sidecar (Favs)' : `Sidecar (${STATE.org})`;
        const favClass = STATE.favoritesOnly ? 'active' : '';
        const liveTabClass = STATE.activeTab === 'live' ? 'active' : '';
        const upTabClass = STATE.activeTab === 'upcoming' ? 'active' : '';

        // Clean Render: We DO NOT attach event listeners here anymore.
        // They are all handled by handlePanelClick attached in ensureUI.
        if(!panel.innerHTML.trim()) {
             panel.innerHTML = `
                <div class="holo-header">
                    <span id="holo-header-title">${titleText}</span>
                    <div class="holo-controls">
                        <span id="holo-fav-toggle" class="holo-fav-toggle ${favClass}" title="Toggle Favorites Only">★</span>
                        <span class="close-btn">✖</span>
                    </div>
                </div>
                <div class="holo-tabs">
                    <div id="tab-live" class="holo-tab ${liveTabClass}">LIVE NOW</div>
                    <div id="tab-upcoming" class="holo-tab ${upTabClass}">UPCOMING</div>
                </div>
                <div class="holo-content" id="holo-list">
                    <div style="text-align:center; padding: 20px;">Loading...</div>
                </div>
                <div class="holo-footer">
                    <button id="holo-refresh" class="holo-btn secondary">Refresh</button>
                    <button id="holo-settings-btn" class="holo-btn secondary">Settings</button>
                </div>
            `;
        } else {
            // Update Existing Elements
            const titleEl = document.getElementById('holo-header-title');
            if(titleEl) titleEl.innerText = titleText;

            const starEl = document.getElementById('holo-fav-toggle');
            if(starEl) {
                if(STATE.favoritesOnly) {
                    if(!starEl.classList.contains('active')) starEl.classList.add('active');
                } else {
                    if(starEl.classList.contains('active')) starEl.classList.remove('active');
                }
            }

            const tabL = document.getElementById('tab-live');
            const tabU = document.getElementById('tab-upcoming');
            if(tabL && tabU) {
                tabL.className = `holo-tab ${liveTabClass}`;
                tabU.className = `holo-tab ${upTabClass}`;
            }
        }
    }

    function renderSettingsUI() {
        const list = document.getElementById('holo-list');
        list.innerHTML = `
            <div class="holo-settings">
                <h4>API Key</h4>
                <input type="text" id="holo-api-input" class="holo-input" placeholder="Holodex API Key" value="${STATE.apiKey}">
                <button id="holo-save-key" class="holo-btn">Save Key</button>
                <hr style="border-color:#333; margin: 10px 0;">
                <h4>Manage Favorites</h4>
                <input type="text" id="holo-search-input" class="holo-input" placeholder="Search (e.g. Pekora)">
                <div id="holo-search-results"></div>
                <h5 style="margin-top:10px;">Your List (${STATE.favorites.length})</h5>
                <div id="holo-fav-list"></div>
                <button id="holo-back-btn" class="holo-btn secondary" style="margin-top:15px;">Back to Streams</button>
            </div>
        `;

        let searchTimeout;
        const searchInput = document.getElementById('holo-search-input');
        if (searchInput) {
            searchInput.addEventListener('input', (e) => {
                clearTimeout(searchTimeout);
                const query = e.target.value.trim();
                if(query.length < 2) return;
                searchTimeout = setTimeout(() => { doChannelSearch(query); }, 500);
            });
        }
        renderFavList();
    }

    function renderFavList() {
        const container = document.getElementById('holo-fav-list');
        if(!container) return;
        container.innerHTML = '';
        if(STATE.favorites.length === 0) {
            container.innerHTML = '<div style="font-size:10px; color:#666; font-style:italic;">No favorites added yet.</div>';
            return;
        }
        STATE.favorites.forEach((chan, idx) => {
            const div = document.createElement('div');
            div.className = 'fav-item';
            div.innerHTML = `<span>${chan.name}</span><button class="holo-btn delete">Remove</button>`;
            div.querySelector('button').addEventListener('click', () => {
                STATE.favorites.splice(idx, 1);
                GM_setValue('holo_favorites', JSON.stringify(STATE.favorites));
                renderFavList();
            });
            container.appendChild(div);
        });
    }

    function doChannelSearch(query) {
        if(!STATE.apiKey) return;
        GM_xmlhttpRequest({
            method: "GET",
            url: `https://holodex.net/api/v2/search/autocomplete?q=${encodeURIComponent(query)}`,
            headers: { "X-APIKEY": STATE.apiKey },
            onload: function(res) {
                try {
                    const data = JSON.parse(res.responseText);
                    const resultsDiv = document.getElementById('holo-search-results');
                    resultsDiv.innerHTML = '';
                    const channels = data.filter(item => item.type === 'channel').slice(0, 5);
                    if(channels.length === 0) { resultsDiv.innerHTML = 'No channels found.'; return; }
                    channels.forEach(ch => {
                        const row = document.createElement('div');
                        row.className = 'search-result';
                        row.innerHTML = `+ ${ch.text || ch.name}`;
                        row.addEventListener('click', () => {
                            addFavorite(ch.value || ch.id, ch.text || ch.name);
                            document.getElementById('holo-search-input').value = '';
                            resultsDiv.innerHTML = '';
                        });
                        resultsDiv.appendChild(row);
                    });
                } catch(e) {}
            }
        });
    }

    function addFavorite(id, name) {
        if(STATE.favorites.some(f => f.id === id)) return;
        STATE.favorites.push({ id, name });
        GM_setValue('holo_favorites', JSON.stringify(STATE.favorites));
        renderFavList();
    }

    // --- FETCHING LOGIC ---
    function loadStreams() {
        if (!STATE.apiKey) { renderSettingsUI(); return; }
        const list = document.getElementById('holo-list');
        if(!list) return;
        list.innerHTML = '<div style="text-align:center; padding: 20px;">Fetching streams...</div>';

        if (STATE.favoritesOnly) fetchFavoritesOnly(list);
        else fetchGlobal(list);
    }

    function fetchGlobal(listElement) {
        const status = STATE.activeTab === 'upcoming' ? 'upcoming' : 'live';
        const url = `https://holodex.net/api/v2/live?org=${STATE.org}&status=${status}&type=stream&limit=50`;
        GM_xmlhttpRequest({
            method: "GET",
            url: url,
            headers: { "X-APIKEY": STATE.apiKey },
            onload: function(response) {
                if (response.status === 200) {
                    try { renderStreams(JSON.parse(response.responseText)); }
                    catch(e) { listElement.innerHTML = 'Error parsing JSON'; }
                } else listElement.innerHTML = `API Error: ${response.status}`;
            }
        });
    }

    function fetchFavoritesOnly(listElement) {
        if(STATE.favorites.length === 0) {
            listElement.innerHTML = '<div style="text-align:center; padding:20px;">Your favorites list is empty!</div>';
            return;
        }
        const status = STATE.activeTab === 'upcoming' ? 'upcoming' : 'live';
        let completedRequests = 0;
        let allStreams = [];
        const totalRequests = STATE.favorites.length;
        STATE.favorites.forEach(fav => {
            const url = `https://holodex.net/api/v2/live?channel_id=${fav.id}&status=${status}&type=stream`;
            GM_xmlhttpRequest({
                method: "GET",
                url: url,
                headers: { "X-APIKEY": STATE.apiKey },
                onload: function(response) {
                    if (response.status === 200) {
                        try {
                            const data = JSON.parse(response.responseText);
                            if(Array.isArray(data)) allStreams = allStreams.concat(data);
                        } catch(e) {}
                    }
                },
                onloadend: function() {
                    completedRequests++;
                    if(completedRequests === totalRequests) {
                        const uniqueStreams = Array.from(new Map(allStreams.map(item => [item.id, item])).values());
                        renderStreams(uniqueStreams);
                    }
                }
            });
        });
    }

    // --- FILTERS (JAPANESE NAMES INCLUDED) ---
    const HOLOSTARS_BLACKLIST = [
        "HOLOSTARS", "UPROAR", "TEMPUS", "ARMIS",
        "MIYABI", "HANASAKI", "花咲みやび", "みやび",
        "IZURU", "KANADE", "奏手イヅル", "イヅル",
        "ARURAN", "ARURANDEISU", "アルランディス", "アルラン",
        "RIKKA", "律可",
        "ASTEL", "LEDA", "アステル", "レダ",
        "TEMMA", "KISHIDO", "岸堂天真", "天真",
        "ROBERU", "YUKOKU", "夕刻ロベル", "ロベル",
        "SHIEN", "KAGEYAMA", "影山シエン", "シエン",
        "OGA", "ARAGAMI", "荒咬オウガ", "オウガ",
        "FUMA", "YATOGAMI", "夜十神封魔", "封魔",
        "UYU", "UTSUGI", "羽継烏有", "烏有",
        "GAMMA", "HIZAKI", "緋崎ガンマ", "ガンマ",
        "RIO", "MINASE", "水無世燐央", "燐央",
        "ALTARE", "AXEL", "HAKKA", "SHINRI", "BETTEL", "FLAYON",
        "JURARD", "GOLDBULLET", "OCTAVIO", "CRIMZON"
    ];

    function isFreeChat(title) {
        if (!title) return false;
        return /free\s*chat|chat\s*room|フリーチャット|ふりーちゃっと|待機所/i.test(title);
    }

    function isHolostars(stream) {
        if (!stream.channel) return false;
        if (stream.channel.org === 'Holostars') return true;
        if (stream.channel.group && stream.channel.group.toLowerCase().includes('holostars')) return true;
        const channelName = (stream.channel.name || "").toUpperCase();
        for (const keyword of HOLOSTARS_BLACKLIST) {
            if (channelName.includes(keyword)) return true;
        }
        return false;
    }

    function formatStartTime(isoString) {
        const date = new Date(isoString);
        const now = new Date();
        const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
        const diffMs = date - now;
        const diffMins = Math.floor(diffMs / 60000);
        const diffHours = Math.floor(diffMins / 60);
        let relative = "";
        if (diffMins < 0) relative = "Started";
        else if (diffMins < 60) relative = `in ${diffMins} min`;
        else relative = `in ${diffHours}h ${diffMins % 60}m`;
        return `${timeStr} <span style="color:#888;">(${relative})</span>`;
    }

    function renderStreams(streams) {
        const list = document.getElementById('holo-list');
        if(!list) return;
        list.innerHTML = '';
        if (!Array.isArray(streams) || streams.length === 0) {
            list.innerHTML = `<div style="text-align:center; padding:20px;">Nothing found here :(</div>`;
            return;
        }
        const filteredStreams = streams.filter(s => !isFreeChat(s.title) && !isHolostars(s));
        if (filteredStreams.length === 0) {
            list.innerHTML = `<div style="text-align:center; padding:20px;">No streams found (All hidden by filters)</div>`;
            return;
        }

        if (STATE.activeTab === 'live') {
            filteredStreams.sort((a, b) => (b.live_viewers || 0) - (a.live_viewers || 0));
        } else {
            filteredStreams.sort((a, b) => new Date(a.start_scheduled) - new Date(b.start_scheduled));
        }

        filteredStreams.forEach(stream => {
            const card = document.createElement('div');
            card.className = 'holo-stream-card';
            const thumbUrl = `https://i.ytimg.com/vi/${stream.id}/mqdefault.jpg`;
            let metaHtml = "";
            if (STATE.activeTab === 'live') {
                const viewers = stream.live_viewers
                    ? (stream.live_viewers > 1000 ? (stream.live_viewers / 1000).toFixed(1) + 'k' : stream.live_viewers)
                    : 'WAIT';
                metaHtml = `<div class="holo-meta meta-live">● ${viewers} Viewers</div>`;
            } else {
                const timeDisplay = formatStartTime(stream.start_scheduled);
                metaHtml = `<div class="holo-meta meta-upcoming">▶ ${timeDisplay}</div>`;
            }

            card.innerHTML = `
                <img src="${thumbUrl}" class="holo-thumb" loading="lazy">
                <div class="holo-info">
                    <div class="holo-title" title="${stream.title}">${stream.title}</div>
                    <div class="holo-channel">${stream.channel.name}</div>
                    ${metaHtml}
                </div>
            `;
            card.addEventListener('click', () => { window.open(`https://www.youtube.com/watch?v=${stream.id}`, '_blank'); });
            list.appendChild(card);
        });
    }

    function toggleFavorites(e) {
        if(e) e.stopPropagation();
        STATE.favoritesOnly = !STATE.favoritesOnly;
        GM_setValue('favorites_only', STATE.favoritesOnly);
        const panel = document.getElementById('holo-sidecar-panel');
        // Force header update immediately
        renderMainUI(panel);
        loadStreams();
    }

    // --- GLOBAL Click Outside (To Close) ---
    document.addEventListener('click', function(event) {
        const panel = document.getElementById('holo-sidecar-panel');
        const btn = document.getElementById('holo-sidecar-btn');
        if (!panel || !btn || !panel.classList.contains('open')) return;
        // Fix for removed elements (e.g. clicking something that disappears instantly)
        if (!document.body.contains(event.target)) return;

        if (!panel.contains(event.target) && !btn.contains(event.target)) {
            panel.classList.remove('open');
        }
    });

    setInterval(ensureUI, 2000);
    ensureUI();

})();