Kick Player Quality Changer (one-shot)

Auto-pick your preferred quality on Kick exactly once per stream/VOD.

// ==UserScript==
// @name            Kick Player Quality Changer (one-shot)
// @description     Auto-pick your preferred quality on Kick exactly once per stream/VOD.
// @namespace       https://github.com/you/kick-quality
// @version         0.3.0
// @author          elfahdo
// @match           https://kick.com/*
// @match           https://*.kick.com/*
// @icon            https://www.google.com/s2/favicons?sz=64&domain=kick.com
// @license         MIT
// @grant           none
// @run-at          document-end
// ==/UserScript==

(function () {
  'use strict';

  // ── customize ────────────────────────────────────────────────────────────────
  // Common: "1080p60","1080p","720p60","720p","480p","360p"
  const PreferredQuality = "360p";
  const AllQualities = ["1080p60","1080p","720p60","720p","480p","360p"];
  // ─────────────────────────────────────────────────────────────────────────────

  const log = (...a) => console.log("[KickQuality]", ...a);

  // one-shot guards
  const DoneKeys = new Set();             // remembers streams/VODs we handled
  let href = location.href;                // SPA URL change detection

  // ——— helpers ———
  function findMainVideo() {
    const vids = Array.from(document.querySelectorAll('video'))
      .filter(v => v.offsetParent !== null && v.clientWidth*v.clientHeight > 0);
    vids.sort((a,b) => (b.clientWidth*b.clientHeight) - (a.clientWidth*a.clientHeight));
    return vids[0] || null;
  }

  // derive a key that changes when stream/VOD changes
  function getVideoKey(v) {
    const src = (v.currentSrc || v.src || "").split("?")[0];
    // fallback to pathname if src not ready yet
    return `${location.pathname}::${src || "no-src"}`;
  }

  function looksLikeAvatar(btn) {
    if (btn.closest('header')) return true;
    if (btn.querySelector('img,[data-testid*="avatar" i]')) return true;
    const aria = (btn.getAttribute('aria-label') || "").toLowerCase();
    return /\b(profile|account|avatar|user)\b/.test(aria);
  }

  function center(el) {
    const r = el.getBoundingClientRect();
    return { x:r.left+r.width/2, y:r.top+r.height/2 };
  }
  function isOverRect(el, rect) {
    const c = center(el);
    return c.x >= rect.left && c.x <= rect.right && c.y >= rect.top && c.y <= rect.bottom + 60;
  }
  function distToBottomRight(el, rect) {
    const c = center(el);
    return Math.hypot(rect.right - c.x, rect.bottom - c.y);
  }

  function findSettingsButton(video) {
    if (!video) return null;
    const vRect = video.getBoundingClientRect();

    // 1) aria-label contains "Settings" and sits over/near the video
    const explicit = Array.from(document.querySelectorAll('button[aria-haspopup="menu"]'))
      .find(b => /settings/i.test(b.getAttribute('aria-label') || "") && isOverRect(b, vRect));
    if (explicit) return explicit;

    // 2) any menu button over the video area, pick bottom-right-most (typical cog spot)
    const menuish = Array.from(document.querySelectorAll('button[aria-haspopup="menu"]'))
      .filter(b => isOverRect(b, vRect) && !looksLikeAvatar(b));
    menuish.sort((a,b) => distToBottomRight(a, vRect) - distToBottomRight(b, vRect));
    return menuish[0] || null;
  }

  function simulateClick(el) {
    if (!el) return;
    el.focus();
    ['pointerover','pointerenter','pointerdown','mousedown','pointerup','mouseup','click']
      .forEach(type => el.dispatchEvent(new PointerEvent(type, {
        bubbles:true, cancelable:true, composed:true,
        pointerId:1, pointerType:'mouse', isPrimary:true
      })));
  }

  function pickQualityOnce(closeMenuCb) {
    const items = Array.from(document.querySelectorAll('[role="menuitemradio"]'));
    if (!items.length) return false;

    const clean = t => t.trim().replace(/\s+/g,'');
    let picked = false;

    // exact
    const exact = items.find(i => clean(i.textContent) === clean(PreferredQuality));
    if (exact) { exact.click(); picked = true; }

    // fallback along our list
    if (!picked) {
      const start = AllQualities.indexOf(PreferredQuality);
      if (start >= 0) {
        for (let i=start; i<AllQualities.length && !picked; i++) {
          const q = AllQualities[i];
          const node = items.find(it => clean(it.textContent) === clean(q));
          if (node) { node.click(); picked = true; }
        }
      }
    }

    // lowest non-auto
    if (!picked) {
      const nonAuto = items.filter(i => !/auto/i.test(i.textContent));
      const last = nonAuto[nonAuto.length-1] || items[items.length-1];
      if (last) { last.click(); picked = true; }
    }

    if (picked && typeof closeMenuCb === 'function') closeMenuCb();
    return picked;
  }

  // ——— main one-shot runner ———
  function runOnceForThisVideo(maxTries = 20) {
    let tries = 0;
    const tick = () => {
      const v = findMainVideo();
      if (!v) return (++tries < maxTries) && setTimeout(tick, 250);

      const key = getVideoKey(v);
      if (DoneKeys.has(key)) return; // already handled this stream/VOD

      const cog = findSettingsButton(v);
      if (!cog) return (++tries < maxTries) && setTimeout(tick, 250);

      // open menu
      simulateClick(cog);

      // wait briefly for radios to mount, then pick once
      let waited = 0;
      const waitForMenu = setInterval(() => {
        waited++;
        const success = pickQualityOnce(() => simulateClick(cog)); // close after pick
        if (success || waited > 12) { // ~3s max
          clearInterval(waitForMenu);
          if (success) {
            DoneKeys.add(key);       // ✅ mark done — prevents repeats
            log("Applied once for:", key);
          }
        }
      }, 250);
    };
    tick();
  }

  // initial attempt
  runOnceForThisVideo();

  // rerun on SPA URL changes (but still one-shot thanks to DoneKeys)
  setInterval(() => {
    if (location.href !== href) {
      href = location.href;
      runOnceForThisVideo();
    }
  }, 500);

  // rerun if a *new* <video> element mounts or src changes (player remounts/ads)
  const mo = new MutationObserver(() => {
    // if a new video appeared with a new key, we’ll handle it once
    runOnceForThisVideo();
  });
  mo.observe(document.documentElement, { childList: true, subtree: true });

  // also watch for src changes on the main video (some players swap sources)
  const srcWatch = setInterval(() => {
    const v = findMainVideo();
    if (!v) return;
    const key = getVideoKey(v);
    if (!DoneKeys.has(key)) runOnceForThisVideo(); // handle once if new src
  }, 1500);
})();