Twitch Kick Integration

Adds Kick livestreams to Twitch's sidebar, and plays them within Twitch when clicked. Select streamers via the Tampermonkey browser addon menu.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Twitch Kick Integration
// @namespace    http://tampermonkey.net/
// @version      1.4
// @description  Adds Kick livestreams to Twitch's sidebar, and plays them within Twitch when clicked. Select streamers via the Tampermonkey browser addon menu.
// @author       Aton_Freson
// @match        *://*.twitch.tv/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @connect      kick.com
// @license      CC BY-NC-SA 4.0; https://creativecommons.org/licenses/by-nc-sa/4.0/
// ==/UserScript==

(function() {
    'use strict';

    class KickSidebarIntegration {
        constructor(rawStreamerList, checkIntervalMinutes = 3) {
            this.checkInterval = checkIntervalMinutes * 60 * 1000;
            this.liveData = [];
            this.containerId = 'kick-sidebar-integration';
            this._pollTimeout = null;
            this.streamerConfigs = [];
            this.setStreamers(rawStreamerList);
        }

        init() {
            this.injectCSS();
            this.startObserver();
            this.pollApi();
            this._handlePendingOverlay();
        }

        setStreamers(rawStreamerList) {
            this.streamerConfigs = this._parseStreamerConfigs(rawStreamerList);
        }

        _parseStreamerConfigs(list) {
            const input = Array.isArray(list) ? list : [];
            return input.map(entry => {
                if (typeof entry !== 'string') return null;
                const trimmed = entry.trim().toLowerCase();
                if (!trimmed) return null;
                const [kickPart, displayPart] = trimmed.split('=');
                const kickName = kickPart?.trim();
                if (!kickName) return null;
                const displayName = (displayPart?.trim()) || kickName;
                return { kickName, displayName };
            }).filter(Boolean);
        }

        // --- STYLES ---
        injectCSS() {
            GM_addStyle(`
                #${this.containerId} {
                    display: flex;
                    flex-direction: column;
                }
                .kick-ext-header {
                    padding: 4px 10px; font-size: 11px; font-weight: 700;
                    color: #53fc18; text-transform: uppercase; letter-spacing: 0.5px;
                }

                .kick-ext-item {
                    display: flex; align-items: center; justify-content: space-between;
                    padding: 5px 10px; text-decoration: none !important; color: #efeff1;
                    transition: background-color 0.15s ease;
                }
                .kick-ext-item:hover { background-color: #26262c; }
                .kick-ext-left { display: flex; align-items: center; gap: 10px; overflow: hidden; }
                .kick-ext-avatar { width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0; }
                .kick-ext-meta { display: flex; flex-direction: column; overflow: hidden; }
                .kick-ext-name { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
                .kick-ext-category { font-size: 12px; color: #adadb8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
                .kick-ext-right { display: flex; align-items: center; gap: 5px; font-size: 13px; flex-shrink: 0; }
                .kick-ext-dot { width: 8px; height: 8px; background-color: #53fc18; border-radius: 50%; }
                .kick-ext-empty { padding: 5px 10px; font-size: 12px; color: #adadb8; font-style: italic; }

                /* Kick overlay on Twitch player — leave 50px at bottom for Twitch controls */
                #kick-overlay-container {
                    position: absolute; top: 0; left: 0; width: 100%; height: calc(100% - 50px);
                    z-index: 9999;
                }
                #kick-overlay-container iframe {
                    width: 100%; height: 100%; border: none;
                }
                #kick-overlay-badge {
                    position: absolute; top: 10px; left: 10px; z-index: 10000;
                    background: rgba(0,0,0,0.7); color: #53fc18; font-weight: 700;
                    font-size: 12px; padding: 4px 10px; border-radius: 4px;
                    display: flex; align-items: center; gap: 8px;
                    pointer-events: auto;
                }
                #kick-overlay-close {
                    cursor: pointer; color: #efeff1; font-size: 16px; line-height: 1;
                    background: none; border: none; padding: 0 0 0 4px;
                }
                #kick-overlay-close:hover { color: #ff4d4d; }

                /* Standalone Kick player when no Twitch player exists (offline/banned) */
                #kick-standalone-player {
                    position: relative; width: 100%; aspect-ratio: 16/9;
                    background: #000; z-index: 50;
                }
                #kick-standalone-player iframe {
                    width: 100%; height: 100%; border: none;
                }
            `);
        }

        // --- API LOGIC ---
        fetchStreamerStatus(streamerConfig) {
            return new Promise((resolve) => {
                GM_xmlhttpRequest({
                    method: "GET",
                    url: `https://kick.com/api/v1/channels/${streamerConfig.kickName}`,
                    onload: (response) => {
                        try {
                            const data = JSON.parse(response.responseText);
                            if (data.livestream && data.livestream.is_live) {
                                resolve({
                                    kickName: streamerConfig.kickName,
                                    displayName: streamerConfig.displayName,
                                    name: data.user.username,
                                    avatar: data.user.profile_pic,
                                    viewers: data.livestream.viewer_count,
                                    category: data.livestream.categories[0]?.name || 'Just Chatting'
                                });
                            } else {
                                resolve(null); // Offline
                            }
                        } catch (e) {
                            resolve(null);
                        }
                    },
                    onerror: () => resolve(null)
                });
            });
        }

        async pollApi() {
            if (this._pollTimeout) {
                clearTimeout(this._pollTimeout);
                this._pollTimeout = null;
            }

            if (!this.streamerConfigs.length) {
                this.liveData = [];
                this.render();
                this._scheduleNextPoll();
                return;
            }

            const promises = this.streamerConfigs.map(config => this.fetchStreamerStatus(config));
            const results = await Promise.all(promises);

            const liveStreamers = this._normalizeLiveData(results);
            this.liveData = liveStreamers.sort((a, b) => b.viewers - a.viewers);

            this.render();
            this._scheduleNextPoll();
        }

        _normalizeLiveData(results) {
            const map = new Map();
            for (const streamer of results) {
                if (!streamer) continue;
                const key = streamer.displayName.toLowerCase();
                const existing = map.get(key);
                if (!existing) {
                    map.set(key, streamer);
                } else {
                    map.set(key, this._choosePreferredLiveEntry(existing, streamer));
                }
            }
            return Array.from(map.values());
        }

        _choosePreferredLiveEntry(existing, candidate) {
            const existingDirect = existing.kickName === existing.displayName;
            const candidateDirect = candidate.kickName === candidate.displayName;
            if (existingDirect !== candidateDirect) {
                return candidateDirect ? candidate : existing;
            }
            return candidate.viewers >= existing.viewers ? candidate : existing;
        }

        _scheduleNextPoll() {
            this._pollTimeout = setTimeout(() => this.pollApi(), this.checkInterval);
        }

        // --- DOM MANIPULATION ---
        formatViewers(num) {
            return num >= 1000 ? (num / 1000).toFixed(1) + 'k' : num;
        }

        render() {
            const sidebar = document.querySelector('[data-a-target="side-nav-bar"]');
            if (!sidebar) return;

            // Find the "Followed Channels" section
            const followedSection = sidebar.querySelector('.side-nav-section')
                || [...sidebar.querySelectorAll('[aria-label]')].find(el =>
                    /followed/i.test(el.getAttribute('aria-label'))
                );

            // Fallback: look for the heading text and walk up to the section container
            let targetList = null;
            if (followedSection) {
                targetList = followedSection;
            } else {
                const headings = sidebar.querySelectorAll('h5, p, span, div');
                for (const h of headings) {
                    if (/followed\s+channels/i.test(h.textContent.trim())) {
                        // Walk up to find the wrapping section, then find its list
                        targetList = h.closest('[class*="side-nav"]') || h.parentElement?.parentElement;
                        break;
                    }
                }
            }

            if (!targetList) return; // Followed Channels section not found yet

            // Remove existing Kick entries if no one is live
            if (this.liveData.length === 0) {
                const existing = document.getElementById(this.containerId);
                if (existing) existing.remove();
                return;
            }

            let container = document.getElementById(this.containerId);

            if (!container) {
                container = document.createElement('div');
                container.id = this.containerId;

                const header = document.createElement('div');
                header.className = 'kick-ext-header';
                header.innerText = 'Kick';
                container.appendChild(header);

                const listWrapper = document.createElement('div');
                listWrapper.className = 'kick-ext-list';
                container.appendChild(listWrapper);

                // Insert at the end of the Followed Channels section so it scrolls together
                const transitionGroup = targetList.querySelector('[class*="TransitionGroup"]')
                    || targetList.querySelector('div > div');
                if (transitionGroup && transitionGroup.parentElement) {
                    transitionGroup.parentElement.appendChild(container);
                } else {
                    targetList.appendChild(container);
                }
            }

            // Update the list content
            const listWrapper = container.querySelector('.kick-ext-list');
            listWrapper.innerHTML = ''; // Clear current

            this.liveData.forEach(streamer => {
                const a = document.createElement('a');
                a.href = `https://www.twitch.tv/${streamer.displayName}`;
                a.className = 'kick-ext-item';
                a.addEventListener('click', (e) => {
                    e.preventDefault();
                    const currentChannel = window.location.pathname.replace(/^\//, '').split('/')[0].toLowerCase();
                    if (currentChannel === streamer.displayName.toLowerCase()) {
                        // Already on this channel, just overlay
                        this.overlayKickStream(streamer.kickName);
                    } else {
                        // Store in sessionStorage so we auto-overlay after the page loads
                        sessionStorage.setItem('kick-pending-overlay', streamer.kickName);
                        // Full navigation — script will re-init on the new page
                        window.location.href = `https://www.twitch.tv/${streamer.displayName}`;
                    }
                });
                a.innerHTML = `
                    <div class="kick-ext-left">
                        <img class="kick-ext-avatar" src="${streamer.avatar}" alt="${streamer.displayName}">
                        <div class="kick-ext-meta">
                            <span class="kick-ext-name">${streamer.displayName}</span>
                            <span class="kick-ext-category">${streamer.category}</span>
                        </div>
                    </div>
                    <div class="kick-ext-right">
                        <div class="kick-ext-dot"></div>
                        <span>${this.formatViewers(streamer.viewers)}</span>
                    </div>
                `;
                listWrapper.appendChild(a);
            });
        }

        // --- KICK OVERLAY ---
        overlayKickStream(username) {
            // Remove any existing overlay/standalone first
            this.removeOverlay();

            const playerContainer = document.querySelector('.video-player__container')
                || document.querySelector('[data-a-target="video-player"]')
                || document.querySelector('.video-player');

            if (playerContainer) {
                // Twitch player exists — overlay on top of it
                const pos = getComputedStyle(playerContainer).position;
                if (pos === 'static') playerContainer.style.position = 'relative';

                const overlay = document.createElement('div');
                overlay.id = 'kick-overlay-container';

                const iframe = document.createElement('iframe');
                iframe.src = `https://player.kick.com/${username}`;
                iframe.allow = 'autoplay; fullscreen';
                iframe.setAttribute('allowfullscreen', 'true');
                overlay.appendChild(iframe);

                const badge = document.createElement('div');
                badge.id = 'kick-overlay-badge';
                badge.innerHTML = `KICK <button id="kick-overlay-close" title="Remove Kick overlay">✕</button>`;
                overlay.appendChild(badge);

                playerContainer.appendChild(overlay);

                // Pause the Twitch player underneath
                try {
                    const twitchVideo = playerContainer.querySelector('video');
                    if (twitchVideo) twitchVideo.pause();
                } catch(e) {}
            } else {
                // No Twitch player (offline / banned / nonexistent) — inject standalone
                const mainContent = document.querySelector('[data-a-target="video-player-layout"]')
                    || document.querySelector('main')
                    || document.querySelector('[class*="channel-root"]')
                    || document.querySelector('.root-scrollable__wrapper')
                    || document.querySelector('#root div');
                if (!mainContent) return;

                const standalone = document.createElement('div');
                standalone.id = 'kick-standalone-player';

                const iframe = document.createElement('iframe');
                iframe.src = `https://player.kick.com/${username}`;
                iframe.allow = 'autoplay; fullscreen';
                iframe.setAttribute('allowfullscreen', 'true');
                standalone.appendChild(iframe);

                const badge = document.createElement('div');
                badge.id = 'kick-overlay-badge';
                badge.innerHTML = `KICK <button id="kick-overlay-close" title="Remove Kick player">✕</button>`;
                standalone.appendChild(badge);

                mainContent.insertBefore(standalone, mainContent.firstChild);
            }

            document.getElementById('kick-overlay-close').addEventListener('click', (e) => {
                e.stopPropagation();
                this.removeOverlay();
            });
        }

        removeOverlay() {
            const overlay = document.getElementById('kick-overlay-container');
            const standalone = document.getElementById('kick-standalone-player');
            if (overlay) {
                overlay.remove();
                // Resume Twitch video
                try {
                    const playerContainer = document.querySelector('.video-player__container')
                        || document.querySelector('[data-a-target="video-player"]')
                        || document.querySelector('.video-player');
                    const twitchVideo = playerContainer?.querySelector('video');
                    if (twitchVideo) twitchVideo.play();
                } catch(e) {}
            }
            if (standalone) standalone.remove();
        }

        // --- OBSERVER ---
        startObserver() {
            // Twitch SPA navigation can destroy our injected elements.
            // This observer ensures we re-inject if the sidebar is rebuilt.
            // It also removes the Kick overlay when navigating to a different channel.
            this._lastUrl = window.location.href;
            const observer = new MutationObserver(() => {
                const sidebar = document.querySelector('[data-a-target="side-nav-bar"]');
                if (sidebar && !document.getElementById(this.containerId)) {
                    this.render();
                }

                // Detect SPA navigation — remove overlay if URL changed
                const currentUrl = window.location.href;
                if (currentUrl !== this._lastUrl) {
                    this._lastUrl = currentUrl;
                    this.removeOverlay();
                }
            });
            observer.observe(document.body, { childList: true, subtree: true });
        }

        /**
         * Emulate "pressing the sidebar entry a second time" after navigating.
         * The click handler always does a full-page navigation, so the script
         * re-inits on the new page. Here we wait for window 'load' + 2s
         * for Twitch to finish React-rendering, then call overlayKickStream
         */
        _handlePendingOverlay() {
            const pending = sessionStorage.getItem('kick-pending-overlay');
            if (!pending) return;

            // Consume immediately so it can't fire twice
            sessionStorage.removeItem('kick-pending-overlay');

            const doOverlay = () => {
                setTimeout(() => this.overlayKickStream(pending), 2000);
            };

            if (document.readyState === 'complete') {
                doOverlay();
            } else {
                window.addEventListener('load', doOverlay);
            }
        }
    }

    // --- STREAMER STORAGE ---
    const DEFAULT_STREAMERS = ['xqc'];

    function getStreamers() {
        const stored = GM_getValue('kick_streamers', null);
        if (stored === null) {
            // First run — seed from defaults
            GM_setValue('kick_streamers', DEFAULT_STREAMERS);
            return [...DEFAULT_STREAMERS];
        }
        return stored;
    }

    function saveStreamers(list) {
        GM_setValue('kick_streamers', list);
    }

    // Initialize the app
    const app = new KickSidebarIntegration(getStreamers());
    app.init();

    // --- TAMPERMONKEY MENU COMMANDS ---
    GM_registerMenuCommand('➕ Add Kick streamer', () => {
        const input = prompt('Enter Kick username(s) to add (comma-separated, e.g. "xcq, forsen, etc..."):\n\nTip: you can map a Kick channel to a different Twitch channel by entering entries like "kickname=twitchname" (e.g. asmongold=zackrawrr).');
        if (!input) return;
        const list = getStreamers();
        const names = input.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
        const added = [];
        const skipped = [];
        for (const username of names) {
            if (list.includes(username)) {
                skipped.push(username);
            } else {
                list.push(username);
                added.push(username);
            }
        }
        if (added.length) {
            saveStreamers(list);
            app.streamers = list;
            app.pollApi();
        }
        const msg = [];
        if (added.length) msg.push(`Added: ${added.join(', ')}`);
        if (skipped.length) msg.push(`Already in list: ${skipped.join(', ')}`);
        alert(msg.join('\n') || 'No valid usernames entered.');
    });

    GM_registerMenuCommand('➖ Remove Kick streamer', () => {
        const list = getStreamers();
        if (list.length === 0) {
            alert('No streamers to remove.');
            return;
        }
        const name = prompt('Current streamers:\n' + list.join(', ') + '\n\nEnter username to remove:');
        if (!name) return;
        const username = name.trim().toLowerCase();
        const idx = list.indexOf(username);
        if (idx === -1) {
            alert(`"${username}" not found in the list.`);
            return;
        }
        list.splice(idx, 1);
        saveStreamers(list);
        app.streamers = list;
        app.pollApi();
        alert(`Removed "${username}". The sidebar will update shortly.`);
    });

    GM_registerMenuCommand('📋 List Kick streamers', () => {
        const list = getStreamers();
        if (list.length === 0) {
            alert('No Kick streamers configured.');
        } else {
            alert('Kick streamers:\n\n' + list.join('\n'));
        }
    });

})();