Kick Embed Volume and Playback Speed

04/11/2025, 20:20:30

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        Kick Embed Volume and Playback Speed
// @namespace   yuniDev.kickembedcontrols
// @match       https://player.kick.com/*
// @grant       none
// @version     1.0.1
// @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);
    // Set muted unmuted button state when changing volume
    if (video.volume == 0) {
      video.muted = true;
    } else if (video.muted) {
      video.muted = false;
    }
  });

  // Restore volume to slider when unmuted
  video.addEventListener("volumechange", () => {
    if (!video.muted) {
      video.volume = slider.value;
      slider.style.accentColor = "unset";
    } else {
      slider.style.accentColor = "darkred";
    }
  });

  lastButton.after(slider);

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