Kick Embed Volume and Playback Speed

04/11/2025, 20:20:30

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

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        Kick Embed Volume and Playback Speed
// @namespace   yuniDev.kickembedcontrols
// @match       https://player.kick.com/*
// @grant       none
// @version     1.0
// @author      yuniDev
// @description 04/11/2025, 20:20:30
// @license     MIT
// ==/UserScript==

function waitForElement(selector, root = document.body) {
  return new Promise((resolve) => {
    const element = document.querySelector(selector);
    if (element) {
      resolve(element);
      return;
    }

    const observer = new MutationObserver(() => {
      const found = document.querySelector(selector);
      if (found) {
        observer.disconnect();
        resolve(found);
      }
    });

    observer.observe(root, {
      childList: true,
      subtree: true,
    });
  });
}

function addTooltip(element, tooltipText) {
  element.addEventListener("mouseenter", () => {
    const tooltip = document.createElement("div");
    tooltip.setAttribute("data-side", "top");
    tooltip.setAttribute("data-align", "center");
    tooltip.setAttribute("data-state", "delayed-open");
    tooltip.className = "z-tooltip select-none rounded-md bg-white p-[5px] text-sm font-medium leading-5 text-black data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade will-change-[transform,opacity]";
    tooltip.style.cssText = `
      position: fixed;
      z-index: 801;
      pointer-events: none;
    `;
    tooltip.innerHTML = `
      ${tooltipText}
      <span style="position: absolute; bottom: -5px; left: 50%; transform: translateX(-50%);">
        <svg class="fill-white" width="10" height="5" viewBox="0 0 30 10" preserveAspectRatio="none"><polygon points="0,0 30,0 15,10"></polygon></svg>
      </span>
    `;
    document.body.appendChild(tooltip);
    const rect = element.getBoundingClientRect();
    tooltip.style.left = (rect.left + rect.width / 2 - tooltip.offsetWidth / 2) + "px";
    tooltip.style.top = (rect.top - tooltip.offsetHeight - 2.5) + "px";
    element._tooltip = tooltip;
  });
  element.addEventListener("mouseleave", () => {
    if (element._tooltip) {
      element._tooltip.remove();
      element._tooltip = null;
    }
  });
}

function createSpeedSelector(video, toolbar) {
  const btn = document.createElement("button");
  btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-timer-icon lucide-timer"><line x1="10" x2="14" y1="2" y2="2"/><line x1="12" x2="15" y1="14" y2="11"/><circle cx="12" cy="14" r="8"/></svg>`;
  btn.style.cssText = `
    background: none;
    border: none;
    cursor: pointer;
    padding: 8px;
    margin-right: -8px;
    color: white;
  `;

  addTooltip(btn, "Playback Speed");

  const menu = document.createElement("div");
  menu.setAttribute("data-side", "top");
  menu.setAttribute("data-align", "center");
  menu.setAttribute("data-state", "delayed-open");
  menu.style.cssText = `
    position: fixed;
    background: white;
    border-radius: 4px;
    box-shadow: 0 2px 8px rgba(0,0,0,0.15);
    display: none;
    z-index: 100;
    overflow: hidden;
  `;
  menu.className = "data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade will-change-[transform,opacity]";

  let closeTimeout;

  const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2];
  speeds.forEach((speed) => {
    const option = document.createElement("div");
    option.textContent = speed + "x";
    option.style.cssText = `
      padding: 8px 16px;
      cursor: pointer;
      color: black;
      font-size: 14px;
      transition: background-color 0.2s;
    `;
    option.addEventListener("mouseenter", () => {
      option.style.backgroundColor = "#f0f0f0";
    });
    option.addEventListener("mouseleave", () => {
      option.style.backgroundColor = "transparent";
    });
    option.addEventListener("click", () => {
      video.playbackRate = speed;
      menu.style.display = "none";
    });
    menu.appendChild(option);
  });

  document.body.appendChild(menu);

  btn.addEventListener("click", (e) => {
    e.stopPropagation();
    if (btn._tooltip) {
      btn._tooltip.remove();
      btn._tooltip = null;
    }
    if (menu.style.display === "none") {
      menu.style.display = "block";
      const rect = btn.getBoundingClientRect();
      menu.style.left = (rect.left + rect.width / 2 - menu.offsetWidth / 2) + "px";
      menu.style.top = (rect.top - menu.offsetHeight - 8) + "px";
    } else {
      menu.style.display = "none";
    }
  });

  btn.addEventListener("mouseleave", () => {
    closeTimeout = setTimeout(() => {
      menu.style.display = "none";
    }, 100);
  });

  menu.addEventListener("mouseenter", () => {
    clearTimeout(closeTimeout);
  });

  menu.addEventListener("mouseleave", () => {
    menu.style.display = "none";
  });

  toolbar.lastChild.prepend(btn);
}

(async () => {
  const video = await waitForElement("video");
  const toolbar = video.previousElementSibling;
  const lastButton = toolbar.querySelector("button:last-of-type");

  video.addEventListener("timeupdate", () => {
    if (video.buffered.length <= 0) return;
    const bufferedEnd = video.buffered.end(video.buffered.length - 1);
    const isLive = Math.abs(bufferedEnd - video.currentTime) < 1;
    if (isLive && video.playbackRate > 1) video.playbackRate = 1;
  });

  const reloadBtn = lastButton.cloneNode(true);
  reloadBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-cw-icon lucide-rotate-cw"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>`;
  reloadBtn.addEventListener("click", () => {
    location.reload();
  });
  reloadBtn.firstChild.style = "fill: none;"; // Existing styling likes to fill, breaking the svg
  addTooltip(reloadBtn, "Reload");
  lastButton.before(reloadBtn);

  const slider = document.createElement("input");
  slider.type = "range";
  slider.min = "0";
  slider.max = "1";
  slider.step = "0.01";
  slider.value = 0.6; // Kick defaults to this with a random-ish delay idk why

  slider.addEventListener("input", (e) => {
    video.volume = parseFloat(e.target.value);
  });

  lastButton.after(slider);

  createSpeedSelector(video, toolbar);
})();