Adds a + button on Kick.com to manage streams in one MultiKick.com window
// ==UserScript==
// @name Kick <-> MultiKick Enhancer
// @namespace http://tampermonkey.net/
// @version 2.2
// @description Adds a + button on Kick.com to manage streams in one MultiKick.com window
// @match https://kick.com/*
// @match https://www.kick.com/*
// @match https://multikick.com/*
// @match https://www.multikick.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_addValueChangeListener
// @grant unsafeWindow
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const host = location.hostname.replace(/^www\./, '');
const pageWindow = typeof unsafeWindow === 'object' && unsafeWindow
? unsafeWindow
: window;
const WINDOW_NAME = 'multikick-window';
const MULTIKICK_ORIGIN = 'https://multikick.com';
const ADD_BUTTON_CLASS = 'mk-add-button';
const STREAMS_KEY = 'mk-streams-v2';
const QUEUE_KEY = 'mk-append-queue-v2';
const ACTIVE_KEY = 'mk-active-window-v2';
const OPENING_KEY = 'mk-opening-window-v2';
const ACTIVE_TTL_MS = 15000;
const OPENING_TTL_MS = 10000;
const QUEUE_TTL_MS = 120000;
const HEARTBEAT_INTERVAL_MS = 5000;
const ACTIVE_WRITE_MIN_MS = 4000;
const KICK_SCAN_DELAY_MS = 250;
const MULTIKICK_SCAN_DELAY_MS = 500;
const QUEUE_PROCESS_DELAY_MS = 150;
const RESERVED_KICK_PATHS = new Set([
'about',
'auth',
'categories',
'category',
'clips',
'clip',
'community-guidelines',
'dashboard',
'directory',
'following',
'games',
'legal',
'player',
'privacy',
'search',
'settings',
'store',
'subscriptions',
'support',
'terms',
'video',
'videos',
]);
const MULTIKICK_NON_STREAM_PARTS = new Set([
'chat',
'embed',
'iframe',
'player',
'popout',
'video',
'videos',
]);
function hasSharedStorage() {
return typeof GM_getValue === 'function' &&
typeof GM_setValue === 'function';
}
function gmGet(key, fallback) {
if (!hasSharedStorage()) return fallback;
try {
const value = GM_getValue(key, fallback);
return value === undefined ? fallback : value;
} catch (_err) {
return fallback;
}
}
function gmSet(key, value) {
if (!hasSharedStorage()) return false;
try {
GM_setValue(key, value);
return true;
} catch (_err) {
return false;
}
}
function gmDelete(key) {
if (typeof GM_deleteValue !== 'function') {
gmSet(key, null);
return;
}
try {
GM_deleteValue(key);
} catch (_err) {
gmSet(key, null);
}
}
function gmListen(key, callback) {
if (typeof GM_addValueChangeListener !== 'function') return;
try {
GM_addValueChangeListener(key, callback);
} catch (_err) {
// Some userscript managers expose only part of the GM_* API.
}
}
function debounce(fn, delay) {
let timer = 0;
return function debounced(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
function parseUrl(value, base = location.href) {
try {
return new URL(value, base);
} catch (_err) {
return null;
}
}
function pathParts(pathname = location.pathname) {
return pathname
.replace(/^\/+|\/+$/g, '')
.split('/')
.map(part => {
try {
return decodeURIComponent(part);
} catch (_err) {
return part;
}
})
.filter(Boolean);
}
function normalizeSlug(value) {
if (typeof value !== 'string') return '';
let slug = value.trim().replace(/^@/, '');
try {
slug = decodeURIComponent(slug);
} catch (_err) {
// Keep the original string if it was not valid percent-encoding.
}
slug = slug.trim().toLowerCase();
if (!slug) return '';
if (RESERVED_KICK_PATHS.has(slug)) return '';
if (!/^[a-z0-9_][a-z0-9_-]{0,63}$/.test(slug)) return '';
return slug;
}
function normalizeSlugs(values) {
const slugs = [];
const seen = new Set();
for (const value of values || []) {
const slug = normalizeSlug(value);
if (!slug || seen.has(slug)) continue;
slugs.push(slug);
seen.add(slug);
}
return slugs;
}
function isMultiKickNonStreamPart(value) {
const slug = normalizeSlug(value);
return Boolean(slug && MULTIKICK_NON_STREAM_PARTS.has(slug));
}
function normalizeStreamSlugs(values) {
return normalizeSlugs(values)
.filter(slug => !isMultiKickNonStreamPart(slug));
}
function sameStringList(a, b) {
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
return a.every((value, index) => value === b[index]);
}
function slugsFromPath(pathname = location.pathname) {
return normalizeStreamSlugs(pathParts(pathname));
}
function pathForSlugs(slugs) {
const clean = normalizeStreamSlugs(slugs);
return clean.length ? `/${clean.map(encodeURIComponent).join('/')}` : '/';
}
function freshRecord(key, ttl) {
const record = gmGet(key, null);
if (!record || typeof record !== 'object') return null;
if (typeof record.updatedAt !== 'number') return null;
if (Date.now() - record.updatedAt > ttl) return null;
return record;
}
function freshActiveWindow() {
return freshRecord(ACTIVE_KEY, ACTIVE_TTL_MS);
}
function freshOpeningWindow() {
return freshRecord(OPENING_KEY, OPENING_TTL_MS);
}
function readStreamState() {
const state = gmGet(STREAMS_KEY, null);
return normalizeStreamSlugs(state && Array.isArray(state.slugs) ? state.slugs : []);
}
function writeStreamState(slugs, reason) {
const clean = normalizeStreamSlugs(slugs);
const current = readStreamState();
if (sameStringList(clean, current)) {
return clean;
}
gmSet(STREAMS_KEY, {
slugs: clean,
reason,
updatedAt: Date.now(),
});
return clean;
}
function cleanQueue(queue) {
if (!Array.isArray(queue)) return [];
const now = Date.now();
return queue
.filter(item => item && typeof item === 'object')
.filter(item => typeof item.id === 'string')
.filter(item => typeof item.slug === 'string')
.filter(item => !isMultiKickNonStreamPart(item.slug))
.filter(item => typeof item.createdAt === 'number')
.filter(item => now - item.createdAt <= QUEUE_TTL_MS)
.slice(-100);
}
function readQueue() {
return cleanQueue(gmGet(QUEUE_KEY, []));
}
function writeQueue(queue) {
const clean = cleanQueue(queue);
const current = readQueue();
if (
clean.length === current.length &&
clean.every((item, index) =>
current[index] &&
item.id === current[index].id &&
item.slug === current[index].slug &&
item.createdAt === current[index].createdAt
)
) {
return;
}
gmSet(QUEUE_KEY, clean);
}
function queueSlug(slug) {
const normalized = normalizeSlug(slug);
if (!normalized || isMultiKickNonStreamPart(normalized) || !hasSharedStorage()) return '';
const now = Date.now();
const id = `${now}-${Math.random().toString(36).slice(2)}`;
const queue = readQueue().filter(item => item.slug !== normalized);
queue.push({ id, slug: normalized, createdAt: now });
writeQueue(queue);
return id;
}
function removeQueuedIds(ids) {
if (!ids || !ids.size) return;
writeQueue(readQueue().filter(item => !ids.has(item.id)));
}
function removeQueuedSlugs(slugs) {
const blocked = new Set(normalizeStreamSlugs(slugs));
if (!blocked.size) return;
writeQueue(readQueue().filter(item => !blocked.has(normalizeSlug(item.slug))));
}
function slugFromKickHref(href) {
const url = parseUrl(href, location.origin);
if (!url) return '';
const linkHost = url.hostname.replace(/^www\./, '');
if (linkHost && linkHost !== 'kick.com') return '';
const parts = pathParts(url.pathname);
if (parts.length !== 1) return '';
return normalizeSlug(parts[0]);
}
function slugFromCurrentKickPage() {
const parts = pathParts(location.pathname);
return parts.length ? normalizeSlug(parts[0]) : '';
}
function makeAddButton(slug, onAppend, styleOverrides = {}) {
const btn = document.createElement('a');
btn.className = ADD_BUTTON_CLASS;
btn.textContent = '+';
btn.href = '#';
btn.title = 'Add to MultiKick';
btn.setAttribute('aria-label', `Add ${slug} to MultiKick`);
btn.dataset.mkSlug = slug;
Object.assign(btn.style, {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginLeft: '4px',
minWidth: '1.35em',
minHeight: '1.35em',
position: 'relative',
zIndex: '2147483647',
cursor: 'pointer',
fontSize: '1em',
fontWeight: '700',
lineHeight: '1',
textDecoration: 'none',
color: 'inherit',
pointerEvents: 'auto',
userSelect: 'none',
verticalAlign: 'middle',
}, styleOverrides);
function stopAtButton(event) {
event.stopPropagation();
if (typeof event.stopImmediatePropagation === 'function') {
event.stopImmediatePropagation();
}
}
['pointerdown', 'mousedown', 'mouseup', 'touchstart', 'touchend', 'dblclick'].forEach(type => {
btn.addEventListener(type, stopAtButton, true);
});
btn.addEventListener('click', event => {
event.preventDefault();
stopAtButton(event);
const currentSlug = normalizeSlug(event.currentTarget.dataset.mkSlug || '');
if (currentSlug) onAppend(currentSlug);
}, true);
return btn;
}
function updateAddButton(btn, slug) {
btn.dataset.mkSlug = slug;
btn.title = 'Add to MultiKick';
btn.setAttribute('aria-label', `Add ${slug} to MultiKick`);
}
if (host === 'kick.com') {
function reserveOpeningWindow(slug, initialSlugs) {
if (!hasSharedStorage()) return true;
if (freshActiveWindow() || freshOpeningWindow()) return false;
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
gmSet(OPENING_KEY, {
id,
slug,
slugs: normalizeStreamSlugs(initialSlugs),
updatedAt: Date.now(),
source: location.href,
});
const reserved = freshOpeningWindow();
return Boolean(reserved && reserved.id === id);
}
function openMultiKick(slug) {
const initialSlugs = normalizeStreamSlugs([...readStreamState(), slug]);
const url = `${MULTIKICK_ORIGIN}${pathForSlugs(initialSlugs)}`;
const mkWin = pageWindow.open(url, WINDOW_NAME);
if (!mkWin) {
gmDelete(OPENING_KEY);
return;
}
try {
mkWin.focus();
} catch (_err) {
// Focusing is best-effort only.
}
}
function appendToMultiKick(slug) {
const normalized = normalizeSlug(slug);
if (!normalized) return;
if (!hasSharedStorage()) {
pageWindow.open(`${MULTIKICK_ORIGIN}/${encodeURIComponent(normalized)}`, WINDOW_NAME);
return;
}
queueSlug(normalized);
const initialSlugs = normalizeStreamSlugs([...readStreamState(), normalized]);
if (reserveOpeningWindow(normalized, initialSlugs)) {
openMultiKick(normalized);
}
}
function addButtons() {
document
.querySelectorAll('a[href^="/"], a[href^="https://kick.com/"], a[href^="https://www.kick.com/"]')
.forEach(anchor => {
const img = anchor.querySelector(':scope > img.rounded-full, :scope > picture img.rounded-full');
if (!img) return;
const slug = slugFromKickHref(anchor.getAttribute('href') || '');
if (!slug) return;
const next = anchor.nextElementSibling;
if (next && next.classList.contains(ADD_BUTTON_CLASS)) {
updateAddButton(next, slug);
return;
}
const btn = makeAddButton(slug, appendToMultiKick);
anchor.insertAdjacentElement('afterend', btn);
});
const header = document.getElementById('channel-username');
const slug = header ? slugFromCurrentKickPage() : '';
if (!header || !slug) return;
const next = header.nextElementSibling;
if (next && next.classList.contains(ADD_BUTTON_CLASS)) {
updateAddButton(next, slug);
return;
}
const btn = makeAddButton(slug, appendToMultiKick, {
marginLeft: '8px',
fontSize: '0.9em',
});
header.insertAdjacentElement('afterend', btn);
}
addButtons();
new MutationObserver(debounce(addButtons, KICK_SCAN_DELAY_MS))
.observe(document.body, { childList: true, subtree: true });
} else if (host === 'multikick.com') {
if (pageWindow.name !== WINDOW_NAME) pageWindow.name = WINDOW_NAME;
const pageHistory = pageWindow.history || history;
const instanceId = (() => {
try {
const existing = sessionStorage.getItem('mk-enhancer-instance-id');
if (existing) return existing;
const next = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
sessionStorage.setItem('mk-enhancer-instance-id', next);
return next;
} catch (_err) {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
})();
let protectedPath = location.pathname || '/';
let reloadingForAppend = false;
let lastActiveWriteAt = 0;
let lastActivePath = '';
function ownsActiveWindow() {
const active = freshActiveWindow();
return Boolean(active && active.id === instanceId);
}
function writeActiveRecord(force = false) {
if (!hasSharedStorage()) return false;
const now = Date.now();
if (!force && lastActivePath === protectedPath && now - lastActiveWriteAt < ACTIVE_WRITE_MIN_MS) {
return true;
}
gmSet(ACTIVE_KEY, {
id: instanceId,
updatedAt: now,
path: protectedPath,
url: `${location.origin}${protectedPath}`,
});
lastActiveWriteAt = now;
lastActivePath = protectedPath;
return true;
}
function claimActiveWindow(force = false) {
if (!hasSharedStorage()) return false;
const active = freshActiveWindow();
if (!force && active && active.id !== instanceId) return false;
writeActiveRecord(force || !active || active.id !== instanceId);
if (freshOpeningWindow()) gmDelete(OPENING_KEY);
return true;
}
function syncStateFromSlugs(slugs, reason) {
const next = normalizeStreamSlugs(slugs);
const previous = readStreamState();
const removed = previous.filter(slug => !next.includes(slug));
if (removed.length) removeQueuedSlugs(removed);
writeStreamState(next, reason);
return next;
}
function syncStateFromPath(pathname, reason) {
return syncStateFromSlugs(slugsFromPath(pathname), reason);
}
function samePath(a, b) {
return pathForSlugs(slugsFromPath(a)) === pathForSlugs(slugsFromPath(b));
}
function pathFromHistoryUrl(url) {
if (url === undefined || url === null) return null;
const parsed = parseUrl(String(url), location.href);
if (!parsed) return null;
if (parsed.origin !== location.origin) return null;
return parsed.pathname || '/';
}
function updateActiveRecord() {
if (!ownsActiveWindow()) return;
writeActiveRecord(false);
}
function protectHistoryUrl(url) {
const nextPath = pathFromHistoryUrl(url);
if (!nextPath) return url;
if (nextPath === '/' && protectedPath !== '/') {
return protectedPath;
}
protectedPath = nextPath || '/';
syncStateFromPath(protectedPath, 'history');
updateActiveRecord();
return url;
}
function wrapHistoryMethod(original) {
return function wrappedHistoryMethod(state, title, url) {
const protectedUrl = protectHistoryUrl(url);
return original.call(this, state, title || '', protectedUrl);
};
}
pageHistory.pushState = wrapHistoryMethod(pageHistory.pushState);
pageHistory.replaceState = wrapHistoryMethod(pageHistory.replaceState);
function setMultiKickPath(slugs, shouldReload) {
const nextPath = pathForSlugs(slugs);
protectedPath = nextPath;
syncStateFromSlugs(slugs, 'script');
updateActiveRecord();
if (!samePath(location.pathname, nextPath)) {
pageHistory.replaceState(null, '', nextPath);
}
if (shouldReload) {
reloadingForAppend = true;
setTimeout(() => location.reload(), 50);
}
}
function processAppendQueue() {
if (!hasSharedStorage() || !ownsActiveWindow()) return;
let queue = readQueue();
if (!queue.length) return;
const next = syncStateFromPath(location.pathname, 'before-queue');
queue = readQueue();
if (!queue.length) return;
const processedIds = new Set();
const seen = new Set(next);
let changed = false;
for (const item of queue) {
processedIds.add(item.id);
const slug = normalizeSlug(item.slug);
if (!slug || seen.has(slug)) continue;
next.push(slug);
seen.add(slug);
changed = true;
}
removeQueuedIds(processedIds);
if (changed) {
setMultiKickPath(next, true);
}
}
function maintainActiveWindow(force = false) {
if (claimActiveWindow(force)) {
syncStateFromPath(location.pathname, 'heartbeat');
processAppendQueue();
}
}
window.addEventListener('popstate', () => {
if (location.pathname === '/' && protectedPath !== '/') {
pageHistory.replaceState(null, '', protectedPath);
return;
}
protectedPath = location.pathname || '/';
syncStateFromPath(protectedPath, 'popstate');
updateActiveRecord();
});
function slugFromIframeSrc(src) {
const url = parseUrl(src);
if (!url) return '';
for (const key of ['channel', 'streamer', 'slug', 'username']) {
const slug = normalizeSlug(url.searchParams.get(key) || '');
if (slug) return slug;
}
const ignored = new Set(['embed', 'iframe', 'player', 'popout', 'chat', 'video', 'videos']);
const parts = pathParts(url.pathname);
for (let i = parts.length - 1; i >= 0; i -= 1) {
const slug = normalizeSlug(parts[i]);
if (slug && !ignored.has(slug)) return slug;
}
return '';
}
function iframeNearButton(btn) {
const relative = btn.closest('div.relative');
if (relative) {
const iframe = relative.querySelector('iframe[src]');
if (iframe) return iframe;
}
for (let node = btn; node && node !== document.body; node = node.parentElement) {
const iframe = node.querySelector('iframe[src]');
if (iframe) return iframe;
}
return null;
}
function removeSlugFromMultiKick(slug) {
const target = normalizeSlug(slug);
if (!target) return;
removeQueuedSlugs([target]);
setMultiKickPath(slugsFromPath(location.pathname).filter(part => part !== target), false);
}
function hookDeletes() {
document
.querySelectorAll('button[aria-label="delete stream" i]')
.forEach(btn => {
if (btn.dataset.mkHooked) return;
btn.dataset.mkHooked = '1';
btn.addEventListener('click', () => {
const iframe = iframeNearButton(btn);
if (!iframe) return;
removeSlugFromMultiKick(slugFromIframeSrc(iframe.getAttribute('src') || ''));
}, true);
});
}
const scheduleProcessAppendQueue = debounce(processAppendQueue, QUEUE_PROCESS_DELAY_MS);
const scheduleHookDeletes = debounce(hookDeletes, MULTIKICK_SCAN_DELAY_MS);
gmListen(QUEUE_KEY, scheduleProcessAppendQueue);
window.addEventListener('focus', () => maintainActiveWindow(true));
window.addEventListener('pageshow', () => maintainActiveWindow(false));
window.addEventListener('pagehide', () => {
if (!reloadingForAppend && ownsActiveWindow()) {
gmDelete(ACTIVE_KEY);
}
});
syncStateFromPath(location.pathname, 'load');
maintainActiveWindow(false);
hookDeletes();
setInterval(() => maintainActiveWindow(false), HEARTBEAT_INTERVAL_MS);
new MutationObserver(scheduleHookDeletes)
.observe(document.body, { childList: true, subtree: true });
}
})();