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.

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.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

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         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'));
        }
    });

})();