Kick <-> MultiKick Enhancer

Adds a + button on Kick.com to manage streams in one MultiKick.com window

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!)

Advertisement:

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!)

Advertisement:

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