Play with MPV (Enhanced)

Combines a floating button, thumbnail clicks, and a Shift+Click fallback context menu to play videos in MPV.

Från och med 2025-07-09. Se den senaste versionen.

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

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         Play with MPV (Enhanced)
// @namespace    play-with-mpv-enhanced
// @version      2025.07.09.5
// @description  Combines a floating button, thumbnail clicks, and a Shift+Click fallback context menu to play videos in MPV.
// @author       Akatsuki Rui, nSinister (Merged by Gabreek)
// @license      MIT License
// @require      https://cdn.jsdelivr.net/gh/sizzlemctwizzle/GM_config@06f2015c04db3aaab9717298394ca4f025802873/gm_config.js
// @grant        GM_info
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_notification
// @run-at       document-idle
// @noframes
// @match        *://*.youtube.com/*
// @match        *://*.twitch.tv/*
// @match        *://*.crunchyroll.com/*
// @match        *://*.bilibili.com/*
// @match        *://*.kick.com/*
// @match        *://vimeo.com/*
// @match        *://*/*
// ==/UserScript==

"use strict";

// --- METADATA AND CONSTANTS ---

const MPV_HANDLER_VERSION = "v0.3.15";

const ICON_MPV =
  "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0\
PSI2NCIgdmVyc2lvbj0iMSI+CiA8Y2lyY2xlIHN0eWxlPSJvcGFjaXR5Oi4yIiBjeD0iMzIiIGN5\
PSIzMyIgcj0iMjgiLz4KIDxjaXJjbGUgc3R5bGU9ImZpbGw6IzhkMzQ4ZSIgY3g9IjMyIiBjeT0i\
MzIiIHI9IjI4Ii8+CiA8Y2lyY2xlIHN0eWxlPSJvcGFjaXR5Oi4zIiBjeD0iMzQuNSIgY3k9IjI5\
LjUiIHI9IjIwLjUiLz4KIDxjaXJjbGUgc3R5bGU9Im9wYWNpdHk6LjIiIGN4PSIzMiIgY3k9IjMz\
IiByPSIxNCIvPgogPGNpcmNsZSBzdHlsZT0iZmlsbDojZmZmZmZmIiBjeD0iMzIiIGN5PSIzMiIg\
cj0iMTQiLz4KIDxwYXRoIHN0eWxlPSJmaWxsOiM2OTFmNjkiIHRyYW5zZm9ybT0ibWF0cml4KDEu\
NTE1NTQ0NSwwLDAsMS41LC0zLjY1Mzg3OSwtNC45ODczODQ4KSIgZD0ibTI3LjE1NDUxNyAyNC42\
NTgyNTctMy40NjQxMDEgMi0zLjQ2NDEwMiAxLjk5OTk5OXYtNC0zLjk5OTk5OWwzLjQ2NDEwMiAy\
eiIvPgogPHBhdGggc3R5bGU9ImZpbGw6I2ZmZmZmZjtvcGFjaXR5Oi4xIiBkPSJNIDMyIDQgQSAy\
OCAyOCAwIDAgMCA0IDMyIEEgMjggMjggMCAwIDAgNC4wMjE0ODQ0IDMyLjU4NTkzOCBBIDI4IDI4\
IDAgMCAxIDMyIDUgQSAyOCAyOCAwIDAgMSA1OS45Nzg1MTYgMzIuNDE0MDYyIEEgMjggMjggMCAw\
IDAgNjAgMzIgQSAyOCAyOCAwIDAgMCAzMiA0IHoiLz4KPC9zdmc+Cg==";

const ICON_SETTINGS =
  "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0\
PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij4KIDxkZWZzPgogIDxzdHlsZSBpZD0iY3VycmVudC1j\
b2xvci1zY2hlbWUiIHR5cGU9InRleHQvY3NzIj4KICAgLkNvbG9yU2NoZW1lLVRleHQgeyBjb2xv\
cjojNDQ0NDQ0OyB9IC5Db2xvclNjaGVtZS1IaWdobGlnaHQgeyBjb2xvcjojNDI4NWY0OyB9CiAg\
PC9zdHlsZT4KIDwvZGVmcz4KIDxwYXRoIHN0eWxlPSJmaWxsOmN1cnJlbnRDb2xvciIgY2xhc3M9\
IkNvbG9yU2NoZW1lLVRleHQiIGQ9Ik0gNi4yNSAxIEwgNi4wOTU3MDMxIDIuODQzNzUgQSA1LjUg\
NS41IDAgMCAwIDQuNDg4MjgxMiAzLjc3MzQzNzUgTCAyLjgxMjUgMi45ODQzNzUgTCAxLjA2MjUg\
Ni4wMTU2MjUgTCAyLjU4Mzk4NDQgNy4wNzIyNjU2IEEgNS41IDUuNSAwIDAgMCAyLjUgOCBBIDUu\
NSA1LjUgMCAwIDAgMi41ODAwNzgxIDguOTMxNjQwNiBMIDEuMDYyNSA5Ljk4NDM3NSBMIDIuODEy\
NSAxMy4wMTU2MjUgTCA0LjQ4NDM3NSAxMi4yMjg1MTYgQSA1LjUgNS41IDAgMCAwIDYuMDk1NzAz\
MSAxMy4xNTIzNDQgTCA2LjI0NjA5MzggMTUuMDAxOTUzIEwgOS43NDYwOTM4IDE1LjAwMTk1MyBM\
IDkuOTAwMzkwNiAxMy4xNTgyMDMgQSA1LjUgNS41IDAgMCAwIDExLjUwNzgxMiAxMi4yMjg1MTYg\
TCAxMy4xODM1OTQgMTMuMDE3NTc4IEwgMTQuOTMzNTk0IDkuOTg2MzI4MSBMIDEzLjQxMjEwOSA4\
LjkyOTY4NzUgQSA1LjUgNS41IDAgMCAwIDEzLjQ5NjA5NCA4LjAwMTk1MzEgQSA1LjUgNS41IDAg\
MCAwIDEzLjQxNjAxNiA3LjA3MDMxMjUgTCAxNC45MzM1OTQgNi4wMTc1NzgxIEwgMTMuMTgzNTk0\
IDIuOTg2MzI4MSBMIDExLjUxMTcxOSAzLjc3MzQzNzUgQSA1LjUgNS41IDAgMCAwIDkuOTAwMzkw\
NiAyLjg0OTYwOTQgTCA5Ljc1IDEgTCA2LjI1IDEgeiBNIDggNiBBIDIgMiAwIDAgMSAxMCA4IEEg\
MiAyIDAgMCAxIDggMTAgQSAyIDIgMCAwIDEgNiA4IEEgMiAyIDAgMCAxIDggNiB6IiB0cmFuc2Zv\
cm09InRyYW5zbGF0ZSg0IDQpIi8+Cjwvc3ZnPgo=";

// Unified configuration for supported sites
const SITES = {
  "www.youtube.com": {
    watchPaths: { mode: true, list: ["/watch", "/playlist", "/shorts"] },
    thumbSelector: "a#thumbnail.ytd-thumbnail",
    thumbNeedsFullUrl: true,
  },
  "m.youtube.com": {
    watchPaths: { mode: true, list: ["/watch", "/playlist", "/shorts"] },
    thumbSelector: "a.media-item-thumbnail-container",
    thumbNeedsFullUrl: true,
  },
  "www.twitch.tv": {
    watchPaths: { mode: false, list: ["/directory", "/downloads", "/jobs", "/p", "/turbo"] },
  },
  "www.crunchyroll.com": {
    watchPaths: { mode: true, list: ["/watch"] },
  },
  "www.bilibili.com": {
    watchPaths: { mode: true, list: ["/bangumi/play", "/video"] },
    thumbSelector: "a.bili-video-card__image--link",
    thumbNeedsFullUrl: false,
  },
  "live.bilibili.com": {
    watchPaths: { mode: false, list: ["/p"] },
  },
  "kick.com": {
    watchPaths: { mode: false, list: ["/browse", "/category"] },
    thumbSelector: "a.card-thumbnail",
    thumbNeedsFullUrl: true,
  },
  "vimeo.com": {
    watchPaths: { mode: true, list: ["/"] },
    thumbSelector: "a.iris_video-vital__overlay",
    thumbNeedsFullUrl: false,
  },
};

// --- CSS ---

const css = String.raw;

const MPV_CSS = css`
  .play-with-mpv { z-index: 99999; position: fixed; left: 8px; bottom: 8px; width: 48px; height: 48px; }
  .pwm-play { width: 48px; height: 48px; border: 0; border-radius: 50%; background-size: 48px; background-image: url(data:image/svg+xml;base64,${ICON_MPV}); background-repeat: no-repeat; display: block; cursor: pointer; }
  .pwm-settings { opacity: 0; visibility: hidden; transition: all 0.2s ease-in-out; display: block; position: absolute; top: -32px; left: 8px; width: 32px; height: 32px; border: 0; border-radius: 50%; background-size: 32px; background-color: #eeeeee; background-image: url(data:image/svg+xml;base64,${ICON_SETTINGS}); background-repeat: no-repeat; cursor: pointer; }
  .play-with-mpv:hover .pwm-settings { opacity: 1; visibility: visible; }
`;

const CONTEXT_MENU_CSS = css`
  #pwm-context-menu { position: fixed; z-index: 100000; display: none; background-color: #ffffff; border: 1px solid #ccc; border-radius: 5px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); padding: 5px 0; min-width: 180px; }
  #pwm-context-menu-item { padding: 8px 15px; font-size: 14px; color: #333; cursor: pointer; display: flex; align-items: center; gap: 10px; }
  #pwm-context-menu-item:hover { background-color: #f0f0f0; }
  #pwm-context-menu-item img { width: 16px; height: 16px; }
`;

const CONFIG_ID = "play-with-mpv";

const CONFIG_CSS = css`
  body { display: flex; justify-content: center; background-color: #f0f0f0; }
  #${CONFIG_ID}_wrapper { display: flex; flex-direction: column; justify-content: center; background-color: white; border-radius: 8px; padding: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
  #${CONFIG_ID} .config_header { display: flex; align-items: center; padding: 12px; font-size: 18px; font-weight: bold; color: #333; border-bottom: 1px solid #ddd; margin-bottom: 15px; }
  #${CONFIG_ID} .config_var { margin: 0 0 12px 0; display: flex; align-items: center; justify-content: space-between; }
  #${CONFIG_ID} .field_label { display: inline-block; width: 150px; font-size: 14px; color: #555; }
  #${CONFIG_ID}_field_cookies, #${CONFIG_ID}_field_profile, #${CONFIG_ID}_field_quality, #${CONFIG_ID}_field_v_codec, #${CONFIG_ID}_field_console, #${CONFIG_ID}_field_profile { width: 100px; height: 28px; font-size: 14px; text-align: center; border: 1px solid #ccc; border-radius: 4px; }
  #${CONFIG_ID}_field_profile { text-align: left; padding-left: 5px; }
  #${CONFIG_ID}_buttons_holder { display: flex; flex-direction: column; margin-top: 10px; }
  #${CONFIG_ID} .saveclose_buttons { margin: 2px; padding: 8px 0; border-radius: 5px; border: none; cursor: pointer; font-size: 14px; background-color: #4285f4; color: white; }
  #${CONFIG_ID} .reset_holder { padding-top: 4px; text-align: center; }
  #${CONFIG_ID}_reset{ color: #777; font-size: 12px; cursor: pointer; }
`;

const CONFIG_IFRAME_CSS = css`
  position: fixed; z-index: 99999; width: 340px; height: 420px; border: 1px solid #ccc; border-radius: 10px; box-shadow: 0 5px 15px rgba(0,0,0,0.2); top: 50%; left: 50%; transform: translate(-50%, -50%);
`;

const CONFIG_FIELDS = {
  cookies: { label: "Try to Pass Cookies", type: "select", options: ["yes", "no"], default: "no" },
  profile: { label: "MPV Profile", type: "text", default: "default" },
  quality: { label: "Video Quality", type: "select", options: ["default", "2160p", "1440p", "1080p", "720p", "480p", "360p"], default: "default" },
  v_codec: { label: "Video Codec", type: "select", options: ["default", "av01", "vp9", "h265", "h264"], default: "default" },
  console: { label: "Run With Console", type: "select", options: ["yes", "no"], default: "yes" },
};

// --- GM_CONFIG INITIALIZATION ---

GM_config.init({
  id: CONFIG_ID,
  title: GM_info.script.name,
  fields: CONFIG_FIELDS,
  events: {
    init: () => {
      // Ensure saved values are valid
      let quality = GM_config.get("quality").toLowerCase();
      let v_codec = GM_config.get("v_codec").toLowerCase();
      if (!CONFIG_FIELDS.quality.options.includes(quality)) GM_config.set("quality", "default");
      if (!CONFIG_FIELDS.v_codec.options.includes(v_codec)) GM_config.set("v_codec", "default");
    },
    save: () => {
      let profile = GM_config.get("profile").trim();
      GM_config.set("profile", profile === "" ? "default" : profile);
      updatePlayButton();
      GM_config.close();
    },
    reset: () => { GM_config.save(); },
  },
  css: CONFIG_CSS.trim(),
});

// --- CORE FUNCTIONS ---

function btoaUrl(url) {
  return btoa(url).replace(/\//g, "_").replace(/\+/g, "-").replace(/=/g, "");
}

function generateProto(url) {
  const config = {
    cookies: GM_config.get("cookies").toLowerCase(),
    profile: GM_config.get("profile").trim(),
    quality: GM_config.get("quality").toLowerCase(),
    v_codec: GM_config.get("v_codec").toLowerCase(),
    console: GM_config.get("console").toLowerCase(),
  };
  let proto = config.console === "yes" ? "mpv-debug://play/" : "mpv://play/";
  proto += btoaUrl(url);
  const options = [];
  if (config.cookies === "yes") options.push("cookies=" + document.location.hostname + ".txt");
  if (config.profile !== "default" && config.profile !== "") options.push("profile=" + config.profile);
  if (config.quality !== "default") options.push("quality=" + config.quality);
  if (config.v_codec !== "default") options.push("v_codec=" + config.v_codec);
  if (options.length > 0) proto += "/?" + options.join("&");
  return proto;
}

function isWatchPage() {
  const siteConfig = SITES[location.hostname];
  if (siteConfig && siteConfig.watchPaths) {
    const { mode, list } = siteConfig.watchPaths;
    const path = location.pathname;
    for (const item of list) {
      if (path.startsWith(item) && (path.length === item.length || path.charAt(item.length) === "/")) {
        return mode;
      }
    }
    // For unlisted paths (e.g., YouTube home), the behavior is the inverse of the mode.
    if (path !== "/") return !mode;
  }
  return false;
}

function updatePlayButton() {
  const button = document.querySelector(".pwm-play");
  if (!button) return;
  const shouldShow = isWatchPage() && !document.fullscreenElement;
  button.style.display = shouldShow ? "block" : "none";
  if (shouldShow) button.href = generateProto(location.href);
}

function createControls() {
  const style = document.createElement("style");
  style.textContent = MPV_CSS.trim();
  document.head.appendChild(style);
  const container = document.createElement("div");
  container.className = "play-with-mpv";
  const buttonPlay = document.createElement("a");
  buttonPlay.className = "pwm-play";
  buttonPlay.addEventListener("click", (e) => {
    const video = document.querySelector("video");
    if (video) video.pause();
    if (e.stopPropagation) e.stopPropagation();
  });
  const buttonSettings = document.createElement("button");
  buttonSettings.className = "pwm-settings";
  buttonSettings.addEventListener("click", () => {
    if (!GM_config.isOpen) {
      GM_config.open();
      GM_config.frame.style = CONFIG_IFRAME_CSS.trim();
    }
  });
  container.appendChild(buttonPlay);
  container.appendChild(buttonSettings);
  document.body.appendChild(container);
  document.addEventListener("fullscreenchange", updatePlayButton);
}

function processThumbnails() {
    const siteConfig = SITES[location.hostname];
    if (!siteConfig || !siteConfig.thumbSelector) return;
    const elements = document.querySelectorAll(siteConfig.thumbSelector);
    elements.forEach(el => {
        if (el.dataset.mpvReady) return;
        el.dataset.mpvReady = "true";
        el.addEventListener('click', function(event) {
            event.preventDefault();
            event.stopPropagation();
            let href = el.getAttribute('href');
            if (!href) return;
            let fullUrl = siteConfig.thumbNeedsFullUrl ? (new URL(href, location.origin)).href : href;
            location.href = generateProto(fullUrl);
        }, true);
    });
}

// --- CONTEXT MENU FUNCTIONS ---

function createContextMenu() {
    const style = document.createElement("style");
    style.textContent = CONTEXT_MENU_CSS.trim();
    document.head.appendChild(style);
    const menu = document.createElement("div");
    menu.id = "pwm-context-menu";
    const item = document.createElement("div");
    item.id = "pwm-context-menu-item";
    item.innerHTML = `<img src="data:image/svg+xml;base64,${ICON_MPV}" alt="MPV Icon"> <span>Play with MPV</span>`;
    menu.appendChild(item);
    document.body.appendChild(menu);
    item.addEventListener('click', () => {
        const url = menu.dataset.url;
        if (url) location.href = generateProto(url);
        hideContextMenu();
    });
}

function hideContextMenu() {
    const menu = document.getElementById('pwm-context-menu');
    if (menu) menu.style.display = 'none';
}

/**
 * Shows the context menu at the mouse event's position.
 * Activated with Shift+Click on unlisted sites.
 * @param {MouseEvent} event - The right-click mouse event.
 */
function showContextMenu(event) {
    // If the current site already has explicit support, do nothing.
    if (SITES[location.hostname]) {
        return;
    }

    // If the Shift key is not pressed, allow the default menu.
    if (!event.shiftKey) {
        return;
    }

    const linkElement = event.target.closest('a[href]');
    if (!linkElement || !linkElement.href) {
        hideContextMenu();
        return; // Not a valid link, allow the default menu even with Shift.
    }

    event.preventDefault();
    event.stopPropagation();
    const menu = document.getElementById('pwm-context-menu');
    menu.dataset.url = linkElement.href;
    
    const x = Math.min(event.clientX, window.innerWidth - menu.offsetWidth - 10);
    const y = Math.min(event.clientY, window.innerHeight - menu.offsetHeight - 10);

    menu.style.top = `${y}px`;
    menu.style.left = `${x}px`;
    menu.style.display = 'block';
}

// --- OBSERVERS AND INITIALIZATION ---

function notifyUpdate() {
  if (GM_getValue("mpvHandlerVersion") !== MPV_HANDLER_VERSION) {
    GM_notification({
      title: GM_info.script.name,
      text: `mpv-handler has been updated to ${MPV_HANDLER_VERSION}\n\nClick to see the news.`,
      onclick: () => window.open("https://github.com/akiirui/mpv-handler/releases/latest"),
    });
    GM_setValue("mpvHandlerVersion", MPV_HANDLER_VERSION);
  }
}

function startObservers() {
    // Only run observers on explicitly supported sites to prevent overhead.
    if (!SITES[location.hostname]) return;

    let lastUrl = location.href;
    const observerCallback = () => {
        const url = location.href;
        if (url !== lastUrl) {
            lastUrl = url;
            setTimeout(() => {
                updatePlayButton();
                processThumbnails();
            }, 500);
        }
        processThumbnails();
    };
    const observer = new MutationObserver(observerCallback);
    observer.observe(document.body, { childList: true, subtree: true });
}

// --- EXECUTION ---

if (window.trustedTypes && window.trustedTypes.createPolicy) {
  window.trustedTypes.createPolicy("default", { createHTML: (string) => string });
}

document.addEventListener('contextmenu', showContextMenu);
document.addEventListener('click', hideContextMenu);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideContextMenu(); });

// Only run the main functions if the site is supported.
// The context menu is global, but the other functions are site-specific.
if (SITES[location.hostname]) {
    notifyUpdate();
    createControls();
    updatePlayButton();
    processThumbnails();
    startObservers();
}

// Creates the context menu on all pages; internal logic will decide if it appears.
createContextMenu();