Greasy Fork is available in English.

bilibili plus fixed

B站截图、获取封面、逐帧前进/后退;移除宽屏功能,兼容新版播放器控制栏

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         bilibili plus fixed
// @version      0.5.0
// @description  B站截图、获取封面、逐帧前进/后退;移除宽屏功能,兼容新版播放器控制栏
// @namespace    local.bilibili.plus.fixed
// @author       化猫之宿 / fixed by Codex
// @license      MIT
// @homepageURL  https://greasyfork.org/zh-CN/scripts/373172-bilibili-plus
// @encoding     utf-8
// @match        *://www.bilibili.com/video/*
// @match        *://www.bilibili.com/bangumi/play/*
// @match        *://www.bilibili.com/blackboard/*
// @compatible   chrome 54+
// @compatible   firefox 49+
// @grant        none
// ==/UserScript==

(function () {
  "use strict";

  const FPS = 29.97;
  const ID = "bili-plus-fixed-controls";
  const STYLE_ID = "bili-plus-fixed-style";

  const SELECTORS = {
    controlRoots: [
      ".bpx-player-dm-root",
      ".bpx-player-control-bottom-center .bpx-player-dm-root",
      ".bpx-player-control-bottom-left",
      ".bilibili-player-video-control-bottom-center",
      ".squirtle-controller-wrap"
    ],
    playerArea: [
      ".bpx-player-container",
      "#bilibili-player",
      ".bilibili-player",
      ".squirtle-video-wrap"
    ]
  };

  const icons = {
    prev: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 6.5 5.5 12 11 17.5V14h8v-4h-8V6.5Z"/></svg>',
    next: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 6.5V10H5v4h8v3.5l5.5-5.5L13 6.5Z"/></svg>',
    cover: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 5h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Zm0 11.5 4.2-4.2 2.8 2.8 3.8-4.8L19 14.2V7H5v9.5Zm4.2-6.7a1.6 1.6 0 1 0 0-3.2 1.6 1.6 0 0 0 0 3.2Z"/></svg>',
    shot: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8.3 5 7 7H4a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-1.3-2H8.3ZM12 17a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/></svg>'
  };

  function first(selectors, root = document) {
    for (const selector of selectors) {
      const el = root.querySelector(selector);
      if (el) return el;
    }
    return null;
  }

  function ensureStyle() {
    if (document.getElementById(STYLE_ID)) return;
    const style = document.createElement("style");
    style.id = STYLE_ID;
    style.textContent = `
      #${ID} {
        display: inline-flex;
        align-items: center;
        gap: 4px;
        height: 100%;
        margin-right: 8px;
        color: #61666d;
        fill: currentColor;
        flex: 0 0 auto;
      }
      #${ID} .bpf-btn {
        width: 28px;
        height: 28px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        padding: 0;
        border: 0;
        outline: 0;
        appearance: none;
        -webkit-appearance: none;
        background: transparent;
        color: inherit;
        fill: currentColor;
        cursor: pointer;
        position: relative;
        opacity: .9;
        line-height: 1;
        font: inherit;
        transition: color .15s ease, opacity .15s ease;
      }
      #${ID} .bpf-btn:hover {
        color: #00aeec;
        opacity: 1;
      }
      #${ID} svg {
        width: 22px;
        height: 22px;
        display: block;
        pointer-events: none;
      }
      #${ID} .bpf-btn::after {
        content: attr(data-tip);
        position: absolute;
        left: 50%;
        bottom: 34px;
        transform: translateX(-50%);
        white-space: nowrap;
        background: rgba(0, 0, 0, .82);
        color: #fff;
        font-size: 12px;
        line-height: 1;
        padding: 7px 9px;
        border-radius: 4px;
        pointer-events: none;
        opacity: 0;
        visibility: hidden;
        transition: opacity .12s ease;
      }
      #${ID} .bpf-btn:hover::after {
        opacity: 1;
        visibility: visible;
      }
      .bpx-player-container[data-screen=full] #${ID},
      .bpx-player-container[data-screen=web] #${ID},
      .bpx-player-container[data-screen=wide] #${ID} {
        color: hsla(0, 0%, 100%, .86);
      }
      @media screen and (max-width: 860px) {
        #${ID} {
          gap: 3px;
          margin-right: 3px;
        }
        #${ID} .bpf-btn {
          width: 24px;
        }
        #${ID} svg {
          width: 19px;
          height: 19px;
        }
      }
    `;
    document.head.appendChild(style);
  }

  function getVideo() {
    return document.querySelector("video");
  }

  function pauseVideo(video) {
    if (!video) return;
    video.pause();
  }

  function openUrl(url, width = 960, height = 600) {
    const win = window.open(url, "_blank", `width=${width},height=${height}`);
    if (!win) alert("弹窗被浏览器拦截了,请允许此页面弹窗。");
  }

  function getCoverUrl() {
    const metaSelectors = [
      'meta[itemprop="image"]',
      'meta[itemprop="thumbnailUrl"]',
      'meta[property="og:image"]',
      'meta[name="thumbnail"]'
    ];

    for (const selector of metaSelectors) {
      const content = document.querySelector(selector)?.getAttribute("content");
      if (content) return content.replace(/^\/\//, `${location.protocol}//`);
    }

    const scripts = Array.from(document.scripts, script => script.textContent || "");
    const scriptText = scripts.find(text => text.includes('"pic"') || text.includes('"cover"'));
    const match = scriptText && scriptText.match(/"(?:pic|cover)"\s*:\s*"([^"]+)"/);
    return match ? match[1].replace(/\\\//g, "/").replace(/^\/\//, `${location.protocol}//`) : "";
  }

  function openCover() {
    const url = getCoverUrl();
    if (!url) {
      alert("未找到封面。");
      return;
    }
    openUrl(url, 960, 600);
  }

  function screenshot() {
    const video = getVideo();
    if (!video || !video.videoWidth || !video.videoHeight) {
      alert("未找到可截图的视频画面。");
      return;
    }

    const canvas = document.createElement("canvas");
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;

    try {
      canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height);
      pauseVideo(video);
      const url = canvas.toDataURL("image/png");
      openImageDocument(url, canvas.width, canvas.height);
    } catch (error) {
      console.error("[bilibili plus fixed] screenshot failed:", error);
      alert("截图失败:当前视频画面可能被浏览器跨域限制。");
    }
  }

  function openImageDocument(dataUrl, width, height) {
    const win = window.open("", "_blank", `width=${Math.min(width, 1280)},height=${Math.min(height, 800)}`);
    if (!win) {
      alert("弹窗被浏览器拦截了,请允许此页面弹窗。");
      return;
    }
    win.document.title = "Bilibili Screenshot";
    win.document.body.innerHTML = "";
    const style = win.document.createElement("style");
    style.textContent = "html,body{margin:0;width:100%;height:100%;background:#222;display:flex;align-items:center;justify-content:center}img{max-width:100%;max-height:100%;object-fit:contain}";
    const img = win.document.createElement("img");
    img.src = dataUrl;
    img.alt = "screenshot";
    win.document.head.appendChild(style);
    win.document.body.appendChild(img);
    win.focus();
  }

  function seekFrame(direction) {
    const video = getVideo();
    if (!video) return;
    pauseVideo(video);
    const offset = direction === "next" ? 1 / FPS : -1 / FPS;
    video.currentTime = Math.max(0, video.currentTime + offset);
  }

  function makeButton(className, title, icon, handler) {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.className = `bpf-btn ${className}`;
    btn.dataset.tip = title;
    btn.title = title;
    btn.innerHTML = icon;
    btn.addEventListener("click", event => {
      event.preventDefault();
      event.stopPropagation();
      handler();
    });
    return btn;
  }

  function makeControls() {
    const controls = document.createElement("div");
    controls.id = ID;
    controls.append(
      makeButton("bpf-cover", "封面", icons.cover, openCover),
      makeButton("bpf-shot", "截图", icons.shot, screenshot),
      makeButton("bpf-prev", "逐帧后退 <", icons.prev, () => seekFrame("prev")),
      makeButton("bpf-next", "逐帧前进 >", icons.next, () => seekFrame("next"))
    );
    return controls;
  }

  function mountControls() {
    ensureStyle();
    const root = first(SELECTORS.controlRoots);
    if (!root) return false;

    const old = document.getElementById(ID);
    if (old && old.parentElement === root) return true;
    if (old) old.remove();

    root.prepend(makeControls());
    return true;
  }

  function bindHotkeys() {
    if (window.__biliPlusFixedHotkeys) return;
    window.__biliPlusFixedHotkeys = true;

    document.addEventListener("keydown", event => {
      const tag = event.target?.tagName?.toLowerCase();
      if (tag === "input" || tag === "textarea" || event.target?.isContentEditable) return;
      if (!getVideo()) return;

      if (event.code === "Comma") {
        event.preventDefault();
        seekFrame("prev");
      }
      if (event.code === "Period") {
        event.preventDefault();
        seekFrame("next");
      }
    }, true);
  }

  function boot() {
    bindHotkeys();
    mountControls();

    const observerTarget = first(SELECTORS.playerArea) || document.body;
    const observer = new MutationObserver(() => {
      window.clearTimeout(boot.timer);
      boot.timer = window.setTimeout(mountControls, 200);
    });
    observer.observe(observerTarget, { childList: true, subtree: true });

    let tries = 0;
    const timer = window.setInterval(() => {
      if (mountControls() || ++tries > 40) window.clearInterval(timer);
    }, 500);
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", boot, { once: true });
  } else {
    boot();
  }
})();