bilibili plus fixed

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

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

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

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

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

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например 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();
  }
})();