// ==UserScript==
// @name 动漫弹幕播放
// @namespace https://github.com/LesslsMore/anime-danmu-play
// @version 0.3.7
// @author lesslsmore
// @description 自动匹配加载动漫剧集对应弹幕并播放,目前支持樱花动漫、风车动漫
// @license MIT
// @include /^https:\/\/www\.dmla.*\.com\/play\/.*$/
// @include https://www.tt776b.com/play/*
// @include https://www.dm539.com/play/*
// @require https://cdn.jsdelivr.net/npm/crypto-js@4.2.0/crypto-js.js
// @require https://cdn.jsdelivr.net/npm/artplayer@5.1.1/dist/artplayer.js
// @require https://cdn.jsdelivr.net/npm/artplayer-plugin-danmuku@5.0.1/dist/artplayer-plugin-danmuku.js
// @require https://cdn.jsdelivr.net/npm/dexie@4.0.8/dist/dexie.min.js
// @connect https://api.dandanplay.net/*
// @connect https://danmu.yhdmjx.com/*
// @connect http://v16m-default.akamaized.net/*
// @connect self
// @connect *
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @run-at document-end
// ==/UserScript==
(async function (CryptoJS, artplayerPluginDanmuku, Artplayer, Dexie) {
'use strict';
(function() {
var originalSetItem = localStorage.setItem;
var originalRemoveItem = localStorage.removeItem;
localStorage.setItem = function(key2, value) {
var event = new Event("itemInserted");
event.key = key2;
event.value = value;
document.dispatchEvent(event);
originalSetItem.apply(this, arguments);
};
localStorage.removeItem = function(key2) {
var event = new Event("itemRemoved");
event.key = key2;
document.dispatchEvent(event);
originalRemoveItem.apply(this, arguments);
};
})();
function get_anime_info(url2) {
let episode2 = parseInt(url2.split("-").pop().split(".")[0]);
let include = [
/^https:\/\/www\.dmla.*\.com\/play\/.*$/,
// 风车动漫
"https://www.tt776b.com/play/*",
// 风车动漫
"https://www.dm539.com/play/*"
// 樱花动漫
];
let els = [
document.querySelector(".stui-player__detail.detail > h1 > a"),
document.querySelector("body > div.myui-player.clearfix > div > div > div.myui-player__data.hidden-xs.clearfix > h3 > a"),
document.querySelector(".myui-panel__head.active.clearfix > h3 > a")
];
let el;
let title2;
for (let i = 0; i < include.length; i++) {
if (url2.match(include[i])) {
el = els[i];
}
}
if (el != void 0) {
title2 = el.text;
} else {
title2 = "";
console.log("没有自动匹配到动漫名称");
}
return {
episode: episode2,
title: title2
};
}
function re_render(container) {
let player = document.querySelector(".stui-player__video.clearfix");
if (player == void 0) {
player = document.querySelector("#player-left");
}
let div = player.querySelector("div");
let h = div.offsetHeight;
let w = div.offsetWidth;
player.removeChild(div);
let app = `<div style="height: ${h}px; width: ${w}px;" class="${container}"></div>`;
player.innerHTML = app;
}
var _GM_getValue = /* @__PURE__ */ (() => typeof GM_getValue != "undefined" ? GM_getValue : void 0)();
var _GM_setValue = /* @__PURE__ */ (() => typeof GM_setValue != "undefined" ? GM_setValue : void 0)();
var _GM_xmlhttpRequest = /* @__PURE__ */ (() => typeof GM_xmlhttpRequest != "undefined" ? GM_xmlhttpRequest : void 0)();
function xhr_get(url2) {
return new Promise((resolve, reject) => {
_GM_xmlhttpRequest({
url: url2,
method: "GET",
headers: {},
onload: function(xhr) {
resolve(xhr.responseText);
}
});
});
}
function request(opts) {
let { url: url2, method, params } = opts;
if (params) {
let u = new URL(url2);
Object.keys(params).forEach((key2) => {
const value = params[key2];
if (value !== void 0 && value !== null) {
u.searchParams.set(key2, params[key2]);
}
});
url2 = u.toString();
}
console.log("请求地址: ", url2);
return new Promise((resolve, reject) => {
_GM_xmlhttpRequest({
url: url2,
method: method || "GET",
responseType: "json",
onload: (res) => {
resolve(res.response);
},
onerror: reject
});
});
}
let end_point = "https://api.dandanplay.net";
let API_comment = "/api/v2/comment/";
let API_search_episodes = `/api/v2/search/episodes`;
function get_episodeId(animeId, id) {
id = id.toString().padStart(4, "0");
let episodeId = `${animeId}${id}`;
return episodeId;
}
async function get_search_episodes(anime, episode2) {
const res = await request({
url: `${end_point}${API_search_episodes}`,
params: { anime, episode: episode2 }
});
return res.animes;
}
async function get_comment(episodeId) {
const res = await request({
url: `${end_point}${API_comment}${episodeId}?withRelated=true&chConvert=1`
});
return res.comments;
}
const key = CryptoJS.enc.Utf8.parse("57A891D97E332A9D");
const iv = CryptoJS.enc.Utf8.parse("844182a9dfe9c5ca");
async function get_yhdmjx_url(url2) {
let body = await xhr_get(url2);
let m3u8 = get_m3u8_url(body);
if (m3u8) {
let body2 = await xhr_get(m3u8);
let aes_data = get_encode_url(body2);
if (aes_data) {
let url3 = Decrypt(aes_data);
let src = url3.split(".net/")[1];
let src_url2 = `http://v16m-default.akamaized.net/${src}`;
return src_url2;
}
}
}
function get_m3u8_url(data) {
let regex = /"url":"([^"]+)","url_next":"([^"]+)"/g;
const matches = data.match(regex);
if (matches) {
let play = JSON.parse(`{${matches[0]}}`);
let m3u8 = `https://danmu.yhdmjx.com/m3u8.php?url=${play.url}`;
console.log("m3u8", m3u8);
return m3u8;
} else {
console.log("No matches found.");
}
}
function get_encode_url(data) {
let regex = /getVideoInfo\("([^"]+)"/;
const matches = data.match(regex);
if (matches) {
return matches[1];
} else {
console.log("No matches found.");
}
}
function Decrypt(srcs) {
let decrypt = CryptoJS.AES.decrypt(srcs, key, { iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 });
let decryptedStr = decrypt.toString(CryptoJS.enc.Utf8);
return decryptedStr.toString();
}
function update_danmu(art2, danmus) {
art2.plugins.artplayerPluginDanmuku.config({
danmuku: danmus
});
art2.plugins.artplayerPluginDanmuku.load();
}
function add_danmu(art2) {
let plug = artplayerPluginDanmuku({
danmuku: [],
speed: 5,
// 弹幕持续时间,单位秒,范围在[1 ~ 10]
opacity: 1,
// 弹幕透明度,范围在[0 ~ 1]
fontSize: 25,
// 字体大小,支持数字和百分比
color: "#FFFFFF",
// 默认字体颜色
mode: 0,
// 默认模式,0-滚动,1-静止
margin: [10, "25%"],
// 弹幕上下边距,支持数字和百分比
antiOverlap: true,
// 是否防重叠
useWorker: true,
// 是否使用 web worker
synchronousPlayback: false,
// 是否同步到播放速度
filter: (danmu) => danmu.text.length < 50,
// 弹幕过滤函数,返回 true 则可以发送
lockTime: 5,
// 输入框锁定时间,单位秒,范围在[1 ~ 60]
maxLength: 100,
// 输入框最大可输入的字数,范围在[0 ~ 500]
minWidth: 200,
// 输入框最小宽度,范围在[0 ~ 500],填 0 则为无限制
maxWidth: 600,
// 输入框最大宽度,范围在[0 ~ Infinity],填 0 则为 100% 宽度
theme: "light",
// 输入框自定义挂载时的主题色,默认为 dark,可以选填亮色 light
heatmap: true,
// 是否开启弹幕热度图, 默认为 false
beforeEmit: (danmu) => !!danmu.text.trim()
// 发送弹幕前的自定义校验,返回 true 则可以发送
// 通过 mount 选项可以自定义输入框挂载的位置,默认挂载于播放器底部,仅在当宽度小于最小值时生效
// mount: document.querySelector('.artplayer-danmuku'),
});
art2.plugins.add(plug);
art2.on("artplayerPluginDanmuku:emit", (danmu) => {
console.info("新增弹幕", danmu);
});
art2.on("artplayerPluginDanmuku:error", (error) => {
console.info("加载错误", error);
});
art2.on("artplayerPluginDanmuku:config", (option) => {
});
}
function NewPlayer(src_url2, container) {
var art2 = new Artplayer({
container,
url: src_url2,
// autoplay: true,
// muted: true,
autoSize: true,
fullscreen: true,
fullscreenWeb: true,
autoOrientation: true,
flip: true,
playbackRate: true,
aspectRatio: true,
setting: true,
controls: [
{
position: "right",
html: "上传弹幕",
click: function() {
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;
let dm = bilibiliDanmuParseFromXml(xml);
console.log(dm);
art2.plugins.artplayerPluginDanmuku.config({
danmuku: dm
});
art2.plugins.artplayerPluginDanmuku.load();
};
reader.readAsText(input.files[0]);
});
input.click();
}
}
],
contextmenu: [
{
name: "搜索",
html: `<div id="k-player-danmaku-search-form">
<label>
<span>搜索番剧名称</span>
<input type="text" id="animeName" class="k-input" />
</label>
<div style="min-height:24px; padding-top:4px">
<span id="tips"></span>
</div>
<label>
<span>番剧名称</span>
<select id="animes" class="k-select"></select>
</label>
<label>
<span>章节</span>
<select id="episodes" class="k-select"></select>
</label>
<label>
<span class="open-danmaku-list">
<span>弹幕列表</span><small id="count"></small>
</span>
</label>
<span class="specific-thanks">弹幕服务由 弹弹play 提供</span>
</div>`
}
]
});
return art2;
}
function getMode(key2) {
switch (key2) {
case 1:
case 2:
case 3:
return 0;
case 4:
case 5:
return 1;
default:
return 0;
}
}
function bilibiliDanmuParseFromXml(xmlString) {
if (typeof xmlString !== "string")
return [];
const matches = xmlString.matchAll(/<d (?:.*? )??p="(?<p>.+?)"(?: .*?)?>(?<text>.+?)<\/d>/gs);
return Array.from(matches).map((match) => {
const attr = match.groups.p.split(",");
if (attr.length >= 8) {
const text = match.groups.text.trim().replaceAll(""", '"').replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
return {
text,
time: Number(attr[0]),
mode: getMode(Number(attr[1])),
fontSize: Number(attr[2]),
color: `#${Number(attr[3]).toString(16)}`,
timestamp: Number(attr[4]),
pool: Number(attr[5]),
userID: attr[6],
rowID: Number(attr[7])
};
} else {
return null;
}
}).filter(Boolean);
}
function bilibiliDanmuParseFromJson(jsonString) {
return jsonString.map((comment) => {
let attr = comment.p.split(",");
return {
text: comment.m,
time: Number(attr[0]),
mode: getMode(Number(attr[1])),
fontSize: Number(25),
color: `#${Number(attr[2]).toString(16)}`,
timestamp: Number(comment.cid),
pool: Number(0),
userID: attr[3],
rowID: Number(0)
};
});
}
function createStorage(storage) {
function getItem(key2, defaultValue) {
try {
const value = storage.getItem(key2);
if (value)
return JSON.parse(value);
return defaultValue;
} catch (error) {
return defaultValue;
}
}
return {
getItem,
setItem(key2, value) {
storage.setItem(key2, JSON.stringify(value));
},
removeItem: storage.removeItem.bind(storage),
clear: storage.clear.bind(storage)
};
}
createStorage(window.sessionStorage);
const local = createStorage(window.localStorage);
let gm;
try {
gm = { getItem: _GM_getValue, setItem: _GM_setValue };
} catch (error) {
gm = local;
}
const db_name = "anime";
const db_schema = {
info: "&anime_id",
// 主键 索引
url: "&anime_id"
// 主键 索引
};
const db_obj = {
[db_name]: get_db(db_name, db_schema)
};
const db_url = db_obj[db_name].url;
const db_info = db_obj[db_name].info;
function get_db(db_name2, db_schema2, db_ver = 1) {
let db = new Dexie(db_name2);
db.version(db_ver).stores(db_schema2);
return db;
}
const db_url_put = db_url.put.bind(db_url);
const db_url_get = db_url.get.bind(db_url);
db_url.put = async function(key2, value, expiryInMinutes = 60) {
const now = /* @__PURE__ */ new Date();
const item = {
anime_id: key2,
value,
expiry: now.getTime() + expiryInMinutes * 6e4
};
const result = await db_url_put(item);
const event = new Event("db_yhdm_put");
event.key = key2;
event.value = value;
document.dispatchEvent(event);
return result;
};
db_url.get = async function(key2) {
const item = await db_url_get(key2);
const event = new Event("db_yhdm_get");
event.key = key2;
event.value = item ? item.value : null;
document.dispatchEvent(event);
if (!item) {
return null;
}
const now = /* @__PURE__ */ new Date();
if (now.getTime() > item.expiry) {
await db_url.delete(key2);
return null;
}
return item.value;
};
const db_info_put = db_info.put.bind(db_info);
const db_info_get = db_info.get.bind(db_info);
db_info.put = async function(key2, value, expiryInMinutes = 60 * 24 * 7) {
const now = /* @__PURE__ */ new Date();
const item = {
anime_id: key2,
value,
expiry: now.getTime() + expiryInMinutes * 6e4
};
const result = await db_info_put(item);
const event = new Event("db_info_put");
event.key = key2;
event.value = value;
document.dispatchEvent(event);
return result;
};
db_info.get = async function(key2) {
const item = await db_info_get(key2);
const event = new Event("db_info_get");
event.key = key2;
event.value = item ? item.value : null;
document.dispatchEvent(event);
if (!item) {
return null;
}
const now = /* @__PURE__ */ new Date();
if (now.getTime() > item.expiry) {
await db_info.delete(key2);
return null;
}
return item.value;
};
let url = window.location.href;
let { episode, title } = get_anime_info(url);
let anime_url = url.split("-")[0];
let anime_id = parseInt(anime_url.split("/")[4]);
console.log(url);
console.log(episode);
console.log(title);
let db_anime_url = {
"episodes": {}
};
let db_url_value = await( db_url.get(anime_id));
if (db_url_value != null) {
db_anime_url = db_url_value;
}
let src_url;
if (!db_anime_url["episodes"].hasOwnProperty(url)) {
src_url = await( get_yhdmjx_url(url));
if (src_url) {
db_anime_url["episodes"][url] = src_url;
db_url.put(anime_id, db_anime_url);
}
} else {
src_url = db_anime_url["episodes"][url];
}
let db_anime_info = {
"animes": [{ "animeTitle": title }],
"idx": 0,
"episode_dif": 0
};
let db_info_value = await( db_info.get(anime_id));
if (db_info_value != null) {
db_anime_info = db_info_value;
} else {
db_info.put(anime_id, db_anime_info);
}
console.log("db_anime_info", db_anime_info);
console.log("src_url", src_url);
re_render("artplayer-app");
let art = NewPlayer(src_url, ".artplayer-app");
add_danmu(art);
let $count = document.querySelector("#count");
let $animeName = document.querySelector("#animeName");
let $animes = document.querySelector("#animes");
let $episodes = document.querySelector("#episodes");
function art_msgs(msgs) {
art.notice.show = msgs.join(",\n\n");
}
let UNSEARCHED = ["未搜索到番剧弹幕", "请按右键菜单", "手动搜索番剧名称"];
let SEARCHED = () => {
try {
return [`番剧:${$animes.options[$animes.selectedIndex].text}`, `章节: ${$episodes.options[$episodes.selectedIndex].text}`, `已加载 ${$count.textContent} 条弹幕`];
} catch (e) {
console.log(e);
return [];
}
};
init();
get_animes();
async function update_episode_danmu() {
const new_idx = $episodes.selectedIndex;
const db_anime_info2 = await db_info.get(anime_id);
const { episode_dif } = db_anime_info2;
let dif = new_idx + 1 - episode;
if (dif !== episode_dif) {
db_anime_info2["episode_dif"] = dif;
db_info.put(anime_id, db_anime_info2);
}
const episodeId = $episodes.value;
console.log("episodeId: ", episodeId);
let danmu = await get_comment(episodeId);
let danmus = bilibiliDanmuParseFromJson(danmu);
update_danmu(art, danmus);
}
function get_animes() {
const { animes, idx } = db_anime_info;
const { animeTitle } = animes[idx];
if (!animes[idx].hasOwnProperty("animeId")) {
console.log("没有缓存,请求接口");
get_animes_new(animeTitle);
} else {
console.log("有缓存,请求弹幕");
updateAnimes(animes, idx);
}
}
async function get_animes_new(title2) {
try {
const animes = await get_search_episodes(title2);
if (animes.length === 0) {
art_msgs(UNSEARCHED);
} else {
db_anime_info["animes"] = animes;
db_info.put(anime_id, db_anime_info);
}
return animes;
} catch (error) {
console.log("弹幕服务异常,稍后再试");
}
}
function init() {
art.on("artplayerPluginDanmuku:loaded", (danmus) => {
console.info("加载弹幕", danmus.length);
$count.textContent = danmus.length;
if ($count.textContent === "") {
art_msgs(UNSEARCHED);
} else {
art_msgs(SEARCHED());
}
});
art.on("pause", () => {
if ($count.textContent === "") {
art_msgs(UNSEARCHED);
} else {
art_msgs(SEARCHED());
}
});
$animeName.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
get_animes_new($animeName.value);
}
});
$animeName.addEventListener("blur", () => {
get_animes_new($animeName.value);
});
$animeName.value = db_anime_info["animes"][db_anime_info["idx"]]["animeTitle"];
$animes.addEventListener("change", async () => {
const new_idx = $animes.selectedIndex;
const { idx, animes } = db_anime_info;
if (new_idx !== idx) {
db_anime_info["idx"] = new_idx;
db_info.put(anime_id, db_anime_info);
updateEpisodes(animes[new_idx]);
}
});
$episodes.addEventListener("change", update_episode_danmu);
document.addEventListener("db_info_put", async function(e) {
let { animes: old_animes } = await db_info.get(anime_id);
let { animes: new_animes, idx: new_idx } = e.value;
if (new_animes !== old_animes) {
updateAnimes(new_animes, new_idx);
}
});
document.addEventListener("updateAnimes", function(e) {
console.log("updateAnimes 事件");
updateEpisodes(e.value);
});
document.addEventListener("updateEpisodes", function(e) {
console.log("updateEpisodes 事件");
update_episode_danmu();
});
}
function updateAnimes(animes, idx) {
const html = animes.reduce((html2, anime) => html2 + `<option value="${anime.animeId}">${anime.animeTitle}</option>`, "");
$animes.innerHTML = html;
$animes.value = animes[idx]["animeId"];
const event = new Event("updateAnimes");
event.value = animes[idx];
console.log(animes[idx]);
document.dispatchEvent(event);
}
async function updateEpisodes(anime) {
const { animeId, episodes } = anime;
const html = episodes.reduce((html2, episode2) => html2 + `<option value="${episode2.episodeId}">${episode2.episodeTitle}</option>`, "");
$episodes.innerHTML = html;
const db_anime_info2 = await db_info.get(anime_id);
const { episode_dif } = db_anime_info2;
let episodeId = get_episodeId(animeId, episode_dif + episode);
$episodes.value = episodeId;
const event = new Event("updateEpisodes");
document.dispatchEvent(event);
}
})(CryptoJS, artplayerPluginDanmuku, Artplayer, Dexie);