Adds Kick livestreams to Twitch's sidebar, and plays them within Twitch when clicked. Select streamers via the Tampermonkey browser addon menu.
// ==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'));
}
});
})();