bilibili plus fixed

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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