Play with MPV (Enhanced)

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

Устаревшая версия за 18.07.2025. Перейдите к последней версии.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         Play with MPV (Enhanced)
// @namespace    play-with-mpv-enhanced
// @version      2025.07.18.1
// @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: "no" },
};

// --- GM_CONFIG INITIALIZATION ---

GM_config.init({
  id: CONFIG_ID,
  title: GM_info.script.name,
  fields: CONFIG_FIELDS,
  events: {
    init: () => {
      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;
      }
    }
    if (path !== "/") return !mode;
  }
  return false;
}

// [MODIFICADO] A ÚNICA MUDANÇA ESTÁ AQUI.
function updatePlayButton() {
    const button = document.querySelector(".pwm-play");
    if (!button) return;

    const shouldShow = isWatchPage() && !document.fullscreenElement;
    button.style.display = shouldShow ? "block" : "none";

    if (shouldShow) {
        let videoUrl = null;

        // Prioridade 1: A tag 'og:url' é a mais confiável no YouTube.
        const ogUrlMeta = document.querySelector("meta[property='og:url']");
        if (ogUrlMeta && ogUrlMeta.content) {
            videoUrl = ogUrlMeta.content;
        }
        // Prioridade 2: Se a primeira falhar, o link canônico é a segunda melhor opção.
        else {
            const canonicalLink = document.querySelector("link[rel='canonical']");
            if (canonicalLink && canonicalLink.href) {
                videoUrl = canonicalLink.href;
            }
            // Último recurso: O location.href original.
            else {
                videoUrl = location.href;
            }
        }

        // Gera o link somente se uma URL válida foi encontrada.
        if (videoUrl && videoUrl.includes("http")) {
            button.href = generateProto(videoUrl);
        }
    }
}

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';
}

function showContextMenu(event) {
    // The line 'if (SITES[location.hostname]) { return; }' has been removed.
    if (!event.shiftKey) { return; }
    const linkElement = event.target.closest('a[href]');
    if (!linkElement || !linkElement.href) {
        hideContextMenu();
        return;
    }
    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() {
        const video = document.querySelector('video.html5-main-video');
        if (video && !video.paused) {
            video.pause();
        }
    if (!SITES[location.hostname]) return;

    let lastUrl = location.href;
    const observerCallback = () => {
        // A cada mudança no DOM, roda a função de novo para garantir que o link esteja sempre atualizado.
        // Isso é importante para a navegação interna do YouTube.
        updatePlayButton();
        processThumbnails();

        // A lógica original para detectar mudança de página e forçar uma atualização mais lenta.
        const url = location.href;
        if (url !== lastUrl) {
            lastUrl = url;
            setTimeout(() => {
                updatePlayButton();
                processThumbnails();
            }, 500);
        }
    };
    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(); });

if (SITES[location.hostname]) {
    notifyUpdate();
    createControls();
    // Roda a função de atualização várias vezes nos primeiros segundos para garantir que pegue a URL correta.
    // Esta é a forma mais simples de garantir que funcione no carregamento direto.
    setTimeout(updatePlayButton, 100);
    setTimeout(updatePlayButton, 500);
    setTimeout(updatePlayButton, 1000);
    processThumbnails();
    startObservers();
}

createContextMenu();