// ==UserScript== // @name SakuraDanmaku 樱花弹幕 // @namespace https://muted.top/ // @version 1.0.0 // @description yhdm, but with Danmaku from Bilibili 让樱花动漫和橘子动漫加载 Bilibili 弹幕 // @author MUTED64 // @match https://*.yhdmp.cc/vp/* // @match https://*.mgnacg.com/bangumi/* // @match https://www.yhdmp.cc/yxsf/player/dpx2/* // @match https://play.mknacg.top:8585/* // @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 // @downloadURL none // ==/UserScript== "use strict"; const sites = { yhdm: { address: /.*:\/\/.*\.yhdmp\.cc\/vp\/.*/, videoFrame: "iframe", videoFrameURL: "https://www.yhdmp.cc/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://play.mknacg.top:8585", 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%", }, }; 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); } async load(xml = undefined) { const bilibiliDanmaku = new BilibiliDanmaku(this.keyword, this.episode); this.basic_info = await bilibiliDanmaku.getInfoAndDanmaku(xml); this.danmaku = new Danmaku({ container: this.container, media: this.video, comments: this.basic_info.danmaku, speed: 144, }); } show() { 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); } 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); } applySettings(settings) { this.toggleShowAndHide(settings.show); this.setSpeed(settings.speed); this.setOpacity(settings.opacity); this.setFontSize(settings.fontSize); this.setLimit(settings.limit); } } function getKeywordAndEpisode(currentSite) { const keyword = document .querySelector(currentSite.bangumiTitle) .textContent.replace(/ 第[0-9]+集.*/gi, "") .replace(/ 第[0-9]+话.*/gi, "") .replace(/ Part ?[0-9]+.*/, ""); const episode = Number( document .querySelector(currentSite.episode) .textContent.replace(/[^0-9]+/gi, "") ); return { keyword, episode, }; } 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 = `
${message}

或手动上传XML弹幕文件


s
`; 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; } 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; } `; GM_addStyle(globalStyle); 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, }, }, 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"; } if (settings[setting] !== storedSettings[setting]) { return setting; } } } function getStoredSettings() { return GM_getValue("danmakuSettings", { show: true, speed: 144, opacity: 1, fontSize: 25, limit: 1, }); } const currentSite = sites[ Object.keys(sites).find((site) => window.location.href.match(sites[site].address) ) ]; if (currentSite) { const { keyword, episode } = getKeywordAndEpisode(currentSite); const videoFrame = document.querySelector(currentSite.videoFrame); videoFrame.addEventListener("load", () => 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 "offset": danmakuControl.setOffset(event.data.settings.offset); break; default: break; } delete event.data.settings.offset; GM_setValue("danmakuSettings", event.data.settings); } }); }