SakuraDanmaku 樱花弹幕

yhdm, but with Danmaku from Bilibili 让樱花动漫和橘子动漫加载 Bilibili 弹幕

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

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

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

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

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

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

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

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

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

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

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

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

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

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

/* eslint-disable indent */
/* eslint-disable max-len */
/* eslint-disable no-undef */
// ==UserScript==
// @name         SakuraDanmaku 樱花弹幕
// @namespace    https://muted.top/
// @version      1.0.5
// @description  yhdm, but with Danmaku from Bilibili  让樱花动漫和橘子动漫加载 Bilibili 弹幕
// @author       MUTED64
// @match        *://*.yhpdm.net/vp/*
// @match        *://*.mgnacg.com/bangumi/*
// @match        *://*.akkdm.com/play/*
// @match        *://*.yinghuacd.com/v/*
// @match        *://*.agemys.net/play/*
// @match        https://www.yhpdm.net/yxsf/player/dpx2/*
// @match        https://player.mknacg.top/*
// @match        https://www.akkdm.com/dp/*
// @match        https://tup.yinghuacd.com/*
// @match        https://www.agemys.net/age/player/dp2/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addElement
// @grant        GM_addStyle
// @connect      api.bilibili.com
// @icon         https://www.yhdmp.cc/yxsf/yh_pic/favicon.ico
// @require      https://bowercdn.net/c/danmaku-2.0.4/dist/danmaku.dom.min.js
// @license      GPLv3
// @run-at       document-end
// ==/UserScript==

"use strict";

const sites = {
  yhdm: {
    address: /.*:\/\/.*\.yhpdm\.net\/vp\/.*/,
    videoFrame: "iframe",
    videoFrameURL: "https://www.yhpdm.net/yxsf/player/dpx2",
    bangumiTitle: "title",
    episode: "div.gohome > span",
    container: "div.dplayer-video-wrap",
    video: "div.dplayer-video-wrap > video",
    iconsBar: "div.dplayer-controller > div.dplayer-icons.dplayer-icons-right",
    panelLeft: "1em",
    panelTop: "42%",
    panelTransform: "translateY(-50%)",
  },
  mgnacg: {
    address: /.*:\/\/.*\.mgnacg\.com\/bangumi\/.*/,
    videoFrame: "iframe#videoiframe",
    videoFrameURL: "https://player.mknacg.top",
    bangumiTitle: "h1.page-title > a",
    episode: "span.btn-pc.page-title",
    container: "div.art-video-player",
    video: "div.art-video-player > video.art-video",
    iconsBar: "div.art-video-player div.art-controls > div.art-controls-right",
    panelRight: "10em",
    panelBottom: "2%",
  },
  akkdm: {
    address: /.*:\/\/.*\.akkdm\.com\/play\/.*/,
    videoFrame: "#playleft > iframe",
    videoFrameURL: "https://www.akkdm.com/dp",
    bangumiTitle:
      "body > div.page.player > div.main > div > div.module.module-player > div > div.module-player-side > div.module-player-info > div > h1 > a",
    episode: "#panel2 > div > div > a.module-play-list-link.active",
    container: "div.video-wrapper",
    video: "div.video-wrapper > video",
    iconsBar: "div.art-video-player div.art-controls > div.art-controls-right",
    panelLeft: "1px",
    panelBottom: "2%",
  },
  yinghuacd: {
    address: /.*:\/\/.*\.yinghuacd\.com\/v\/.*/,
    videoFrame: "iframe",
    videoFrameURL: "https://tup.yinghuacd.com",
    bangumiTitle: "div.gohome > h1 > a",
    episode: "div.gohome span",
    container: "div.dplayer-video-wrap",
    video: "div.dplayer-video-wrap > video",
    iconsBar: "div.dplayer-controller > div.dplayer-icons.dplayer-icons-right",
    panelLeft: "1em",
    panelTop: "42%",
    panelTransform: "translateY(-50%)",
  },
  agedm: {
    address: /.*:\/\/.*\.agemys\.net\/play\/.*/,
    videoFrame: "iframe#age_playfram",
    videoFrameURL: "https://www.agemys.net/age/player/dp2",
    bangumiTitle: "#detailname > a",
    episode: "#main0 > div:nth-child(2) > ul > li > a[style]",
    container: "div.dplayer-video-wrap",
    video: "div.dplayer-video-wrap > video",
    iconsBar: "div.dplayer-controller > div.dplayer-icons.dplayer-icons-right",
    panelLeft: "1em",
    panelTop: "42%",
    panelTransform: "translateY(-50%)",
  },
};

class BilibiliDanmaku {
  static #EP_API_BASE = "https://api.bilibili.com/pgc/view/web/season";
  static #DANMAKU_API_BASE = "https://api.bilibili.com/x/v1/dm/list.so";
  static #KEYWORD_API_BASE =
    "https://api.bilibili.com/x/web-interface/search/type?search_type=media_bangumi";

  constructor(keyword, episode) {
    this.keyword = keyword;
    this.episode = episode;
  }

  // GM_xmlhttpRequest的Promise封装
  #Get(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "GET",
        url: url,
        onload: (response) => {
          resolve(response.responseText);
        },
        onerror: (error) => {
          reject(error);
        },
      });
    });
  }

  // Bilibili弹幕xml串转换为可加载的对象
  #parseBilibiliDanmaku(string) {
    const $xml = new DOMParser().parseFromString(string, "text/xml");
    return [...$xml.getElementsByTagName("d")]
      .map(($d) => {
        const p = $d.getAttribute("p");
        if (p === null || $d.childNodes[0] === undefined) return null;
        const values = p.split(",");
        const mode = { 6: "ltr", 1: "rtl", 5: "top", 4: "bottom" }[values[1]];
        if (!mode) return null;
        const fontSize = Number(values[2]) || 25;
        const color = `000000${Number(values[3]).toString(16)}`.slice(-6);
        return {
          text: $d.childNodes[0].nodeValue,
          mode,
          time: values[0] * 1,
          baseTime: values[0] * 1,
          style: {
            fontSize: `${fontSize}px`,
            color: `#${color}`,
            textShadow: "0px 1px 3px #000,0px 0px 3px #000",
            font: `${fontSize}px sans-serif`,
            fillStyle: `#${color}`,
            strokeStyle: color === "000000" ? "#fff" : "#000",
            lineWidth: 2.0,
          },
        };
      })
      .filter((x) => x);
  }

  // 获取Bilibili对应视频的弹幕
  async getInfoAndDanmaku(xml = undefined) {
    if (!xml) {
      const fetchedFromKeyword = JSON.parse(
        await this.#Get(
          `${this.constructor.#KEYWORD_API_BASE}&keyword=${this.keyword}`
        )
      ).data.result;

      this.mdid = fetchedFromKeyword[0].media_id;
      this.ssid = fetchedFromKeyword[0].season_id;
      this.epid = fetchedFromKeyword[0].eps[0].id;

      // 获取cid
      let { code, message, result } = JSON.parse(
        await this.#Get(`${this.constructor.#EP_API_BASE}?ep_id=${this.epid}`)
      );
      if (code) {
        throw new Error(message);
      }
      this.cid = result.episodes[this.episode - 1].cid;

      // 获取弹幕
      this.danmaku = this.#parseBilibiliDanmaku(
        await this.#Get(`${this.constructor.#DANMAKU_API_BASE}?oid=${this.cid}`)
      );
      this.basic_info = {
        mdid: this.mdid,
        ssid: this.ssid,
        epid: this.epid,
        cid: this.cid,
        danmaku: this.danmaku,
      };
      return this.basic_info;
    } else {
      this.basic_info = { danmaku: this.#parseBilibiliDanmaku(xml) };
      return this.basic_info;
    }
  }
}

class DanmakuControl {
  danmaku;
  danmakuElement;

  constructor(keyword, episode, container, video) {
    this.keyword = keyword;
    this.episode = episode;
    this.container = document.querySelector(container);
    this.video = document.querySelector(video);
    const selectInterval = setInterval(() => {
      this.container = document.querySelector(container);
      this.video = document.querySelector(video);
      if (this.container && this.video) {
        clearInterval(selectInterval);
      }
    }, 500);
  }

  async load(xml = undefined) {
    const bilibiliDanmaku = new BilibiliDanmaku(this.keyword, this.episode);
    this.basic_info = await bilibiliDanmaku.getInfoAndDanmaku(xml);
    const loadInterval = setInterval(() => {
      if (this.container && this.video) {
        this.danmaku = new Danmaku({
          container: this.container,
          media: this.video,
          comments: this.basic_info.danmaku,
          speed: 144,
        });
        clearInterval(loadInterval);
      }
    }, 500);
  }

  show() {
    const showInterval = setInterval(() => {
      if (this.container && this.video) {
        this.video.style.position = "absolute";
        this.danmaku.show();
        this.danmakuElement = this.container.lastElementChild;
        this.danmakuElement.style.zIndex = 1000;
        this.danmakuSettings = getStoredSettings();
        this.applySettings(this.danmakuSettings);
        let resizeObserver = new ResizeObserver(() => {
          this.danmaku.resize();
        });
        resizeObserver.observe(this.container);
        clearInterval(showInterval);
      }
    }, 500);
  }

  toggleShowAndHide(show) {
    if (show) {
      this.danmakuElement.style.display = "block";
    } else {
      this.danmakuElement.style.display = "none";
    }
  }

  destroy() {
    this.danmaku.destroy();
  }

  setSpeed(speed) {
    this.danmaku.speed = Number(speed);
  }

  setFontSize(fontSize) {
    for (const i of this.danmaku.comments) {
      i.style.font = `${fontSize}px sans-serif`;
    }
  }

  setLimit(percentLimit) {
    for (const i of this.danmaku.comments) {
      i.style.display = "block";
      if (Math.random() > percentLimit) {
        i.style.display = "none";
      }
    }
  }

  setOpacity(opacity) {
    this.danmakuElement.style.opacity = opacity;
  }

  setOffset(offset) {
    for (const comment of this.danmaku.comments) {
      comment.time = comment.baseTime - Number(offset);
    }
    this.video.currentTime = Number(this.video.currentTime);
  }

  setHideTop(hideTop) {
    if (hideTop) {
      for (const i of this.danmaku.comments) {
        if (i.mode === "top") {
          i.style.display = "none";
        }
      }
    } else {
      for (const i of this.danmaku.comments) {
        if (i.mode === "top") {
          i.style.display = "block";
        }
      }
    }
  }

  setHideBottom(hideBottom) {
    if (hideBottom) {
      for (const i of this.danmaku.comments) {
        if (i.mode === "bottom") {
          i.style.display = "none";
        }
      }
    } else {
      for (const i of this.danmaku.comments) {
        if (i.mode === "bottom") {
          i.style.display = "block";
        }
      }
    }
  }

  applySettings(settings) {
    this.toggleShowAndHide(settings.show);
    this.setSpeed(settings.speed);
    this.setOpacity(settings.opacity);
    this.setFontSize(settings.fontSize);
    this.setLimit(settings.limit);
    this.setHideTop(settings.hideTop);
    this.setHideBottom(settings.hideBottom);
  }
}

function getMainPageInfo(currentSite) {
  let keyword = document
    .querySelector(currentSite.bangumiTitle)
    .textContent.replace(/ 第[0-9]+集.*/gi, "")
    .replace(/ 第[0-9]+话.*/gi, "")
    .replace(/ Part ?[0-9]+.*/, "");
  let episode = Number(
    document
      .querySelector(currentSite.episode)
      .textContent.replace(/[^0-9]+/gi, "")
  );
  let videoFrame = document.querySelector(currentSite.videoFrame);

  return {
    keyword,
    episode,
    videoFrame,
  };
}

function loadConfigToIframe(
  videoFrame,
  keyword,
  episode,
  currentSite,
  xml = undefined
) {
  videoFrame.contentWindow.postMessage(
    {
      keyword,
      episode,
      currentSite,
      xml,
    },
    currentSite.videoFrameURL
  );
}

function showChoosePanel(message, keyword, episode, currentSite, videoFrame) {
  if (document.querySelector(".danmakuChoose")) {
    document.querySelector(".danmakuChoose").remove();
  }

  const storedSettings = getStoredSettings();

  GM_addElement(document.body, "div", { class: "danmakuChoose" });
  document.querySelector(".danmakuChoose").innerHTML = `
  <button class="sakura-danmaku-button" id="folding-button">折叠面板</button>
  
  <pre id="danmaku-message">${message}</pre>
  <hr class="danmaku-panel-hr"/>

  <div class="danmaku-settings-wrapper">
    <div class="danmaku-metadata">
      <label for="keyword">番剧名</label>
      <input class="danmaku-metadata-input" id="keyword" value="${keyword}"/>
    </div>
    <div class="danmaku-metadata">
      <label for="episode">剧集数</label>
      <input class="danmaku-metadata-input" id="episode" value="${episode}"/>
    </div>
  </div>
  <button class="sakura-danmaku-button" id="manual-danmaku-button">确认</button>

  <div class="danmaku-upload">
    <p class="danmaku-upload-label">或手动上传XML弹幕文件</p>
    <button class="sakura-danmaku-button" id="upload-xml-button">选择</button>
  </div>
  <hr class="danmaku-panel-hr"/>

  <div class="danmaku-settings-wrapper danmaku-iframe-settings-wrapper">
    <div class="danmaku-settings">
      <label for="danmaku-show">显示弹幕</label>
      <input type="checkbox" id="danmaku-show" ${
        storedSettings.show ? "checked" : ""
      }/>
    </div>
    <div class="danmaku-settings">
      <label for="danmaku-speed">弹幕速度</label>
      <input type="range" id="danmaku-speed" min="72" max="288" step="2" value="${
        storedSettings.speed
      }"/>
    </div>
    <div class="danmaku-settings">
      <label for="danmaku-opacity">弹幕透明度</label>
      <input type="range" id="danmaku-opacity" min="0" max="1" step="0.1" value="${
        storedSettings.opacity
      }"/>
    </div>
    <div class="danmaku-settings">
      <label for="danmaku-font-size">字体大小</label>
      <input type="range" id="danmaku-font-size" min="16" max="32" step="2" value="${
        storedSettings.fontSize
      }"/>
    </div>
    <div class="danmaku-settings">
      <label for="danmaku-limit">弹幕密度</label>
      <input type="range" id="danmaku-limit" min="0" max="1" step="0.02" value="${
        storedSettings.limit
      }"/>
    </div>
    <div class="danmaku-settings">
      <label for="danmaku-offset">弹幕偏移</label>
      <input type="number" id="danmaku-offset" min="-30" max="30" step="2" value="0"/>
      s
    </div>
    <div class="danmaku-settings">
      <label for="danmaku-hide-top">屏蔽顶部弹幕</label>
      <input type="checkbox" id="danmaku-hide-top" ${
        storedSettings.hideTop ? "checked" : ""
      }/>
      <label for="danmaku-hide-bottom">屏蔽底部弹幕</label>
      <input type="checkbox" id="danmaku-hide-bottom" ${
        storedSettings.hideBottom ? "checked" : ""
      }/>
    </div>
  </div>`;

  const globalStyle = `.danmakuChoose {
    position:fixed;
    left:${currentSite.panelLeft ? currentSite.panelLeft : "auto"};
    top:${currentSite.panelTop ? currentSite.panelTop : "auto"};
    right:${currentSite.panelRight ? currentSite.panelRight : "auto"};
    bottom:${currentSite.panelBottom ? currentSite.panelBottom : "auto"};
    transform:${
      currentSite.panelTransform ? currentSite.panelTransform : "none"
    };
    background-color:rgba(32,32,32,0.9);
    color:white;
    font:1em sans-serif !important;
    padding:1em;
    border-radius:8px;
    border:1px solid gray;
    line-height:1.5;
    z-index:999999;
    overflow:hidden;
    display:flex;
    flex-direction:column;
    user-select:none;
  }

  pre#danmaku-message {
    font-family:sans-serif !important;
    margin:0 !important;
    text-align:center;
  }

  hr.danmaku-panel-hr {
    border-top: 1px solid lightgray;
    border-bottom: none;
    border-left: none;
    border-right: none;
    margin: 1em 0;
  }

  div.danmaku-settings-wrapper {
    display:flex;
    flex-direction:column;
    gap:0.5em;
    margin: 0 0 0.5em 0;
    text-align: initial;
  }

  div.danmaku-settings{
    display:flex;
    justify-content:space-between;
    align-items:center;
    gap: 0.5em;
  }

  div.danmaku-settings > input {
    appearance: auto;
    -moz-appearance: auto;
    -webkit-appearance: auto;
    border: 1px solid lightgray;
    flex:6 1 0;
    height:1.4em;
  }

  div.danmaku-settings > label {
    flex:4 1 0;
  }

  div.danmaku-settings > input[type="checkbox"] {
    max-width:1em;
    height:1em;
    border-radius:4px;
  }

  div.danmaku-settings > input[type="number"] {
    border-radius:4px;
    flex:5.5 1 0;
  }

  div.danmaku-settings > input[type="range"] {
    height:auto;
  }

  div.danmaku-metadata{
    display:flex;
    justify-content:space-between;
    align-items:center;
    gap: 1em;
  }

  input.danmaku-metadata-input {
    border-radius:4px;
    padding:0 0.2em;
    border:1px solid lightgray;
    height:2em;
    background-color:rgba(0,0,0,0);
    color:white;
    flex:1;
  }

  button#manual-danmaku-button {
    width:100%;
    margin-bottom:0.2em;
  }

  div.danmaku-upload {
    display:flex;
    justify-content:space-between;
    align-items:center;
    margin:1em 0 0 0;
  }

  p.danmaku-upload-label {
    flex:3;
    display:inline-flex;
    margin:0!important;
  }

  button#upload-xml-button {
    flex:1;
  }

  button.sakura-danmaku-button {
    cursor:pointer;
    border-radius:4px;
    border:1px solid lightgray;
    height:2em;
    background-color:rgba(0,0,0,0);
    color:white;
  }

  button.sakura-danmaku-button:hover {
    background-color:lightgray;
    color:black;
  }

  button#folding-button {
    visibility:visible;
    width:7em;
    margin-bottom:0.5em;
  }
  `;

  GM_addStyle(globalStyle);

  document.querySelector("#folding-button").addEventListener("click", () => {
    const danmakuChoose = document.querySelector(".danmakuChoose");
    if (danmakuChoose.style.visibility === "hidden") {
      danmakuChoose.style.visibility = "visible";
      document.querySelector("#folding-button").textContent = "折叠面板";
    } else {
      danmakuChoose.style.visibility = "hidden";
      document.querySelector("#folding-button").textContent = "展开面板";
    }
  });

  document
    .querySelector("#manual-danmaku-button")
    .addEventListener("click", () => {
      keyword = document.querySelector("#keyword").value;
      episode = document.querySelector("#episode").value;
      loadConfigToIframe(videoFrame, keyword, episode, currentSite);
    });

  document.querySelector("#upload-xml-button").addEventListener("click", () => {
    const input = document.createElement("input");
    input.type = "file";
    input.accept = "text/xml";
    input.addEventListener("change", () => {
      const reader = new FileReader();
      reader.onload = () => {
        const xml = reader.result;
        loadConfigToIframe(videoFrame, keyword, episode, currentSite, xml);
      };
      reader.readAsText(input.files[0]);
    });
    input.click();
  });

  document
    .querySelectorAll(".danmaku-iframe-settings-wrapper input")
    .forEach((input) => {
      input.addEventListener("input", () => {
        videoFrame.contentWindow.postMessage(
          {
            type: "danmaku-settings",
            settings: {
              show: document.querySelector("#danmaku-show").checked,
              speed: document.querySelector("#danmaku-speed").value,
              opacity: document.querySelector("#danmaku-opacity").value,
              fontSize: document.querySelector("#danmaku-font-size").value,
              limit: document.querySelector("#danmaku-limit").value,
              offset: document.querySelector("#danmaku-offset").value,
              hideTop: document.querySelector("#danmaku-hide-top").checked,
              hideBottom: document.querySelector("#danmaku-hide-bottom")
                .checked,
            },
          },
          currentSite.videoFrameURL
        );
      });
    });
}

async function loadDanmaku(keyword, episode, currentSite, xml = undefined) {
  const danmakuControl = await new DanmakuControl(
    keyword,
    episode,
    currentSite.container,
    currentSite.video,
    xml
  );
  await danmakuControl.load(xml);
  danmakuControl.show();
  return danmakuControl;
}

function getChangedSetting(settings, storedSettings) {
  for (const setting in settings) {
    if (setting === "offset" && settings[setting] !== "0") {
      return "offset";
    } else if (
      setting !== "offset" &&
      settings[setting] !== storedSettings[setting]
    ) {
      return setting;
    }
  }
}

function getStoredSettings() {
  return GM_getValue("danmakuSettings", {
    show: true,
    speed: 144,
    opacity: 1,
    fontSize: 25,
    limit: 1,
    hideTop: false,
    hideBottom: false,
  });
}

const currentSite =
  sites[
    Object.keys(sites).find((site) =>
      window.location.href.match(sites[site].address)
    )
  ];

if (currentSite) {
  const { keyword, episode, videoFrame } = getMainPageInfo(currentSite);
  videoFrame.onload = () => loadConfigToIframe(videoFrame, keyword, episode, currentSite);

  window.addEventListener("message", (event) => {
    if (event.data.includes("加载弹幕")) {
      showChoosePanel(event.data, keyword, episode, currentSite, videoFrame);
    }
  });
} else {
  let danmakuControl;
  window.addEventListener("message", async (event) => {
    if (event.data.currentSite) {
      danmakuControl?.destroy();
      const { keyword, episode, currentSite, xml } = event.data;
      try {
        danmakuControl = await loadDanmaku(keyword, episode, currentSite, xml);
        event.source.postMessage(
          `自动加载弹幕:\n${keyword} 第${episode}集\n如果不是你想要的,请手动填入对应番剧`,
          event.origin
        );
      } catch (e) {
        console.error(e);
        event.source.postMessage(
          `加载弹幕失败:\n${keyword} 第${episode}集\n请检查B站是否存在对应剧集`,
          event.origin
        );
        return;
      }
    } else if (event.data.type === "danmaku-settings") {
      switch (getChangedSetting(event.data.settings, getStoredSettings())) {
        case "show":
          danmakuControl.toggleShowAndHide(event.data.settings.show);
          break;
        case "speed":
          danmakuControl.setSpeed(event.data.settings.speed);
          break;
        case "opacity":
          danmakuControl.setOpacity(event.data.settings.opacity);
          break;
        case "fontSize":
          danmakuControl.setFontSize(event.data.settings.fontSize);
          break;
        case "limit":
          danmakuControl.setLimit(event.data.settings.limit);
          break;
        case "hideTop":
          danmakuControl.setHideTop(event.data.settings.hideTop);
          break;
        case "hideBottom":
          danmakuControl.setHideBottom(event.data.settings.hideBottom);
          break;
        case "offset":
          danmakuControl.setOffset(event.data.settings.offset);
          break;
        default:
          break;
      }

      delete event.data.settings.offset;
      GM_setValue("danmakuSettings", event.data.settings);
    }
  });
}