Holotower Sidecar Mini

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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();

})();