bilibili plus fixed

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

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==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();
  }
})();