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