// ==UserScript==
// @name 莓挞B站视频批量下载助手
// @version 1.6.1
// @author 蓝莓蛋挞
// @description Bilibili 全合集/全分P/UP主全部视频快速解析,支持自定义下拉菜单、当前视频默认勾选、标题/Logo快速下载、批量视频/音频/XML或ASS弹幕字幕、直链优先、DASH无损合流。
// @match https://www.bilibili.com/video/*
// @match https://www.bilibili.com/list/*
// @icon https://i0.hdslb.com/bfs/static/jinkela/long/images/512.png
// @require https://cdn.jsdelivr.net/npm/@ffmpeg/ffmpeg@0.11.6/dist/ffmpeg.min.js
// @require https://cdn.jsdelivr.net/npm/qrcode@1.3.1/build/qrcode.min.js
// @grant GM_xmlhttpRequest
// @grant GM_download
// @grant GM_addStyle
// @grant unsafeWindow
// @connect api.bilibili.com
// @connect comment.bilibili.com
// @connect *.bilivideo.com
// @connect bilivideo.com
// @connect *.bilivideo.cn
// @connect bilivideo.cn
// @connect cdn.jsdelivr.net
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/*
* =========================================================
* 完整性校验
* =========================================================
*/
const TAMPER_PROTECTED = [
{
key: 'authorPopup',
name: 'createAuthorPopup',
fn: createAuthorPopup,
hash: 'aae352a6c75cc3afe08948a62bfe3a2d',
},
{
key: 'authorShow',
name: 'showAuthorPopup',
fn: showAuthorPopup,
hash: 'f31f2db260f6808c76c27ce236b36950',
},
{
key: 'authorHide',
name: 'hideAuthorPopup',
fn: hideAuthorPopup,
hash: '7df6a0528a66fe636f36bcbc34790ff8',
},
{
key: 'downloadOne',
name: 'downloadOne',
fn: downloadOne,
hash: '2b269a615a05cd6c1b105036bb2f2cd3',
},
{
key: 'startSingle',
name: 'startSingle',
fn: startSingle,
hash: '0a78eafdcbf7f4c876910807f1eb5321',
},
{
key: 'startQuick',
name: 'startQuickCurrent',
fn: startQuickCurrent,
hash: '194283d741cfda19e351f60187dca1c6',
},
{
key: 'startSelected',
name: 'startSelected',
fn: startSelected,
hash: 'f2991a24b99922ea986a163a37e7c5b7',
},
{
key: 'startSelectedAudio',
name: 'startSelectedAudio',
fn: startSelectedAudio,
hash: '507bbfd2bf3cca5d1e4fcc2849e21507',
},
{
key: 'startSelectedSubtitles',
name: 'startSelectedSubtitles',
fn: startSelectedSubtitles,
hash: 'bb026df940867b65227f804ed83489ac',
},
];
const WECHAT_QR_URL =
'http://weixin.qq.com/r/mp/aRANFT-EwGK-rXt290V7';
function md5(input) {
function safeAdd(x, y) {
const lsw = (x & 0xffff) + (y & 0xffff);
const msw = (x >> 16) + (y >> 16) + (lsw >> 16);
return (msw << 16) | (lsw & 0xffff);
}
function bitRotateLeft(num, cnt) {
return (num << cnt) | (num >>> (32 - cnt));
}
function md5cmn(q, a, b, x, s, t) {
return safeAdd(
bitRotateLeft(
safeAdd(safeAdd(a, q), safeAdd(x, t)),
s
),
b
);
}
function md5ff(a, b, c, d, x, s, t) {
return md5cmn((b & c) | (~b & d), a, b, x, s, t);
}
function md5gg(a, b, c, d, x, s, t) {
return md5cmn((b & d) | (c & ~d), a, b, x, s, t);
}
function md5hh(a, b, c, d, x, s, t) {
return md5cmn(b ^ c ^ d, a, b, x, s, t);
}
function md5ii(a, b, c, d, x, s, t) {
return md5cmn(c ^ (b | ~d), a, b, x, s, t);
}
function binlMD5(x, len) {
x[len >> 5] |= 0x80 << len % 32;
x[(((len + 64) >>> 9) << 4) + 14] = len;
let a = 1732584193;
let b = -271733879;
let c = -1732584194;
let d = 271733878;
for (let i = 0; i < x.length; i += 16) {
const olda = a;
const oldb = b;
const oldc = c;
const oldd = d;
a = md5ff(a, b, c, d, x[i], 7, -680876936);
d = md5ff(d, a, b, c, x[i + 1], 12, -389564586);
c = md5ff(c, d, a, b, x[i + 2], 17, 606105819);
b = md5ff(b, c, d, a, x[i + 3], 22, -1044525330);
a = md5ff(a, b, c, d, x[i + 4], 7, -176418897);
d = md5ff(d, a, b, c, x[i + 5], 12, 1200080426);
c = md5ff(c, d, a, b, x[i + 6], 17, -1473231341);
b = md5ff(b, c, d, a, x[i + 7], 22, -45705983);
a = md5ff(a, b, c, d, x[i + 8], 7, 1770035416);
d = md5ff(d, a, b, c, x[i + 9], 12, -1958414417);
c = md5ff(c, d, a, b, x[i + 10], 17, -42063);
b = md5ff(b, c, d, a, x[i + 11], 22, -1990404162);
a = md5ff(a, b, c, d, x[i + 12], 7, 1804603682);
d = md5ff(d, a, b, c, x[i + 13], 12, -40341101);
c = md5ff(c, d, a, b, x[i + 14], 17, -1502002290);
b = md5ff(b, c, d, a, x[i + 15], 22, 1236535329);
a = md5gg(a, b, c, d, x[i + 1], 5, -165796510);
d = md5gg(d, a, b, c, x[i + 6], 9, -1069501632);
c = md5gg(c, d, a, b, x[i + 11], 14, 643717713);
b = md5gg(b, c, d, a, x[i], 20, -373897302);
a = md5gg(a, b, c, d, x[i + 5], 5, -701558691);
d = md5gg(d, a, b, c, x[i + 10], 9, 38016083);
c = md5gg(c, d, a, b, x[i + 15], 14, -660478335);
b = md5gg(b, c, d, a, x[i + 4], 20, -405537848);
a = md5gg(a, b, c, d, x[i + 9], 5, 568446438);
d = md5gg(d, a, b, c, x[i + 14], 9, -1019803690);
c = md5gg(c, d, a, b, x[i + 3], 14, -187363961);
b = md5gg(b, c, d, a, x[i + 8], 20, 1163531501);
a = md5gg(a, b, c, d, x[i + 13], 5, -1444681467);
d = md5gg(d, a, b, c, x[i + 2], 9, -51403784);
c = md5gg(c, d, a, b, x[i + 7], 14, 1735328473);
b = md5gg(b, c, d, a, x[i + 12], 20, -1926607734);
a = md5hh(a, b, c, d, x[i + 5], 4, -378558);
d = md5hh(d, a, b, c, x[i + 8], 11, -2022574463);
c = md5hh(c, d, a, b, x[i + 11], 16, 1839030562);
b = md5hh(b, c, d, a, x[i + 14], 23, -35309556);
a = md5hh(a, b, c, d, x[i + 1], 4, -1530992060);
d = md5hh(d, a, b, c, x[i + 4], 11, 1272893353);
c = md5hh(c, d, a, b, x[i + 7], 16, -155497632);
b = md5hh(b, c, d, a, x[i + 10], 23, -1094730640);
a = md5hh(a, b, c, d, x[i + 13], 4, 681279174);
d = md5hh(d, a, b, c, x[i], 11, -358537222);
c = md5hh(c, d, a, b, x[i + 3], 16, -722521979);
b = md5hh(b, c, d, a, x[i + 6], 23, 76029189);
a = md5hh(a, b, c, d, x[i + 9], 4, -640364487);
d = md5hh(d, a, b, c, x[i + 12], 11, -421815835);
c = md5hh(c, d, a, b, x[i + 15], 16, 530742520);
b = md5hh(b, c, d, a, x[i + 2], 23, -995338651);
a = md5ii(a, b, c, d, x[i], 6, -198630844);
d = md5ii(d, a, b, c, x[i + 7], 10, 1126891415);
c = md5ii(c, d, a, b, x[i + 14], 15, -1416354905);
b = md5ii(b, c, d, a, x[i + 5], 21, -57434055);
a = md5ii(a, b, c, d, x[i + 12], 6, 1700485571);
d = md5ii(d, a, b, c, x[i + 3], 10, -1894986606);
c = md5ii(c, d, a, b, x[i + 10], 15, -1051523);
b = md5ii(b, c, d, a, x[i + 1], 21, -2054922799);
a = md5ii(a, b, c, d, x[i + 8], 6, 1873313359);
d = md5ii(d, a, b, c, x[i + 15], 10, -30611744);
c = md5ii(c, d, a, b, x[i + 6], 15, -1560198380);
b = md5ii(b, c, d, a, x[i + 13], 21, 1309151649);
a = md5ii(a, b, c, d, x[i + 4], 6, -145523070);
d = md5ii(d, a, b, c, x[i + 11], 10, -1120210379);
c = md5ii(c, d, a, b, x[i + 2], 15, 718787259);
b = md5ii(b, c, d, a, x[i + 9], 21, -343485551);
a = safeAdd(a, olda);
b = safeAdd(b, oldb);
c = safeAdd(c, oldc);
d = safeAdd(d, oldd);
}
return [a, b, c, d];
}
function binl2rstr(input) {
let output = '';
const length32 = input.length * 32;
for (let i = 0; i < length32; i += 8) {
output += String.fromCharCode(
(input[i >> 5] >>> i % 32) & 0xff
);
}
return output;
}
function rstr2binl(input) {
const output = [];
output[(input.length >> 2) - 1] = undefined;
for (let i = 0; i < output.length; i += 1) {
output[i] = 0;
}
const length8 = input.length * 8;
for (let i = 0; i < length8; i += 8) {
output[i >> 5] |=
(input.charCodeAt(i / 8) & 0xff) << i % 32;
}
return output;
}
function rstr2hex(input) {
const hexTab = '0123456789abcdef';
let output = '';
for (let i = 0; i < input.length; i += 1) {
const x = input.charCodeAt(i);
output +=
hexTab.charAt((x >>> 4) & 0x0f) +
hexTab.charAt(x & 0x0f);
}
return output;
}
function str2rstrUTF8(input) {
return unescape(
encodeURIComponent(input)
);
}
const raw = str2rstrUTF8(input);
return rstr2hex(
binl2rstr(
binlMD5(
rstr2binl(raw),
raw.length * 8
)
)
);
}
function normalizeCode(fn) {
return fn
.toString()
.replace(/\s+/g, '');
}
function verifyOne(check) {
const fn = check.fn;
if (typeof fn !== 'function') {
return {
ok: false,
name: check.name,
reason: 'missing',
};
}
const actual = md5(
normalizeCode(fn)
);
if (actual !== check.hash) {
return {
ok: false,
name: check.name,
reason: 'mismatch',
actual,
expected: check.hash,
};
}
return { ok: true };
}
/*
* 包装受保护函数:
* 调用前先做 MD5 校验,发现篡改则拦截下载并提示。
*/
function protectEntry(name, fn) {
const check =
TAMPER_PROTECTED.find(
item => item.name === name
) || null;
return function (...args) {
if (check) {
const result =
verifyOne(check);
if (!result.ok) {
markTampered(result);
return;
}
}
return fn.apply(
this,
args
);
};
}
let tamperNotified = false;
function markTampered(detail) {
const panel =
document.getElementById(
PANEL_ID
);
if (panel) {
panel.classList.add(
'tm-tampered'
);
}
setStatus(
'⚠️ 检测到非原版脚本,已阻止下载',
'error'
);
if (!tamperNotified) {
tamperNotified = true;
showTamperCard(detail);
}
}
function showTamperCard(detail) {
const id =
`${PANEL_ID}-tamper-card`;
if (
document.getElementById(
id
)
) {
return;
}
const wrap =
document.createElement(
'div'
);
wrap.id = id;
wrap.innerHTML = `
⚠️ 检测到非原版脚本
当前脚本可能已被篡改,已停止下载
可能存在病毒等未知风险,请停止使用
关注公众号,获取原版脚本
`;
document.body.appendChild(wrap);
wrap
.querySelector(
'.tm-tamper-close'
)
?.addEventListener(
'click',
() => {
wrap.remove();
}
);
wrap.addEventListener(
'click',
event => {
if (
event.target ===
wrap
) {
wrap.remove();
}
}
);
try {
if (typeof QRCode !== 'undefined') {
QRCode.toCanvas(
document.getElementById(
`${id}-qr`
),
WECHAT_QR_URL,
{ width: 150, margin: 2 },
err => {
if (err) {
console.error(
'[BiliDL] QR generation failed:',
err
);
}
}
);
}
} catch (err) {
console.error(
'[BiliDL] QRCode unavailable:',
err
);
}
}
/*
* =========================================================
* 常量
* =========================================================
*/
const PANEL_ID = 'tm-bili-downloader';
const BILI_BLUE = '#00AEEC';
const FALLBACK_LOGO =
'https://i0.hdslb.com/bfs/static/jinkela/long/images/512.png';
const CORE_PATH =
'https://cdn.jsdelivr.net/npm/@ffmpeg/core-st@0.11.1/dist/ffmpeg-core.js';
const QUALITY_FALLBACK = [
{ qn: 127, label: '8K 超高清' },
{ qn: 126, label: '杜比视界' },
{ qn: 125, label: 'HDR 真彩' },
{ qn: 120, label: '4K 超清' },
{ qn: 116, label: '1080P 60帧' },
{ qn: 112, label: '1080P 高码率' },
{ qn: 80, label: '1080P 高清' },
{ qn: 74, label: '720P 60帧' },
{ qn: 64, label: '720P 高清' },
{ qn: 32, label: '480P 清晰' },
{ qn: 16, label: '360P 流畅' },
];
const CODECS = {
auto: {
ids: [12, 7, 13],
label: '自动(优先 H.265)',
},
avc: {
ids: [7],
label: 'AVC / H.264',
},
hevc: {
ids: [12],
label: 'HEVC / H.265',
},
av1: {
ids: [13],
label: 'AV1',
},
};
const state = {
href: location.href,
videoData: null,
items: [],
collectionTitle: '',
hasCollection: false,
formats: [],
selectedKeys: new Set(),
lastCheckedKey: '',
search: '',
busy: false,
initSeq: 0,
scrollCurrentPending: false,
listMode: false,
listUid: '',
listTotal: 0,
listLoadedCount: 0,
listCursor: null,
listCollectionTitle: '',
listRawItems: [],
listMaxCount: 0,
listDateStart: '',
listDateEnd: '',
listAutoScroll: false,
listLoading: false,
};
const $ = (
selector,
root = document
) =>
root.querySelector(
selector
);
const $$ = (
selector,
root = document
) => [
...root.querySelectorAll(
selector
),
];
const sleep = ms =>
new Promise(
resolve =>
setTimeout(
resolve,
ms
)
);
function unsafeWindowSafe() {
try {
return typeof unsafeWindow !==
'undefined'
? unsafeWindow
: window;
} catch {
return window;
}
}
function sanitize(name) {
return (
String(
name ||
'bilibili_video'
)
.replace(
/[\\/:*?"<>|\u0000-\u001f]+/g,
'_'
)
.replace(
/\s+/g,
' '
)
.replace(
/[. ]+$/g,
''
)
.trim()
.slice(
0,
180
) ||
'bilibili_video'
);
}
function escapeHtml(text) {
return String(
text
).replace(
/[&<>'"]/g,
c => ({
'&': '&',
'<': '<',
'>': '>',
"'": ''',
'"': '"',
})[c]
);
}
function pad(
n,
width = 2
) {
return String(
n
).padStart(
width,
'0'
);
}
function formatBytes(bytes) {
if (
!Number.isFinite(
bytes
) ||
bytes <= 0
) {
return '未知';
}
const units = [
'B',
'KB',
'MB',
'GB',
];
let n = bytes;
let i = 0;
while (
n >= 1024 &&
i <
units.length - 1
) {
n /= 1024;
i += 1;
}
return `${n.toFixed(
i >= 2
? 2
: 1
)} ${units[i]}`;
}
function setStatus(
text,
type = ''
) {
const el =
$(
`#${PANEL_ID} .tm-status`
);
if (!el) {
return;
}
el.textContent =
text;
el.dataset.type =
type;
}
function setProgress(
value,
text = ''
) {
const p =
Math.max(
0,
Math.min(
100,
Number(
value
) || 0
)
);
const fill =
$(
`#${PANEL_ID} .tm-progress-fill`
);
const label =
$(
`#${PANEL_ID} .tm-progress-text`
);
if (
fill
) {
fill.style.width =
`${p}%`;
}
if (
label
) {
label.textContent =
text ||
`${p.toFixed(
0
)}%`;
}
}
function setBusy(busy) {
state.busy =
busy;
$$(
`#${PANEL_ID} button,
#${PANEL_ID} input`
).forEach(
el => {
if (
el.classList.contains(
'tm-min-btn'
)
) {
return;
}
el.disabled =
busy;
}
);
updateSelectAllState();
}
/*
* =========================================================
* Logo
* =========================================================
*/
function findOfficialLogo() {
const selectors = [
'#biliMainHeader .mini-header__logo',
'.bili-header .mini-header__logo',
'.left-entry__title .mini-header__logo',
'.mini-header__logo',
'.zhuzhan-icon',
];
for (
const selector
of selectors
) {
const element =
document.querySelector(
selector
);
if (
!element ||
element.closest(
`#${PANEL_ID}`
)
) {
continue;
}
if (
element.tagName
?.toLowerCase() ===
'svg'
) {
return element;
}
const svg =
element.querySelector
?.(
'svg'
);
if (
svg
) {
return svg;
}
}
return null;
}
function mountOfficialLogo() {
const host =
$(
`#${PANEL_ID} .tm-logo-host`
);
if (
!host
) {
return false;
}
const official =
findOfficialLogo();
if (
official
) {
const clone =
official.cloneNode(
true
);
clone.removeAttribute(
'id'
);
clone.removeAttribute(
'style'
);
clone.classList.add(
'tm-bili-official-svg'
);
clone.setAttribute(
'aria-hidden',
'true'
);
host.innerHTML =
'';
host.appendChild(
clone
);
host.classList.add(
'is-svg'
);
return true;
}
host.innerHTML = `
`;
host.classList.remove(
'is-svg'
);
return false;
}
function startLogoWatcher() {
let attempts =
0;
const timer =
setInterval(
() => {
attempts +=
1;
if (
mountOfficialLogo() ||
attempts >= 30
) {
clearInterval(
timer
);
}
},
500
);
}
/*
* =========================================================
* API
* =========================================================
*/
function getCurrentBvid() {
const match =
location.pathname.match(
/\/video\/(BV[0-9A-Za-z]+)/i
);
if (
match
) {
return match[1];
}
const urlBvid =
new URL(
location.href
).searchParams.get(
'bvid'
);
if (
urlBvid
) {
return urlBvid;
}
const initial =
unsafeWindowSafe()
?.__INITIAL_STATE__;
return (
initial
?.videoData
?.bvid ||
initial
?.bvid ||
''
);
}
/*
* =========================================================
* 列表页 (/list/uid) 批量加载
* =========================================================
*/
function isListPage() {
return /^\/list\/\d+/.test(
location.pathname
);
}
function getListUid() {
const match =
location.pathname.match(
/\/list\/(\d+)/
);
if (match) {
return match[1];
}
const initial =
unsafeWindowSafe()
?.__INITIAL_STATE__;
return (
initial?.upInfo?.mid ||
initial?.mediaListInfo?.mid ||
''
);
}
function getListInitialState() {
const initial =
unsafeWindowSafe()
?.__INITIAL_STATE__;
if (!initial) {
return null;
}
return {
uid:
String(
initial.upInfo?.mid ||
initial.mediaListInfo
?.mid ||
getListUid()
),
total:
Number(
initial.listTotal ||
initial.mediaListInfo
?.media_count ||
0
),
pn:
Number(
initial.pn || 1
),
cursor:
initial.cursor ||
null,
resourceList:
Array.isArray(
initial.resourceList
)
? initial.resourceList
: [],
};
}
function normalizeListPages(
pages
) {
if (
!Array.isArray(
pages
)
) {
return [];
}
return pages
.filter(
page =>
page &&
(page.cid ||
page.id)
)
.map(
(
page,
index
) => ({
cid:
page.cid ||
page.id,
page:
page.page ||
page.p ||
index + 1,
part:
page.part ||
page.title ||
`P${index + 1}`,
duration:
page.duration ||
0,
})
);
}
function resourceItemToEpisode(
item
) {
const bvid =
item.bvid ||
item.bv_id;
const title =
item.title ||
bvid;
let pages =
normalizeListPages(
item.pages
);
if (
!pages.length
) {
pages = [
{
cid:
item.cid ||
0,
page: 1,
part:
title,
duration:
item.duration ||
0,
},
];
}
return {
bvid,
title,
pubtime:
item.pubtime ||
item.ctime ||
0,
isSeason: false,
parts: pages,
};
}
function buildListTree(
resourceList
) {
return resourceList
.filter(
item =>
item &&
(item.bvid ||
item.bv_id)
)
.map(
resourceItemToEpisode
);
}
async function fetchMediaListPage(
bizId,
cursor
) {
const url =
new URL(
'https://api.bilibili.com/x/v2/medialist/resource/list'
);
url.searchParams.set(
'out_referer',
''
);
url.searchParams.set(
'mobi_app',
'web'
);
url.searchParams.set(
'type',
'1'
);
url.searchParams.set(
'biz_id',
bizId
);
url.searchParams.set(
'ps',
'20'
);
url.searchParams.set(
'desc',
'false'
);
url.searchParams.set(
'sort_field',
'1'
);
url.searchParams.set(
'tid',
'0'
);
url.searchParams.set(
'bvid',
cursor?.bvid ||
''
);
url.searchParams.set(
'oid',
cursor?.oid ||
''
);
url.searchParams.set(
'otype',
'2'
);
url.searchParams.set(
'with_current',
'false'
);
url.searchParams.set(
'direction',
'true'
);
url.searchParams.set(
'preview',
'0'
);
url.searchParams.set(
'use_pn',
'false'
);
url.searchParams.set(
'pn',
'1'
);
url.searchParams.set(
'web_location',
'333.1245'
);
return apiJson(
url.toString()
);
}
function scrollNativeList() {
const el =
document.getElementById(
'playlist-video-action-list'
);
if (!el) {
return;
}
el.scrollTo({
top:
el.scrollHeight,
behavior:
'smooth',
});
}
function updateListBatchUI() {
const panel =
document.getElementById(
PANEL_ID
);
if (!panel) {
return;
}
panel.classList.toggle(
'tm-list-mode',
state.listMode
);
const countEl =
$(
`#${PANEL_ID} .tm-list-batch-count`
);
const loadBtn =
$(
`#${PANEL_ID} .tm-list-load-btn`
);
if (
countEl
) {
countEl.textContent =
state.listMode
? `已加载 ${state.listLoadedCount} / ${state.listTotal}`
: '';
}
if (
loadBtn
) {
loadBtn.disabled =
state.listLoading ||
state.busy;
loadBtn.textContent =
state.listLoading
? '加载中…'
: '加载/刷新列表';
}
}
async function enrichInitialPubtimes(
resourceList
) {
const needFetch =
resourceList.filter(
item =>
item &&
!(item.pubtime || item.ctime)
);
if (
!needFetch.length
) {
return;
}
const results =
await Promise.all(
needFetch.map(
async item => {
try {
const data =
await fetchView(
item.bvid
);
item.pubtime =
Number(
data?.pubdate ||
0
);
} catch (err) {
console.warn(
'[BiliDL] fetch pubdate failed:',
item.bvid,
err
);
}
return item;
}
)
);
const map =
new Map(
results.map(
item => [
item.bvid,
item,
]
)
);
resourceList.forEach(
item => {
if (
map.has(
item.bvid
)
) {
const fetched =
map.get(
item.bvid
);
item.pubtime =
fetched.pubtime;
}
}
);
}
async function loadListItems(
options = {}
) {
const bizId =
state.listUid;
if (
!bizId
) {
throw new Error(
'未识别到列表用户 UID'
);
}
const maxCount =
Number(
options.maxCount
) || 0;
const dateStart =
options.dateStart;
const dateEnd =
options.dateEnd;
const autoScroll =
Boolean(
options.autoScroll
);
const fetchMore =
Boolean(
options.fetchMore
);
const startTs =
dateStart
? new Date(
`${dateStart}T00:00:00`
).getTime() /
1000
: 0;
const endTs =
dateEnd
? new Date(
`${dateEnd}T23:59:59`
).getTime() /
1000
: Infinity;
state.listLoading =
true;
updateListBatchUI();
try {
const initial =
getListInitialState();
let resourceList =
initial?.resourceList
? initial.resourceList.map(
item => ({
...item,
})
)
: [];
if (
(dateStart ||
dateEnd) &&
resourceList.length
) {
setStatus(
'正在补齐前 20 个视频的发布时间…'
);
await enrichInitialPubtimes(
resourceList
);
}
resourceList =
resourceList.filter(
item => {
const pubtime =
item.pubtime ||
0;
if (
pubtime &&
pubtime <
startTs
) {
return false;
}
if (
pubtime &&
pubtime >
endTs
) {
return false;
}
return true;
}
);
const seen =
new Set(
resourceList.map(
item =>
item.bvid
)
);
let cursor =
state.listCursor;
let hasMore =
fetchMore &&
state.listTotal >
resourceList.length;
let pageCount =
0;
while (
hasMore
) {
if (
maxCount >
0 &&
seen.size >=
maxCount
) {
break;
}
if (
pageCount >
200
) {
break;
}
setStatus(
`正在加载列表第 ${pageCount + 1} 页…`
);
const data =
await fetchMediaListPage(
bizId,
cursor
);
const list =
Array.isArray(
data?.media_list
)
? data.media_list
: [];
hasMore =
Boolean(
data?.has_more
) &&
list.length >
0;
if (
!list.length
) {
break;
}
for (
const item
of list
) {
const pubtime =
item.pubtime ||
0;
if (
pubtime &&
pubtime <
startTs
) {
hasMore = false;
break;
}
if (
pubtime &&
pubtime >
endTs
) {
continue;
}
const bvid =
item.bv_id;
if (
!bvid ||
seen.has(
bvid
)
) {
continue;
}
seen.add(
bvid
);
resourceList.push(
item
);
if (
maxCount >
0 &&
seen.size >=
maxCount
) {
hasMore = false;
break;
}
}
const first =
list[0];
cursor =
first
? {
bvid:
first.bv_id,
oid:
String(
first.id
),
}
: null;
pageCount +=
1;
if (
autoScroll
) {
scrollNativeList();
}
await sleep(
120
);
}
state.listRawItems =
resourceList;
state.listLoadedCount =
resourceList.length;
state.listCursor =
cursor;
const tree =
buildListTree(
resourceList
);
state.items =
buildFlatItems(
tree,
state.listCollectionTitle
);
state.hasCollection =
state.items.length >
1;
state.collectionTitle =
state.hasCollection
? sanitize(
state.listCollectionTitle
)
: '';
state.selectedKeys =
new Set(
state.items.map(
item =>
item.key
)
);
state.search =
'';
const searchInput =
$(
`#${PANEL_ID} .tm-search`
);
if (
searchInput
) {
searchInput.value =
'';
}
renderItems();
updateListBatchUI();
setStatus(
`列表加载完成 · ${state.listLoadedCount} 个视频`,
'ok'
);
} finally {
state.listLoading =
false;
updateListBatchUI();
}
}
async function initListPage() {
const initial =
getListInitialState();
const uid =
initial?.uid ||
getListUid();
if (
!uid
) {
throw new Error(
'当前列表页未识别到用户 UID'
);
}
const sameList =
state.listMode &&
state.listUid ===
uid &&
state.listRawItems.length >
0;
state.listMode =
true;
state.listUid =
uid;
state.listTotal =
initial?.total ||
initial
?.resourceList
?.length ||
0;
state.listCollectionTitle =
initial?.resourceList?.[0]
?.upper?.name ||
unsafeWindowSafe()
?.__INITIAL_STATE__
?.upInfo
?.name ||
'UP主全部视频';
const currentBvid =
getCurrentBvid();
const currentItem =
initial?.resourceList?.find(
item =>
item.bvid ===
currentBvid
);
state.listCursor =
currentItem
? {
bvid:
currentItem.bvid,
oid:
String(
currentItem.oid
),
}
: initial?.resourceList?.[0]
? {
bvid:
initial
.resourceList[0]
.bvid,
oid:
String(
initial
.resourceList[0]
.oid
),
}
: null;
if (
sameList
) {
state.scrollCurrentPending =
true;
renderItems();
return {
currentBvid,
videoData:
unsafeWindowSafe()
?.__INITIAL_STATE__
?.videoData,
tree:
buildListTree(
state.listRawItems
),
};
}
await loadListItems({
maxCount: 0,
dateStart: '',
dateEnd: '',
autoScroll: false,
fetchMore: false,
});
return {
currentBvid,
videoData:
unsafeWindowSafe()
?.__INITIAL_STATE__
?.videoData,
tree:
buildListTree(
state.listRawItems
),
};
}
async function apiJson(url) {
const res =
await fetch(
url,
{
credentials:
'include',
referrer:
location.href,
referrerPolicy:
'strict-origin-when-cross-origin',
headers: {
Accept:
'application/json, text/plain, */*',
},
}
);
if (
!res.ok
) {
throw new Error(
`API HTTP ${res.status}`
);
}
const json =
await res.json();
if (
json.code !== 0
) {
throw new Error(
json.message ||
`API code ${json.code}`
);
}
return json.data;
}
async function fetchView(
bvid
) {
const url =
new URL(
'https://api.bilibili.com/x/web-interface/view'
);
url.searchParams.set(
'bvid',
bvid
);
return apiJson(
url.toString()
);
}
async function fetchPagelist(
bvid
) {
const url =
new URL(
'https://api.bilibili.com/x/player/pagelist'
);
url.searchParams.set(
'bvid',
bvid
);
url.searchParams.set(
'jsonp',
'jsonp'
);
const data =
await apiJson(
url.toString()
);
return Array.isArray(
data
)
? data
: [];
}
function getFreshInitialVideoData(
currentBvid
) {
const videoData =
unsafeWindowSafe()
?.__INITIAL_STATE__
?.videoData;
if (
!videoData
) {
return null;
}
if (
videoData.bvid &&
currentBvid &&
videoData.bvid !==
currentBvid
) {
return null;
}
return videoData;
}
/*
* =========================================================
* 合集 / 分P
* =========================================================
*/
function extractSeasonEpisodes(
videoData
) {
const episodes =
[];
const sections =
videoData
?.ugc_season
?.sections;
if (
!Array.isArray(
sections
)
) {
return episodes;
}
for (
const section
of sections
) {
if (
!Array.isArray(
section
?.episodes
)
) {
continue;
}
for (
const ep
of section.episodes
) {
if (
!ep?.bvid
) {
continue;
}
episodes.push({
bvid:
ep.bvid,
title:
ep.title ||
ep.arc
?.title ||
ep.bvid,
cid:
ep.cid ||
0,
isSeason:
true,
});
}
}
return episodes;
}
async function fetchSeasonArchivesFallback(
videoData
) {
const seasonId =
videoData
?.ugc_season
?.id;
const mid =
videoData
?.owner
?.mid;
if (
!seasonId ||
!mid
) {
return [];
}
const archives =
[];
let pageNum =
1;
let total =
Infinity;
while (
archives.length <
total
) {
const url =
new URL(
'https://api.bilibili.com/x/polymer/web-space/seasons_archives_list'
);
url.searchParams.set(
'mid',
String(
mid
)
);
url.searchParams.set(
'season_id',
String(
seasonId
)
);
url.searchParams.set(
'page_num',
String(
pageNum
)
);
url.searchParams.set(
'page_size',
'100'
);
const data =
await apiJson(
url.toString()
);
const list =
Array.isArray(
data
?.archives
)
? data.archives
: [];
total =
Number(
data
?.page
?.total ??
list.length
);
for (
const item
of list
) {
if (
!item?.bvid
) {
continue;
}
archives.push({
bvid:
item.bvid,
title:
item.title ||
item.bvid,
cid:
item.cid ||
0,
isSeason:
true,
});
}
if (
!list.length ||
archives.length >=
total
) {
break;
}
pageNum +=
1;
if (
pageNum >
100
) {
break;
}
}
return [
...new Map(
archives.map(
item => [
item.bvid,
item,
]
)
).values(),
];
}
function buildFlatItems(
tree,
collectionTitle
) {
const flat =
[];
tree.forEach(
(
episode,
episodeIndex
) => {
episode.parts.forEach(
(
part,
partIndex
) => {
flat.push({
key:
`${episode.bvid}:${part.cid}`,
bvid:
episode.bvid,
cid:
part.cid,
page:
part.page ||
partIndex + 1,
pages:
episode.parts.length,
part:
sanitize(
part.part ||
`P${partIndex + 1}`
),
videoTitle:
sanitize(
episode.title ||
episode.bvid
),
collectionTitle:
sanitize(
collectionTitle ||
''
),
collectionIndex:
episodeIndex +
1,
collectionCount:
tree.length,
isSeason:
episode.isSeason,
});
}
);
}
);
return flat;
}
async function parseResourcesFast() {
const currentBvid =
getCurrentBvid();
if (
!currentBvid
) {
throw new Error(
'当前页面没有识别到 BV 号'
);
}
setStatus(
'正在读取合集信息…'
);
let videoData =
getFreshInitialVideoData(
currentBvid
);
if (
!videoData
) {
videoData =
await fetchView(
currentBvid
);
}
state.videoData =
videoData;
let rawEpisodes =
extractSeasonEpisodes(
videoData
);
if (
!rawEpisodes.length &&
videoData
?.ugc_season
?.id
) {
try {
rawEpisodes =
await fetchSeasonArchivesFallback(
videoData
);
} catch (err) {
console.warn(
'[BiliDL] season fallback failed:',
err
);
}
}
if (
!rawEpisodes.length
) {
rawEpisodes = [
{
bvid:
currentBvid,
title:
videoData
?.title ||
currentBvid,
cid:
videoData
?.cid ||
0,
isSeason:
false,
},
];
}
rawEpisodes = [
...new Map(
rawEpisodes.map(
episode => [
episode.bvid,
episode,
]
)
).values(),
];
setStatus(
`正在并发解析 ${rawEpisodes.length} 个视频的分P…`
);
const tree =
await Promise.all(
rawEpisodes.map(
async episode => {
let pages =
[];
if (
episode.bvid ===
currentBvid &&
Array.isArray(
videoData
?.pages
) &&
videoData
.pages
.length
) {
pages =
videoData.pages;
} else {
try {
pages =
await fetchPagelist(
episode.bvid
);
} catch (err) {
console.warn(
`[BiliDL] pagelist failed: ${episode.bvid}`,
err
);
}
}
if (
!pages.length
) {
pages = [
{
cid:
episode.cid ||
(
episode.bvid ===
currentBvid
? videoData
?.cid
: 0
),
page:
1,
part:
episode.title,
},
];
}
return {
bvid:
episode.bvid,
title:
episode.title,
isSeason:
episode.isSeason,
parts:
pages
.filter(
page =>
page
?.cid
)
.map(
(
page,
index
) => ({
cid:
page.cid,
page:
page.page ||
index +
1,
part:
page.part ||
`P${index + 1}`,
})
),
};
}
)
);
const collectionTitle =
videoData
?.ugc_season
?.title ||
videoData
?.ugc_season
?.name ||
videoData
?.title ||
'合集';
state.items =
buildFlatItems(
tree,
collectionTitle
);
state.hasCollection =
Boolean(
videoData
?.ugc_season
?.id
) ||
rawEpisodes.length >
1;
state.collectionTitle =
state.hasCollection
? sanitize(
collectionTitle
)
: '';
/*
* 默认只勾选当前视频 / 当前分P。
*/
state.selectedKeys.clear();
const currentItem =
getCurrentItem();
if (
currentItem
) {
state.selectedKeys.add(
currentItem.key
);
}
state.search =
'';
state.scrollCurrentPending =
true;
const searchInput =
$(
`#${PANEL_ID} .tm-search`
);
if (
searchInput
) {
searchInput.value =
'';
}
const searchWrap =
$(
`#${PANEL_ID} .tm-search-wrap`
);
if (
searchWrap
) {
searchWrap.classList.remove(
'open'
);
}
return {
currentBvid,
videoData,
tree,
};
}
/*
* =========================================================
* 当前视频
* =========================================================
*/
function getCurrentItem() {
const currentBvid =
getCurrentBvid();
if (
!currentBvid
) {
return null;
}
const sameVideo =
state.items.filter(
item =>
item.bvid ===
currentBvid
);
if (
!sameVideo.length
) {
return null;
}
const pParam =
Number(
new URL(
location.href
).searchParams.get(
'p'
) ||
0
);
if (
pParam >
0
) {
const byPage =
sameVideo.find(
item =>
Number(
item.page
) ===
pParam
);
if (
byPage
) {
return byPage;
}
}
const currentCid =
Number(
state.videoData
?.cid ||
0
);
if (
currentCid
) {
const byCid =
sameVideo.find(
item =>
Number(
item.cid
) ===
currentCid
);
if (
byCid
) {
return byCid;
}
}
return sameVideo[0];
}
function flashCurrentRow(
row
) {
if (
!row
) {
return;
}
row.classList.remove(
'tm-current-flash'
);
void row.offsetWidth;
row.classList.add(
'tm-current-flash'
);
setTimeout(
() => {
row.classList.remove(
'tm-current-flash'
);
},
1400
);
}
function scrollCurrentItemToTop() {
if (
!state.scrollCurrentPending ||
state.search
) {
return;
}
const panel =
document.getElementById(
PANEL_ID
);
const list =
$(
`#${PANEL_ID} .tm-items`
);
if (
!panel ||
!list
) {
return;
}
if (
panel.classList.contains(
'minimized'
)
) {
return;
}
if (
list.offsetParent ===
null ||
list.clientHeight <=
0
) {
return;
}
const currentRow =
list.querySelector(
'.tm-item[data-current="1"]'
);
if (
!currentRow
) {
return;
}
requestAnimationFrame(
() => {
if (
panel.classList.contains(
'minimized'
) ||
list.offsetParent ===
null ||
list.clientHeight <=
0
) {
return;
}
list.scrollTop =
Math.max(
0,
currentRow.offsetTop
);
state.scrollCurrentPending =
false;
requestAnimationFrame(
() => {
flashCurrentRow(
currentRow
);
}
);
}
);
}
/*
* =========================================================
* 播放信息
* =========================================================
*/
async function fetchPlayData(
item,
qn
) {
const url =
new URL(
'https://api.bilibili.com/x/player/playurl'
);
url.searchParams.set(
'bvid',
item.bvid
);
url.searchParams.set(
'cid',
String(
item.cid
)
);
url.searchParams.set(
'qn',
String(
qn
)
);
url.searchParams.set(
'fnver',
'0'
);
url.searchParams.set(
'fnval',
'4048'
);
url.searchParams.set(
'fourk',
'1'
);
url.searchParams.set(
'otype',
'json'
);
return apiJson(
url.toString()
);
}
/*
* 尝试获取已经包含音视频的 durl。
*
* 能拿到完整单文件时优先直接下载,
* 不进入 FFmpeg。
*/
async function fetchDirectPlayData(
item,
qn
) {
const url =
new URL(
'https://api.bilibili.com/x/player/playurl'
);
url.searchParams.set(
'bvid',
item.bvid
);
url.searchParams.set(
'cid',
String(
item.cid
)
);
url.searchParams.set(
'qn',
String(
qn
)
);
url.searchParams.set(
'fnver',
'0'
);
url.searchParams.set(
'fnval',
'0'
);
url.searchParams.set(
'fourk',
'0'
);
url.searchParams.set(
'otype',
'json'
);
return apiJson(
url.toString()
);
}
function normalizeFormats(
data
) {
const map =
new Map();
for (
const format
of data
?.support_formats ||
[]
) {
const qn =
Number(
format
.quality
);
if (
!qn
) {
continue;
}
map.set(
qn,
{
qn,
label:
format
.new_description ||
format
.display_desc ||
format
.superscript ||
format
.format ||
`画质 ${qn}`,
}
);
}
for (
const quality
of data
?.accept_quality ||
[]
) {
const qn =
Number(
quality
);
if (
!qn ||
map.has(
qn
)
) {
continue;
}
map.set(
qn,
{
qn,
label:
QUALITY_FALLBACK.find(
item =>
item.qn ===
qn
)
?.label ||
`画质 ${qn}`,
}
);
}
return [
...map.values(),
].sort(
(
a,
b
) =>
b.qn -
a.qn
);
}
function codecName(
codecid
) {
const id =
Number(
codecid
);
if (
id === 7
) {
return 'H.264';
}
if (
id === 12
) {
return 'H.265';
}
if (
id === 13
) {
return 'AV1';
}
return `codec ${id}`;
}
function frameRateName(
stream
) {
const value =
String(
stream
?.frameRate ||
stream
?.frame_rate ||
''
).trim();
if (
!value
) {
return '';
}
if (
/^\d+(?:\.\d+)?$/.test(
value
)
) {
return `${Math.round(
Number(
value
)
)}fps`;
}
return value
.toLowerCase()
.includes(
'fps'
)
? value
: `${value}fps`;
}
function streamUrls(
stream
) {
const urls =
[];
const add =
value => {
if (
!value
) {
return;
}
if (
Array.isArray(
value
)
) {
value.forEach(
add
);
return;
}
const url =
String(
value
).replace(
/^http:/,
'https:'
);
if (
url &&
!urls.includes(
url
)
) {
urls.push(
url
);
}
};
add(
stream
?.baseUrl
);
add(
stream
?.base_url
);
add(
stream
?.url
);
add(
stream
?.backupUrl
);
add(
stream
?.backup_url
);
return urls;
}
function getActualSupportedQns(
data
) {
const qns =
new Set();
for (
const video
of data
?.dash
?.video ||
[]
) {
const qn =
Number(
video.id
);
if (
qn
) {
qns.add(
qn
);
}
}
if (
!qns.size
) {
for (
const format
of data
?.support_formats ||
[]
) {
const qn =
Number(
format.quality
);
if (
qn
) {
qns.add(
qn
);
}
}
}
return [
...qns,
].sort(
(
a,
b
) =>
b - a
);
}
function resolveTargetQn(
data,
requestedQn
) {
const supported =
getActualSupportedQns(
data
);
if (
!supported.length
) {
return Number(
data?.quality ||
0
);
}
for (
const qn
of supported
) {
if (
qn <=
requestedQn
) {
return qn;
}
}
return supported[
supported.length -
1
];
}
function pickExactVideo(
data,
requestedQn,
codecPref
) {
const targetQn =
resolveTargetQn(
data,
requestedQn
);
if (
!targetQn
) {
throw new Error(
'当前视频没有可用的视频流。'
);
}
const videos =
Array.isArray(
data
?.dash
?.video
)
? data
.dash
.video
: [];
const exact =
videos.filter(
video =>
Number(
video.id
) ===
targetQn
);
if (
!exact.length
) {
throw new Error(
'当前视频没有所选画质的视频流。'
);
}
const codecIds =
CODECS[
codecPref
]?.ids ||
CODECS
.auto
.ids;
for (
const codecId
of codecIds
) {
const candidates =
exact
.filter(
video =>
Number(
video
.codecid
) ===
codecId
)
.sort(
(
a,
b
) =>
(
b.bandwidth ||
0
) -
(
a.bandwidth ||
0
)
);
if (
candidates.length
) {
return candidates[0];
}
}
throw new Error(
`当前画质没有 ${
CODECS[
codecPref
]?.label ||
codecPref
} 编码的视频流。`
);
}
function pickAudio(
data
) {
const list =
[];
if (
Array.isArray(
data
?.dash
?.audio
)
) {
list.push(
...data
.dash
.audio
);
}
if (
Array.isArray(
data
?.dash
?.dolby
?.audio
)
) {
list.push(
...data
.dash
.dolby
.audio
);
}
if (
data
?.dash
?.flac
?.audio
) {
list.push(
data
.dash
.flac
.audio
);
}
if (
!list.length
) {
return null;
}
return list.sort(
(
a,
b
) =>
(
b.bandwidth ||
0
) -
(
a.bandwidth ||
0
)
)[0];
}
/*
* =========================================================
* 下载网络数据
* =========================================================
*/
function requestArrayBufferUrl(
url,
onProgress
) {
return new Promise(
(
resolve,
reject
) => {
GM_xmlhttpRequest({
method:
'GET',
url,
responseType:
'arraybuffer',
timeout:
120000,
headers: {
Referer:
'https://www.bilibili.com/',
Origin:
'https://www.bilibili.com',
},
onprogress:
event => {
if (
typeof onProgress ===
'function'
) {
onProgress(
event.loaded ||
0,
event.total ||
0
);
}
},
onload:
response => {
if (
response.status >=
200 &&
response.status <
300 &&
response.response
) {
resolve(
response.response
);
} else {
reject(
new Error(
`HTTP ${response.status}`
)
);
}
},
onerror:
() =>
reject(
new Error(
'媒体网络请求失败'
)
),
ontimeout:
() =>
reject(
new Error(
'媒体网络请求超时'
)
),
});
}
);
}
function requestTextUrl(
url
) {
return new Promise(
(
resolve,
reject
) => {
GM_xmlhttpRequest({
method:
'GET',
url,
responseType:
'text',
timeout:
120000,
headers: {
Referer:
'https://www.bilibili.com/',
Origin:
'https://www.bilibili.com',
},
onload:
response => {
const text =
typeof response
.responseText ===
'string'
? response
.responseText
: String(
response
.response ||
''
);
if (
response.status >=
200 &&
response.status <
300
) {
resolve(
text
);
} else {
reject(
new Error(
`HTTP ${response.status}`
)
);
}
},
onerror:
() =>
reject(
new Error(
'文本网络请求失败'
)
),
ontimeout:
() =>
reject(
new Error(
'文本网络请求超时'
)
),
});
}
);
}
async function requestStreamBuffer(
stream,
onProgress
) {
const urls =
streamUrls(
stream
);
if (
!urls.length
) {
throw new Error(
'媒体直链为空'
);
}
let lastError =
null;
for (
let i = 0;
i <
urls.length;
i += 1
) {
try {
return await requestArrayBufferUrl(
urls[i],
onProgress
);
} catch (err) {
lastError =
err;
console.warn(
`[BiliDL] CDN ${i + 1}/${urls.length} failed:`,
urls[i],
err
);
}
}
throw new Error(
`所有媒体CDN均下载失败:${
lastError
?.message ||
lastError ||
'未知错误'
}`
);
}
function requestDirectFile(
url,
filename,
onProgress
) {
return new Promise(
(
resolve,
reject
) => {
try {
GM_download({
url,
name:
filename,
saveAs:
false,
headers: {
Referer:
'https://www.bilibili.com/',
},
onprogress:
event => {
if (
typeof onProgress ===
'function'
) {
onProgress(
event.loaded ||
0,
event.total ||
0
);
}
},
onload:
() =>
resolve(),
onerror:
error =>
reject(
new Error(
`直链下载失败:${
error
?.error ||
error
?.message ||
'未知错误'
}`
)
),
ontimeout:
() =>
reject(
new Error(
'直链下载超时'
)
),
});
} catch (err) {
reject(
err
);
}
}
);
}
function directEntryUrls(
entry
) {
const urls =
[];
const add =
value => {
if (
!value
) {
return;
}
if (
Array.isArray(
value
)
) {
value.forEach(
add
);
return;
}
const url =
String(
value
).replace(
/^http:/,
'https:'
);
if (
url &&
!urls.includes(
url
)
) {
urls.push(
url
);
}
};
add(
entry
?.url
);
add(
entry
?.backup_url
);
add(
entry
?.backupUrl
);
return urls;
}
function directFileExtension(
data,
entry
) {
const format =
String(
data
?.format ||
''
).toLowerCase();
const url =
String(
entry
?.url ||
''
).toLowerCase();
if (
format.includes(
'flv'
) ||
/\.flv(?:\?|$)/.test(
url
)
) {
return 'flv';
}
return 'mp4';
}
async function downloadDirectEntry(
entry,
filename,
onProgress
) {
const urls =
directEntryUrls(
entry
);
if (
!urls.length
) {
throw new Error(
'直链地址为空'
);
}
let lastError =
null;
for (
let i = 0;
i <
urls.length;
i += 1
) {
try {
await requestDirectFile(
urls[i],
filename,
onProgress
);
return;
} catch (err) {
lastError =
err;
console.warn(
`[BiliDL] direct CDN ${i + 1}/${urls.length} failed:`,
urls[i],
err
);
}
}
throw new Error(
`所有直链均下载失败:${
lastError
?.message ||
lastError ||
'未知错误'
}`
);
}
function saveBlob(
blob,
filename
) {
const url =
URL.createObjectURL(
blob
);
const a =
document.createElement(
'a'
);
a.href =
url;
a.download =
filename;
a.rel =
'noopener';
document.body.appendChild(
a
);
a.click();
a.remove();
setTimeout(
() =>
URL.revokeObjectURL(
url
),
60000
);
}
/*
* =========================================================
* FFmpeg
* =========================================================
*/
async function muxAndSave(
videoBuf,
audioBuf,
outputName
) {
if (
typeof FFmpeg ===
'undefined' ||
!FFmpeg.createFFmpeg
) {
throw new Error(
'FFmpeg.wasm 没有加载成功'
);
}
setStatus(
'正在加载 FFmpeg…'
);
const ffmpeg =
FFmpeg.createFFmpeg({
log:
false,
mainName:
'main',
corePath:
CORE_PATH,
});
await ffmpeg.load();
const token =
`${Date.now()}_${Math.random()
.toString(36)
.slice(2)}`;
const vin =
`video_${token}.mp4`;
const ain =
`audio_${token}.m4a`;
const out =
`output_${token}.mp4`;
try {
ffmpeg.FS(
'writeFile',
vin,
new Uint8Array(
videoBuf
)
);
if (
audioBuf
) {
ffmpeg.FS(
'writeFile',
ain,
new Uint8Array(
audioBuf
)
);
}
setStatus(
'正在无损合流音视频…'
);
setProgress(
96,
'合流中…'
);
try {
if (
audioBuf
) {
await ffmpeg.run(
'-nostdin',
'-y',
'-i',
vin,
'-i',
ain,
'-map',
'0:v:0',
'-map',
'1:a:0',
'-c',
'copy',
'-movflags',
'+faststart',
'-shortest',
out
);
} else {
await ffmpeg.run(
'-nostdin',
'-y',
'-i',
vin,
'-c',
'copy',
'-movflags',
'+faststart',
out
);
}
} catch (err) {
const message =
String(
err
?.message ??
err
);
if (
!/\bexit\(0\)\b/i.test(
message
)
) {
throw err;
}
}
const bytes =
ffmpeg.FS(
'readFile',
out
);
if (
!(
bytes instanceof
Uint8Array
) ||
!bytes.byteLength
) {
throw new Error(
'FFmpeg输出MP4为空'
);
}
saveBlob(
new Blob(
[
bytes
.slice()
.buffer,
],
{
type:
'video/mp4',
}
),
`${outputName}.mp4`
);
} finally {
for (
const path
of [
vin,
ain,
out,
]
) {
try {
ffmpeg.FS(
'unlink',
path
);
} catch {}
}
try {
ffmpeg.exit();
} catch {}
}
}
/*
* =========================================================
* 自定义下拉框
* =========================================================
*/
function dropdownArrowHtml() {
return `
`;
}
function getDropdownRoot(
name
) {
return $(
`#${PANEL_ID} .tm-dropdown[data-name="${name}"]`
);
}
function getDropdownValue(
name
) {
return (
getDropdownRoot(
name
)
?.dataset
?.value ||
''
);
}
function closeDropdowns(
except = null
) {
$$(
`#${PANEL_ID} .tm-dropdown.open`
).forEach(
root => {
if (
root !==
except
) {
root.classList.remove(
'open'
);
}
}
);
}
function setDropdownValue(
root,
value,
label = null
) {
if (
!root
) {
return;
}
const stringValue =
String(
value ??
''
);
root.dataset.value =
stringValue;
const options =
$$(
'.tm-select-option',
root
);
let selectedLabel =
label;
options.forEach(
option => {
const selected =
option.dataset
.value ===
stringValue;
option.classList.toggle(
'selected',
selected
);
option.setAttribute(
'aria-selected',
selected
? 'true'
: 'false'
);
if (
selected &&
selectedLabel ==
null
) {
selectedLabel =
option
.querySelector(
'.tm-option-text'
)
?.textContent
?.trim() ||
option
.textContent
.trim();
}
}
);
const labelEl =
$(
'.tm-select-label',
root
);
if (
labelEl
) {
labelEl.textContent =
selectedLabel ||
'请选择';
}
}
function setDropdownOptions(
name,
options,
preferredValue = ''
) {
const root =
getDropdownRoot(
name
);
if (
!root
) {
return;
}
const menu =
$(
'.tm-select-menu',
root
);
if (
!menu
) {
return;
}
const normalized =
(
options ||
[]
).map(
option => ({
value:
String(
option.value
),
label:
String(
option.label
),
})
);
menu.innerHTML =
normalized
.map(
option => `
`
)
.join(
''
);
if (
!normalized.length
) {
root.dataset.value =
'';
const labelEl =
$(
'.tm-select-label',
root
);
if (
labelEl
) {
labelEl.textContent =
'无可用选项';
}
return;
}
const preferred =
String(
preferredValue ??
''
);
const selected =
normalized.find(
option =>
option.value ===
preferred
) ||
normalized[0];
setDropdownValue(
root,
selected.value,
selected.label
);
}
function initStaticDropdowns() {
setDropdownOptions(
'codec',
[
{
value:
'auto',
label:
'自动(优先 H.265)',
},
{
value:
'avc',
label:
'AVC / H.264',
},
{
value:
'hevc',
label:
'HEVC / H.265',
},
{
value:
'av1',
label:
'AV1',
},
],
'auto'
);
setDropdownOptions(
'subtitle',
[
{
value:
'xml',
label:
'XML 原始弹幕',
},
{
value:
'ass',
label:
'ASS 播放器字幕',
},
],
'xml'
);
}
/*
* =========================================================
* UI 数据
* =========================================================
*/
function getSelectedQn() {
return Number(
getDropdownValue(
'quality'
) ||
0
);
}
function getSelectedCodec() {
return (
getDropdownValue(
'codec'
) ||
'auto'
);
}
function getSelectedSubtitleFormat() {
return (
getDropdownValue(
'subtitle'
) ||
'xml'
);
}
function getVisibleItems() {
const keyword =
state.search
.trim()
.toLowerCase();
if (
!keyword
) {
return state.items;
}
return state.items.filter(
item => {
const text = [
item.videoTitle,
item.part,
`P${item.page}`,
item.page,
item.collectionIndex,
]
.join(
' '
)
.toLowerCase();
return text.includes(
keyword
);
}
);
}
function selectedItems() {
return state.items.filter(
item =>
state.selectedKeys.has(
item.key
)
);
}
function itemLabel(
item
) {
if (
item.pages >
1
) {
return `${item.videoTitle} / P${item.page} ${item.part}`;
}
return item.videoTitle;
}
function itemIndexText(
item,
visibleIndex
) {
if (
state.hasCollection
) {
if (
item.pages >
1
) {
return `${pad(
item.collectionIndex,
2
)}-${pad(
item.page,
2
)}`;
}
return pad(
item.collectionIndex,
2
);
}
if (
item.pages >
1
) {
return `P${pad(
item.page,
2
)}`;
}
return pad(
visibleIndex +
1,
2
);
}
function buildBaseName(
item,
qualityLabel,
stream
) {
const fps =
frameRateName(
stream
);
const codec =
codecName(
stream
?.codecid
);
const suffix = [
qualityLabel,
fps,
codec,
]
.filter(
Boolean
)
.join(
' - '
);
if (
state.hasCollection &&
item.isSeason
) {
const part =
item.pages >
1
? ` - P${pad(
item.page
)} ${item.part}`
: '';
return sanitize(
`[${pad(
item.collectionIndex
)}] ${item.videoTitle}${part} - ${suffix}`
);
}
const part =
item.pages >
1
? ` - P${pad(
item.page
)} ${item.part}`
: '';
return sanitize(
`${item.videoTitle}${part} - ${suffix}`
);
}
function buildPlainBaseName(
item
) {
if (
state.hasCollection &&
item.isSeason
) {
const part =
item.pages >
1
? ` - P${pad(
item.page
)} ${item.part}`
: '';
return sanitize(
`[${pad(
item.collectionIndex
)}] ${item.videoTitle}${part}`
);
}
const part =
item.pages >
1
? ` - P${pad(
item.page
)} ${item.part}`
: '';
return sanitize(
`${item.videoTitle}${part}`
);
}
function audioCodecName(
stream
) {
const codec =
String(
stream
?.codecs ||
stream
?.codec ||
''
).toLowerCase();
if (
codec.includes(
'flac'
)
) {
return 'FLAC';
}
if (
codec.includes(
'ec-3'
) ||
codec.includes(
'eac3'
)
) {
return 'E-AC-3';
}
if (
codec.includes(
'ac-3'
) ||
codec.includes(
'ac3'
)
) {
return 'AC-3';
}
if (
codec.includes(
'opus'
)
) {
return 'Opus';
}
if (
codec.includes(
'mp4a'
) ||
codec.includes(
'aac'
)
) {
return 'AAC';
}
return codec
? codec.toUpperCase()
: '音频';
}
function audioFileExtension(
stream
) {
const mime =
String(
stream
?.mimeType ||
stream
?.mime_type ||
''
).toLowerCase();
if (
mime.includes(
'webm'
)
) {
return 'webm';
}
if (
mime.includes(
'ogg'
)
) {
return 'ogg';
}
return 'm4a';
}
function buildAudioBaseName(
item,
stream
) {
const codec =
audioCodecName(
stream
);
const bandwidth =
Number(
stream
?.bandwidth ||
0
);
const bitrate =
bandwidth >
0
? `${Math.round(
bandwidth /
1000
)}kbps`
: '';
const suffix = [
'音频',
bitrate,
codec,
]
.filter(
Boolean
)
.join(
' - '
);
if (
state.hasCollection &&
item.isSeason
) {
const part =
item.pages >
1
? ` - P${pad(
item.page
)} ${item.part}`
: '';
return sanitize(
`[${pad(
item.collectionIndex
)}] ${item.videoTitle}${part} - ${suffix}`
);
}
const part =
item.pages >
1
? ` - P${pad(
item.page
)} ${item.part}`
: '';
return sanitize(
`${item.videoTitle}${part} - ${suffix}`
);
}
async function probeFormats(
item
) {
if (
!item
) {
state.formats =
[];
renderFormats();
return;
}
try {
setStatus(
'正在读取可用清晰度…'
);
const data =
await fetchPlayData(
item,
127
);
state.formats =
normalizeFormats(
data
);
renderFormats();
} catch (err) {
console.warn(
'[BiliDL] quality probe failed:',
err
);
state.formats =
[];
renderFormats();
}
}
function renderFormats() {
const previous =
Number(
getDropdownValue(
'quality'
) ||
0
);
const formats =
QUALITY_FALLBACK;
const preferred =
formats.some(
item =>
item.qn ===
previous
)
? previous
: 80;
setDropdownOptions(
'quality',
formats.map(
item => ({
value:
item.qn,
label:
item.label,
})
),
preferred
);
}
/*
* =========================================================
* 列表
* =========================================================
*/
function updateSelectAllState() {
const checkbox =
$(
`#${PANEL_ID} .tm-select-all`
);
if (
!checkbox
) {
return;
}
const visibleItems =
getVisibleItems();
if (
!visibleItems.length
) {
checkbox.checked =
false;
checkbox.indeterminate =
false;
checkbox.disabled =
true;
return;
}
const selectedCount =
visibleItems.filter(
item =>
state.selectedKeys.has(
item.key
)
).length;
checkbox.checked =
selectedCount ===
visibleItems.length;
checkbox.indeterminate =
selectedCount >
0 &&
selectedCount <
visibleItems.length;
checkbox.disabled =
state.busy;
}
function updateSelectedCount() {
const el =
$(
`#${PANEL_ID} .tm-selected-count`
);
if (
el
) {
el.textContent =
`已选 ${state.selectedKeys.size}`;
}
updateSelectAllState();
}
function toggleRangeSelection(
startKey,
endKey,
select
) {
const visibleItems =
getVisibleItems();
const start =
visibleItems.findIndex(
item =>
item.key === startKey
);
const end =
visibleItems.findIndex(
item =>
item.key === endKey
);
if (
start < 0 ||
end < 0
) {
return;
}
const lo =
Math.min(
start,
end
);
const hi =
Math.max(
start,
end
);
for (
let i = lo;
i <= hi;
i += 1
) {
const key =
visibleItems[i].key;
if (
select
) {
state.selectedKeys.add(
key
);
} else {
state.selectedKeys.delete(
key
);
}
}
}
function renderItems() {
const list =
$(
`#${PANEL_ID} .tm-items`
);
const hint =
$(
`#${PANEL_ID} .tm-items-hint`
);
if (
!list
) {
return;
}
const items =
getVisibleItems();
const currentItem =
getCurrentItem();
const currentKey =
currentItem
?.key ||
'';
list.innerHTML =
'';
if (
!items.length
) {
list.innerHTML =
'没有匹配的视频
';
if (
hint
) {
hint.textContent =
'0 项';
}
updateSelectedCount();
return;
}
items.forEach(
(
item,
index
) => {
const row =
document.createElement(
'div'
);
row.className =
'tm-item';
if (
item.key ===
currentKey
) {
row.dataset.current =
'1';
}
row.innerHTML = `
${escapeHtml(
itemIndexText(
item,
index
)
)}
${escapeHtml(
itemLabel(
item
)
)}
`;
list.appendChild(
row
);
}
);
if (
hint
) {
hint.textContent =
state.search
? `${items.length}/${state.items.length} 项`
: `${state.items.length} 项`;
}
updateSelectedCount();
scrollCurrentItemToTop();
}
function findItem(
key
) {
return (
state.items.find(
item =>
item.key ===
key
) ||
null
);
}
/*
* =========================================================
* 直链优先
* =========================================================
*/
async function tryDirectMuxedDownload(
item,
index,
total,
requestedQn,
codecPref,
qualityLabel
) {
/*
* durl 无法精确控制 HEVC / AV1。
* 用户明确选这两种编码时直接走 DASH。
*/
if (
codecPref ===
'hevc' ||
codecPref ===
'av1'
) {
return false;
}
let data;
try {
data =
await fetchDirectPlayData(
item,
requestedQn
);
} catch {
return false;
}
/*
* B站可能降级清晰度。
* 只要返回的画质不高于目标档位即可接受,
* 这样批量下载时可以自动适配每个视频的最高可用画质。
*/
const actualQn =
Number(
data
?.quality ||
0
);
if (
!actualQn ||
actualQn >
Number(
requestedQn
)
) {
return false;
}
const durl =
Array.isArray(
data
?.durl
)
? data.durl
: [];
/*
* 多段 durl 最后仍然需要合并,
* 这种情况继续交给 DASH。
*/
if (
durl.length !==
1
) {
return false;
}
const directCodecId =
Number(
data
?.video_codecid ||
0
);
if (
codecPref ===
'avc' &&
directCodecId &&
directCodecId !==
7
) {
return false;
}
const entry =
durl[0];
const extension =
directFileExtension(
data,
entry
);
const directQualityLabel =
QUALITY_FALLBACK.find(
q =>
q.qn ===
actualQn
)?.label ||
qualityLabel;
const pseudoStream = {
codecid:
directCodecId ||
7,
};
const outputName =
buildBaseName(
item,
directQualityLabel,
pseudoStream
);
setStatus(
`[${index}/${total}] 直链下载 ${directQualityLabel} · 无需合流`
);
await downloadDirectEntry(
entry,
`${outputName}.${extension}`,
(
loaded,
totalBytes
) => {
const taskProgress =
totalBytes >
0
? loaded /
totalBytes
: 0;
const global =
(
(
index -
1
) +
Math.min(
taskProgress,
0.995
)
) /
total *
100;
setProgress(
global,
`[${index}/${total}] ${formatBytes(
loaded
)}${
totalBytes >
0
? ` / ${formatBytes(
totalBytes
)}`
: ''
}`
);
}
);
setProgress(
index /
total *
100,
`[${index}/${total}] 完成`
);
return true;
}
/*
* =========================================================
* 视频
* =========================================================
*/
async function downloadOne(
item,
index,
total,
options = {}
) {
const requestedQn =
Number(
options
.requestedQn ??
getSelectedQn()
);
const codecPref =
options
.codecPref ||
getSelectedCodec();
const qualityLabel =
options
.qualityLabel ||
getDropdownRoot(
'quality'
)
?.querySelector(
'.tm-select-label'
)
?.textContent
?.trim() ||
String(
requestedQn
);
if (
!requestedQn
) {
throw new Error(
'请选择清晰度'
);
}
setStatus(
`[${index}/${total}] 正在解析:${itemLabel(
item
)}`
);
setProgress(
(
index -
1
) /
total *
100,
`[${index}/${total}] 解析`
);
const probeData =
options.playData ||
await fetchPlayData(
item,
127
);
const targetQn =
resolveTargetQn(
probeData,
requestedQn
);
if (
!targetQn
) {
throw new Error(
'当前视频没有可用的清晰度。'
);
}
const actualQualityLabel =
QUALITY_FALLBACK.find(
q =>
q.qn ===
targetQn
)?.label ||
qualityLabel;
/*
* 所有画质都先试一次完整直链。
*
* 能得到单文件且画质不高于目标档位,
* 就不加载 FFmpeg。
*/
const directDone =
await tryDirectMuxedDownload(
item,
index,
total,
targetQn,
codecPref,
actualQualityLabel
);
if (
directDone
) {
return;
}
const data =
probeData;
if (
!data
?.dash
) {
throw new Error(
'当前视频没有可用的 DASH 视频流。'
);
}
const video =
pickExactVideo(
data,
requestedQn,
codecPref
);
const audio =
pickAudio(
data
);
const fps =
frameRateName(
video
);
const codec =
codecName(
video
.codecid
);
const outputName =
buildBaseName(
item,
actualQualityLabel,
video
);
setStatus(
`[${index}/${total}] DASH 下载 ${actualQualityLabel}${
fps
? ` · ${fps}`
: ''
} · ${codec}`
);
let videoLoaded =
0;
let audioLoaded =
0;
let videoTotal =
0;
let audioTotal =
0;
const updateProgress =
() => {
const loaded =
videoLoaded +
audioLoaded;
const all =
videoTotal +
audioTotal;
const taskProgress =
all >
0
? loaded /
all
: 0;
const global =
(
(
index -
1
) +
Math.min(
taskProgress,
0.94
)
) /
total *
100;
setProgress(
global,
`[${index}/${total}] ${formatBytes(
loaded
)}${
all >
0
? ` / ${formatBytes(
all
)}`
: ''
}`
);
};
const videoPromise =
requestStreamBuffer(
video,
(
loaded,
totalBytes
) => {
videoLoaded =
loaded;
videoTotal =
totalBytes ||
videoTotal;
updateProgress();
}
);
const audioPromise =
audio
? requestStreamBuffer(
audio,
(
loaded,
totalBytes
) => {
audioLoaded =
loaded;
audioTotal =
totalBytes ||
audioTotal;
updateProgress();
}
)
: Promise.resolve(
null
);
const [
videoBuf,
audioBuf,
] =
await Promise.all(
[
videoPromise,
audioPromise,
]
);
setStatus(
`[${index}/${total}] 下载完成,正在无损合流…`
);
await muxAndSave(
videoBuf,
audioBuf,
outputName
);
setProgress(
index /
total *
100,
`[${index}/${total}] 完成`
);
}
/*
* =========================================================
* 音频
* =========================================================
*/
async function downloadAudioOne(
item,
index,
total
) {
setStatus(
`[${index}/${total}] 正在解析音频:${itemLabel(
item
)}`
);
setProgress(
(
index -
1
) /
total *
100,
`[${index}/${total}] 解析音频`
);
const data =
await fetchPlayData(
item,
80
);
if (
!data
?.dash
) {
throw new Error(
'当前视频没有DASH音频流。'
);
}
const audio =
pickAudio(
data
);
if (
!audio
) {
throw new Error(
'当前视频没有可下载的音频流。'
);
}
const codec =
audioCodecName(
audio
);
const bandwidth =
Number(
audio
.bandwidth ||
0
);
const bitrate =
bandwidth >
0
? `${Math.round(
bandwidth /
1000
)}kbps`
: '';
setStatus(
`[${index}/${total}] 下载音频${
bitrate
? ` · ${bitrate}`
: ''
} · ${codec}`
);
const audioBuf =
await requestStreamBuffer(
audio,
(
loaded,
totalBytes
) => {
const progress =
totalBytes >
0
? loaded /
totalBytes
: 0;
const global =
(
(
index -
1
) +
Math.min(
progress,
0.99
)
) /
total *
100;
setProgress(
global,
`[${index}/${total}] ${formatBytes(
loaded
)}${
totalBytes >
0
? ` / ${formatBytes(
totalBytes
)}`
: ''
}`
);
}
);
const extension =
audioFileExtension(
audio
);
const mime =
audio
.mimeType ||
audio
.mime_type ||
(
extension ===
'webm'
? 'audio/webm'
: 'audio/mp4'
);
const outputName =
buildAudioBaseName(
item,
audio
);
saveBlob(
new Blob(
[
audioBuf,
],
{
type:
mime,
}
),
`${outputName}.${extension}`
);
setProgress(
index /
total *
100,
`[${index}/${total}] 音频完成`
);
}
/*
* =========================================================
* 弹幕 XML / ASS
* =========================================================
*/
async function fetchDanmakuXml(
item
) {
if (
!item
?.cid
) {
throw new Error(
'当前条目没有 CID,无法读取弹幕。'
);
}
const xml =
await requestTextUrl(
`https://comment.bilibili.com/${encodeURIComponent(
String(
item.cid
)
)}.xml`
);
if (
!xml
) {
throw new Error(
'没有读取到弹幕 XML。'
);
}
return xml;
}
function assTime(
seconds
) {
const value =
Math.max(
0,
Number(
seconds
) ||
0
);
const h =
Math.floor(
value /
3600
);
const m =
Math.floor(
(
value %
3600
) /
60
);
const s =
Math.floor(
value %
60
);
const cs =
Math.floor(
(
value -
Math.floor(
value
)
) *
100
);
return `${h}:${pad(
m
)}:${pad(
s
)}.${pad(
cs
)}`;
}
function assColor(
decimalColor
) {
const value =
Math.max(
0,
Math.min(
0xffffff,
Number(
decimalColor
) ||
0xffffff
)
);
const r =
(
value >>
16
) &
0xff;
const g =
(
value >>
8
) &
0xff;
const b =
value &
0xff;
return `&H00${b
.toString(16)
.padStart(
2,
'0'
)}${g
.toString(16)
.padStart(
2,
'0'
)}${r
.toString(16)
.padStart(
2,
'0'
)}&`;
}
function assEscapeText(
text
) {
return String(
text ||
''
)
.replace(
/\\/g,
'\\\\'
)
.replace(
/\{/g,
'\\{'
)
.replace(
/\}/g,
'\\}'
)
.replace(
/\r?\n/g,
'\\N'
);
}
function estimateDanmakuWidth(
text,
fontSize
) {
let units =
0;
for (
const char
of String(
text ||
''
)
) {
units +=
/[\u0000-\u00ff]/.test(
char
)
? 0.56
: 1;
}
return Math.max(
fontSize *
2,
units *
fontSize *
0.98
);
}
function convertDanmakuXmlToAss(
xml,
title
) {
const doc =
new DOMParser()
.parseFromString(
xml,
'application/xml'
);
if (
doc.querySelector(
'parsererror'
)
) {
throw new Error(
'弹幕 XML 解析失败。'
);
}
const entries = [
...doc.querySelectorAll(
'd[p]'
),
]
.map(
node => {
const p =
String(
node.getAttribute(
'p'
) ||
''
).split(
','
);
return {
time:
Number(
p[0]
) ||
0,
mode:
Number(
p[1]
) ||
1,
size:
Number(
p[2]
) ||
25,
color:
Number(
p[3]
) ||
0xffffff,
text:
node.textContent ||
'',
};
}
)
.filter(
item =>
item.text &&
item.mode !==
8
)
.sort(
(
a,
b
) =>
a.time -
b.time
);
const width =
1920;
const height =
1080;
const scrollDuration =
8;
const fixedDuration =
4;
const laneHeight =
54;
const scrollFree =
Array(
17
).fill(
0
);
const topFree =
Array(
7
).fill(
0
);
const bottomFree =
Array(
7
).fill(
0
);
const pickLane =
(
lanes,
start
) => {
const free =
lanes.findIndex(
time =>
time <=
start
);
if (
free >=
0
) {
return free;
}
let best =
0;
for (
let i = 1;
i <
lanes.length;
i += 1
) {
if (
lanes[i] <
lanes[best]
) {
best =
i;
}
}
return best;
};
const events =
[];
for (
const entry
of entries
) {
const start =
entry.time;
let end =
start +
scrollDuration;
const fontSize =
Math.max(
24,
Math.min(
64,
Math.round(
entry.size *
1.75
)
)
);
let tag =
'';
if (
entry.mode ===
4
) {
const lane =
pickLane(
bottomFree,
start
);
bottomFree[
lane
] =
start +
fixedDuration;
end =
start +
fixedDuration;
tag =
`\\an2\\pos(${width / 2},${height - 48 - lane * laneHeight})`;
} else if (
entry.mode ===
5
) {
const lane =
pickLane(
topFree,
start
);
topFree[
lane
] =
start +
fixedDuration;
end =
start +
fixedDuration;
tag =
`\\an8\\pos(${width / 2},${48 + lane * laneHeight})`;
} else if (
entry.mode ===
7
) {
end =
start +
fixedDuration;
tag =
`\\an5\\pos(${width / 2},${height / 2})`;
} else {
const lane =
pickLane(
scrollFree,
start
);
scrollFree[
lane
] =
start +
scrollDuration *
0.82;
const y =
48 +
lane *
laneHeight;
const textWidth =
Math.ceil(
estimateDanmakuWidth(
entry.text,
fontSize
)
);
if (
entry.mode ===
6
) {
tag =
`\\move(${-textWidth - 30},${y},${width + 30},${y})`;
} else {
tag =
`\\move(${width + 30},${y},${-textWidth - 30},${y})`;
}
}
events.push(
`Dialogue: 0,${assTime(
start
)},${assTime(
end
)},Danmaku,,0,0,0,,{${tag}\\fs${fontSize}\\c${assColor(
entry.color
)}}${assEscapeText(
entry.text
)}`
);
}
return [
'[Script Info]',
`Title: ${String(
title ||
'Bilibili Danmaku'
).replace(
/[\r\n]+/g,
' '
)}`,
'ScriptType: v4.00+',
'WrapStyle: 2',
'ScaledBorderAndShadow: yes',
`PlayResX: ${width}`,
`PlayResY: ${height}`,
'',
'[V4+ Styles]',
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding',
'Style: Danmaku,Microsoft YaHei,48,&H00FFFFFF,&H00FFFFFF,&H00000000,&H64000000,-1,0,0,0,100,100,0,0,1,2,0,7,20,20,20,1',
'',
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...events,
'',
].join(
'\n'
);
}
async function downloadSubtitleOne(
item,
index,
total,
format
) {
setStatus(
`[${index}/${total}] 正在读取 ${format.toUpperCase()} 弹幕:${itemLabel(
item
)}`
);
setProgress(
(
index -
1
) /
total *
100,
`[${index}/${total}] 弹幕`
);
const xml =
await fetchDanmakuXml(
item
);
const baseName =
buildPlainBaseName(
item
);
if (
format ===
'ass'
) {
const ass =
convertDanmakuXmlToAss(
xml,
itemLabel(
item
)
);
saveBlob(
new Blob(
[
'\ufeff',
ass,
],
{
type:
'text/x-ssa;charset=utf-8',
}
),
`${baseName}.ass`
);
} else {
saveBlob(
new Blob(
[
xml,
],
{
type:
'application/xml;charset=utf-8',
}
),
`${baseName}.xml`
);
}
setProgress(
index /
total *
100,
`[${index}/${total}] 弹幕完成`
);
}
/*
* =========================================================
* Logo 极速下载
* =========================================================
*/
function quickQualityLabel(
qn,
data
) {
return (
normalizeFormats(
data
).find(
item =>
item.qn ===
Number(
qn
)
)?.label ||
QUALITY_FALLBACK.find(
item =>
item.qn ===
Number(
qn
)
)?.label ||
`画质 ${qn}`
);
}
function pickQuickQn(
data
) {
/*
* 上限为 1080P60。
*/
const maxQn =
116;
const available = [
...new Set(
(
data
?.dash
?.video ||
[]
)
.map(
stream =>
Number(
stream
?.id ||
0
)
)
.filter(
qn =>
qn >
0 &&
qn <=
maxQn
)
),
].sort(
(
a,
b
) =>
b -
a
);
if (
available.length
) {
return available[0];
}
const actual =
Number(
data
?.quality ||
0
);
if (
actual >
0 &&
actual <=
maxQn
) {
return actual;
}
throw new Error(
'没有找到不高于 1080P60 的可下载视频流。'
);
}
let quickToastTimer =
null;
function showQuickToast(
text,
type = '',
duration = 0
) {
const panel =
document.getElementById(
PANEL_ID
);
if (
!panel
) {
return;
}
let toast =
document.getElementById(
`${PANEL_ID}-quick-toast`
);
if (
!toast
) {
toast =
document.createElement(
'div'
);
toast.id =
`${PANEL_ID}-quick-toast`;
document.body.appendChild(
toast
);
}
const rect =
panel.getBoundingClientRect();
toast.style.left =
`${Math.max(
8,
Math.min(
rect.left,
window.innerWidth -
338
)
)}px`;
toast.style.top =
`${Math.min(
window.innerHeight -
60,
rect.bottom +
8
)}px`;
toast.textContent =
text;
toast.dataset.type =
type;
toast.classList.add(
'show'
);
if (
quickToastTimer
) {
clearTimeout(
quickToastTimer
);
quickToastTimer =
null;
}
if (
duration >
0
) {
quickToastTimer =
setTimeout(
() => {
toast.classList.remove(
'show'
);
},
duration
);
}
}
async function startQuickCurrent() {
if (
state.busy
) {
showQuickToast(
'已有下载任务正在进行,请勿重复点击。',
'warn',
2200
);
return;
}
const item =
getCurrentItem() ||
state.items[0];
if (
!item
) {
showQuickToast(
'视频仍在解析,请稍后再点 Logo。',
'error',
2500
);
return;
}
setBusy(
true
);
const panel =
document.getElementById(
PANEL_ID
);
panel
?.classList
.add(
'quick-busy'
);
showQuickToast(
'已开始解析下载,请勿重复点击…',
'loading'
);
try {
const data =
await fetchPlayData(
item,
116
);
if (
!data
?.dash
) {
throw new Error(
'当前视频没有 DASH 视频流。'
);
}
const requestedQn =
pickQuickQn(
data
);
const qualityLabel =
quickQualityLabel(
requestedQn,
data
);
showQuickToast(
`正在下载 ${qualityLabel},请勿重复点击。`,
'loading'
);
await downloadOne(
item,
1,
1,
{
requestedQn,
codecPref:
'auto',
qualityLabel,
playData:
data,
}
);
setStatus(
`下载完成:${qualityLabel} · ${itemLabel(
item
)}`,
'ok'
);
setProgress(
100,
'完成'
);
showQuickToast(
`${qualityLabel} 下载完成。`,
'ok',
2800
);
} catch (err) {
console.error(
'[BiliDL] quick download failed:',
err
);
setStatus(
`极速下载失败:${
err
?.message ||
err
}`,
'error'
);
showQuickToast(
`下载失败:${
err
?.message ||
err
}`,
'error',
3500
);
} finally {
panel
?.classList
.remove(
'quick-busy'
);
setBusy(
false
);
}
}
/*
* =========================================================
* 下载入口
* =========================================================
*/
async function startSingle(
item
) {
if (
state.busy ||
!item
) {
return;
}
setBusy(
true
);
try {
await downloadOne(
item,
1,
1
);
setStatus(
`下载完成:${itemLabel(
item
)}`,
'ok'
);
setProgress(
100,
'完成'
);
} catch (err) {
console.error(
'[BiliDL] single download failed:',
err
);
setStatus(
`失败:${
err
?.message ||
err
}`,
'error'
);
} finally {
setBusy(
false
);
}
}
async function startSelected() {
if (
state.busy
) {
return;
}
const items =
selectedItems();
if (
!items.length
) {
throw new Error(
'请先勾选要批量下载的视频。'
);
}
setBusy(
true
);
let completed =
0;
try {
for (
let i = 0;
i <
items.length;
i += 1
) {
await downloadOne(
items[i],
i + 1,
items.length
);
completed +=
1;
if (
i <
items.length -
1
) {
await sleep(
250
);
}
}
setStatus(
`全部完成:${completed}/${items.length}`,
'ok'
);
setProgress(
100,
`完成 ${completed}/${items.length}`
);
} catch (err) {
console.error(
'[BiliDL]',
err
);
setStatus(
`失败:${
err
?.message ||
err
}`,
'error'
);
} finally {
setBusy(
false
);
}
}
async function startSelectedAudio() {
if (
state.busy
) {
return;
}
const items =
selectedItems();
if (
!items.length
) {
throw new Error(
'请先勾选要下载音频的视频。'
);
}
setBusy(
true
);
let completed =
0;
try {
for (
let i = 0;
i <
items.length;
i += 1
) {
await downloadAudioOne(
items[i],
i + 1,
items.length
);
completed +=
1;
if (
i <
items.length -
1
) {
await sleep(
250
);
}
}
setStatus(
`音频全部完成:${completed}/${items.length}`,
'ok'
);
setProgress(
100,
`音频完成 ${completed}/${items.length}`
);
} catch (err) {
console.error(
'[BiliDL] audio download failed:',
err
);
setStatus(
`音频下载失败:${
err
?.message ||
err
}`,
'error'
);
} finally {
setBusy(
false
);
}
}
async function startSelectedSubtitles() {
if (
state.busy
) {
return;
}
const items =
selectedItems();
if (
!items.length
) {
throw new Error(
'请先勾选要下载弹幕字幕的视频。'
);
}
const format =
getSelectedSubtitleFormat();
setBusy(
true
);
let completed =
0;
try {
for (
let i = 0;
i <
items.length;
i += 1
) {
await downloadSubtitleOne(
items[i],
i + 1,
items.length,
format
);
completed +=
1;
if (
i <
items.length -
1
) {
await sleep(
120
);
}
}
setStatus(
`${format.toUpperCase()} 弹幕全部完成:${completed}/${items.length}`,
'ok'
);
setProgress(
100,
`弹幕 ${completed}/${items.length}`
);
} catch (err) {
console.error(
'[BiliDL] subtitle download failed:',
err
);
setStatus(
`弹幕下载失败:${
err
?.message ||
err
}`,
'error'
);
} finally {
setBusy(
false
);
}
}
/*
* =========================================================
* 拖拽 / 最小化
* =========================================================
*/
function pinPanelToCurrentScreenPosition(
panel
) {
const rect =
panel.getBoundingClientRect();
panel.style.left =
`${rect.left}px`;
panel.style.top =
`${rect.top}px`;
panel.style.right =
'auto';
}
let authorPopupShowTimer = null;
let authorPopupHideTimer = null;
function createAuthorPopup() {
let popup = document.getElementById(`${PANEL_ID}-author-popup`);
if (popup) {
return popup;
}
popup = document.createElement('div');
popup.id = `${PANEL_ID}-author-popup`;
popup.innerHTML = `
`;
document.body.appendChild(popup);
if (typeof QRCode !== 'undefined') {
const canvas = document.getElementById(`${PANEL_ID}-qr-canvas`);
QRCode.toCanvas(
canvas,
'http://weixin.qq.com/r/mp/aRANFT-EwGK-rXt290V7',
{ width: 120, margin: 1 },
error => {
if (error) {
console.error(error);
}
}
);
}
popup.addEventListener('mouseenter', () => {
clearTimeout(authorPopupHideTimer);
});
popup.addEventListener('mouseleave', () => {
authorPopupHideTimer = setTimeout(hideAuthorPopup, 100);
});
return popup;
}
function showAuthorPopup() {
const popup = createAuthorPopup();
const header = $(`#${PANEL_ID} .tm-head`);
if (!header) {
return;
}
const rect = header.getBoundingClientRect();
let top = rect.bottom + 10;
let left = rect.left + rect.width / 2 - 200;
if (left < 10) {
left = 10;
}
if (left + 400 > window.innerWidth - 10) {
left = window.innerWidth - 410;
}
if (top + 300 > window.innerHeight - 10) {
top = rect.top - 310;
}
popup.style.top = `${top}px`;
popup.style.left = `${left}px`;
popup.classList.add('show');
}
function hideAuthorPopup() {
const popup = document.getElementById(`${PANEL_ID}-author-popup`);
if (popup) {
popup.classList.remove('show');
}
}
function bindDragAndMin(panel) {
const header = $(`#${PANEL_ID} .tm-head`);
const minBtn = $(`#${PANEL_ID} .tm-min-btn`);
let dragging = false;
let dragMoved = false;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
// 监听标题栏鼠标移入
header.addEventListener('mouseenter', () => {
clearTimeout(authorPopupHideTimer);
authorPopupShowTimer = setTimeout(() => {
// 核心判断:如果当前没有处于拖动状态(通过 class 判断),则显示浮窗
if (!header.classList.contains('dragging')) {
showAuthorPopup();
}
}, 200); // 200ms 延迟
});
// 监听标题栏鼠标移出
header.addEventListener('mouseleave', () => {
clearTimeout(authorPopupShowTimer);
authorPopupHideTimer = setTimeout(hideAuthorPopup, 100); // 给 100ms 缓冲,让鼠标能移动到浮窗上
});
/* ===================================================== */
minBtn.addEventListener('click', event => {
event.stopPropagation();
pinPanelToCurrentScreenPosition(panel);
const minimized = panel.classList.toggle('minimized');
minBtn.textContent = minimized ? '+' : '−';
minBtn.title = minimized ? '展开' : '最小化';
closeDropdowns();
if (!minimized) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollCurrentItemToTop();
});
});
}
});
header.addEventListener('mousedown', event => {
if (event.button !== 0 || event.target.closest('button')) {
return;
}
// 新增:一旦按下鼠标准备拖动,立即清除定时器并隐藏浮窗
clearTimeout(authorPopupShowTimer);
hideAuthorPopup();
const rect = panel.getBoundingClientRect();
panel.style.left = `${rect.left}px`;
panel.style.top = `${rect.top}px`;
panel.style.right = 'auto';
dragging = true;
dragMoved = false;
startX = event.clientX;
startY = event.clientY;
startLeft = rect.left;
startTop = rect.top;
header.classList.add('dragging');
event.preventDefault();
});
document.addEventListener('mousemove', event => {
if (!dragging) {
return;
}
if (Math.abs(event.clientX - startX) > 3 || Math.abs(event.clientY - startY) > 3) {
dragMoved = true;
}
const maxLeft = Math.max(0, window.innerWidth - panel.offsetWidth);
const maxTop = Math.max(0, window.innerHeight - panel.offsetHeight);
const left = Math.max(0, Math.min(maxLeft, startLeft + event.clientX - startX));
const top = Math.max(0, Math.min(maxTop, startTop + event.clientY - startY));
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
});
document.addEventListener('mouseup', () => {
if (!dragging) {
return;
}
dragging = false;
header.classList.remove('dragging');
if (dragMoved) {
panel.dataset.justDragged = '1';
setTimeout(() => {
delete panel.dataset.justDragged;
}, 0);
}
});
}
/*
* =========================================================
* Panel
* =========================================================
*/
function createPanel() {
let panel =
document.getElementById(
PANEL_ID
);
if (
panel
) {
return panel;
}
panel =
document.createElement(
'section'
);
panel.id =
PANEL_ID;
/*
* 默认最小化。
*/
panel.classList.add(
'minimized'
);
panel.innerHTML = `
已选 0
点标题可直接下载
`;
document.body.appendChild(
panel
);
bindDragAndMin(
panel
);
initStaticDropdowns();
mountOfficialLogo();
startLogoWatcher();
panel.addEventListener(
'input',
event => {
if (
!event.target.matches(
'.tm-search'
)
) {
return;
}
state.search =
event.target.value ||
'';
renderItems();
}
);
panel.addEventListener(
'change',
event => {
if (
event.target.matches(
'.tm-select-all'
)
) {
const visibleItems =
getVisibleItems();
if (
event.target.checked
) {
for (
const item
of visibleItems
) {
state.selectedKeys.add(
item.key
);
}
} else {
for (
const item
of visibleItems
) {
state.selectedKeys.delete(
item.key
);
}
}
renderItems();
return;
}
if (
event.target.matches(
'.tm-item-check'
)
) {
const key =
event.target
.dataset
.key;
if (
!key
) {
return;
}
if (
event.target.checked
) {
state.selectedKeys.add(
key
);
} else {
state.selectedKeys.delete(
key
);
}
updateSelectedCount();
}
}
);
panel.addEventListener(
'click',
async event => {
/*
* Shift + 点击选框:
* 与上一次点击的选框之间连续选中/取消选中。
*/
const itemCheck =
event.target.closest(
'.tm-item-check'
);
if (
itemCheck
) {
const key =
itemCheck.dataset.key;
if (
!key
) {
return;
}
if (
event.shiftKey &&
state.lastCheckedKey &&
state.lastCheckedKey !==
key
) {
event.preventDefault();
const wasSelected =
state.selectedKeys.has(
key
);
toggleRangeSelection(
state.lastCheckedKey,
key,
!wasSelected
);
state.lastCheckedKey =
key;
renderItems();
return;
}
state.lastCheckedKey =
key;
/*
* 普通点击仍走 change 事件处理。
*/
}
/*
* 自定义下拉框。
*/
const option =
event.target.closest(
'.tm-select-option'
);
if (
option
) {
const root =
option.closest(
'.tm-dropdown'
);
setDropdownValue(
root,
option
.dataset
.value,
option
.querySelector(
'.tm-option-text'
)
?.textContent
?.trim()
);
root.classList.remove(
'open'
);
return;
}
const trigger =
event.target.closest(
'.tm-select-trigger'
);
if (
trigger
) {
const root =
trigger.closest(
'.tm-dropdown'
);
const opening =
!root.classList.contains(
'open'
);
closeDropdowns(
root
);
root.classList.toggle(
'open',
opening
);
return;
}
/*
* Logo:
* 单击下载;
* 拖动以后不触发。
*/
const logo =
event.target.closest(
'.tm-logo-host'
);
if (
logo
) {
if (
panel.dataset.justDragged ===
'1'
) {
return;
}
await startQuickCurrent();
return;
}
/*
* 标题下载逻辑保持不变。
*/
const title =
event.target.closest(
'.tm-item-title'
);
if (
title
) {
await startSingle(
findItem(
title
.dataset
.key
)
);
return;
}
const button =
event.target.closest(
'button[data-action]'
);
if (
!button
) {
return;
}
try {
if (
button
.dataset
.action ===
'toggle-search'
) {
const wrap =
button.closest(
'.tm-search-wrap'
);
const input =
wrap
?.querySelector(
'.tm-search'
);
if (
!wrap ||
!input
) {
return;
}
const opened =
wrap.classList.toggle(
'open'
);
button.classList.toggle(
'active',
opened
);
if (
opened
) {
requestAnimationFrame(
() => {
input.focus();
input.select();
}
);
} else {
input.blur();
if (
input.value ||
state.search
) {
input.value =
'';
state.search =
'';
renderItems();
}
}
return;
}
if (
button
.dataset
.action ===
'download'
) {
await startSelected();
return;
}
if (
button
.dataset
.action ===
'download-audio'
) {
await startSelectedAudio();
return;
}
if (
button
.dataset
.action ===
'download-subtitle'
) {
await startSelectedSubtitles();
return;
}
if (
button
.dataset
.action ===
'load-list'
) {
const limitInput =
$(
`#${PANEL_ID} .tm-list-limit`
);
const startInput =
$(
`#${PANEL_ID} .tm-list-date-start`
);
const endInput =
$(
`#${PANEL_ID} .tm-list-date-end`
);
const autoScrollInput =
$(
`#${PANEL_ID} .tm-list-autoscroll-check`
);
await loadListItems({
maxCount:
limitInput
?.value ||
0,
dateStart:
startInput
?.value ||
'',
dateEnd:
endInput
?.value ||
'',
autoScroll:
autoScrollInput
?.checked ||
false,
fetchMore:
true,
});
setStatus(
`列表已刷新 · ${state.listLoadedCount} 个视频`,
'ok'
);
return;
}
} catch (err) {
console.error(
'[BiliDL]',
err
);
setStatus(
`失败:${
err
?.message ||
err
}`,
'error'
);
}
}
);
document.addEventListener(
'click',
event => {
if (
!event.target.closest(
`#${PANEL_ID} .tm-dropdown`
)
) {
closeDropdowns();
}
}
);
document.addEventListener(
'keydown',
event => {
if (
event.key ===
'Escape'
) {
closeDropdowns();
}
}
);
return panel;
}
/*
* =========================================================
* 初始化
* =========================================================
*/
async function initPage(
force = false
) {
if (
state.busy &&
!force
) {
return;
}
const seq =
++state.initSeq;
state.listMode =
isListPage();
createPanel();
mountOfficialLogo();
setStatus(
'正在快速解析合集 / 分P…'
);
setProgress(
0,
'解析中'
);
const result =
isListPage()
? await initListPage()
: await parseResourcesFast();
if (
seq !==
state.initSeq
) {
return;
}
updateListBatchUI();
const currentItem =
getCurrentItem() ||
state.items[0];
await probeFormats(
currentItem
);
const videoCount =
new Set(
state.items.map(
item =>
item.bvid
)
).size;
const description =
state.listMode
? `${state.listLoadedCount} / ${state.listTotal} 个视频`
: state.hasCollection
? `${videoCount} 个视频 · ${state.items.length} 个条目`
: state.items.length >
1
? `${state.items.length} 个分P`
: '单视频';
setStatus(
`解析完成 · ${description}`,
'ok'
);
setProgress(
0,
'等待'
);
renderItems();
return result;
}
/*
* =========================================================
* SPA
* =========================================================
*/
async function refreshForUrlChange() {
/*
* 如果此时正在下载,
* 等当前任务结束后再解析新页面。
*/
while (
state.busy
) {
await sleep(
300
);
}
await initPage();
}
function installSpaWatcher() {
const fire =
() =>
window.dispatchEvent(
new Event(
'tm-bili-urlchange'
)
);
for (
const name
of [
'pushState',
'replaceState',
]
) {
const original =
history[name];
history[name] =
function (
...args
) {
const result =
original.apply(
this,
args
);
fire();
return result;
};
}
window.addEventListener(
'popstate',
fire
);
window.addEventListener(
'tm-bili-urlchange',
() => {
setTimeout(
() => {
if (
location.href ===
state.href
) {
return;
}
state.href =
location.href;
refreshForUrlChange()
.catch(
err => {
setStatus(
`自动刷新失败:${
err
?.message ||
err
}`,
'error'
);
}
);
},
300
);
}
);
setInterval(
() => {
if (
location.href ===
state.href
) {
return;
}
state.href =
location.href;
refreshForUrlChange()
.catch(
err => {
setStatus(
`自动刷新失败:${
err
?.message ||
err
}`,
'error'
);
}
);
},
1000
);
}
/*
* =========================================================
* CSS
* =========================================================
*/
GM_addStyle(`
#${PANEL_ID} {
--bili-blue:
${BILI_BLUE};
--bili-blue-soft:
rgba(
0,
174,
236,
.09
);
--bili-blue-selected:
rgba(
0,
174,
236,
.13
);
--text:
#18191c;
--muted:
#9499a0;
--border:
#e3e5e7;
/*
* 所有可见控件统一圆角。
*/
--radius:
12px;
position:
fixed;
left:
calc(
50% +
10px
);
right:
auto;
top:
72px;
width:
430px;
max-height:
calc(
100vh -
92px
);
z-index:
2147483646;
box-sizing:
border-box;
/*
* 自定义下拉菜单需要溢出窗口。
*/
overflow:
visible;
color:
var(
--text
);
background:
rgba(
255,
255,
255,
.985
);
border:
1px solid
rgba(
0,
0,
0,
.07
);
border-radius:
var(
--radius
);
box-shadow:
0 12px 36px
rgba(
0,
0,
0,
.14
);
backdrop-filter:
blur(
18px
);
-webkit-backdrop-filter:
blur(
18px
);
font:
13px/1.45
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"Microsoft YaHei",
sans-serif;
transition:
width .18s ease,
box-shadow .18s ease;
}
#${PANEL_ID} * {
box-sizing:
border-box;
}
#${PANEL_ID} button,
#${PANEL_ID} input {
font:
inherit;
}
/*
* =====================================================
* Header
* =====================================================
*/
#${PANEL_ID} .tm-head {
height:
76px;
padding:
0 13px
0 17px;
display:
flex;
align-items:
center;
justify-content:
space-between;
gap:
16px;
cursor:
grab;
user-select:
none;
background:
rgba(
255,
255,
255,
.985
);
border-radius:
var(
--radius
)
var(
--radius
)
0
0;
border-bottom:
1px solid
#f0f1f2;
}
#${PANEL_ID}
.tm-head.dragging {
cursor:
grabbing;
}
#${PANEL_ID} .tm-brand {
position:
relative;
width:
230px;
height:
76px;
flex:
0 0 230px;
}
#${PANEL_ID} .tm-brand-line {
position:
absolute;
left:
0;
top:
50%;
width:
100%;
height:
30px;
transform:
translateY(
-50%
);
display:
flex;
align-items:
center;
gap:
11px;
}
/*
* Logo:
* 不再有任何 hover / focus 蓝色高亮。
*/
#${PANEL_ID} .tm-logo-host {
width:
78px;
height:
30px;
flex:
0 0 78px;
display:
flex;
align-items:
center;
justify-content:
flex-start;
overflow:
hidden;
color:
var(
--bili-blue
);
cursor:
pointer;
background:
transparent !important;
border:
none;
border-radius:
0;
outline:
none !important;
box-shadow:
none !important;
transition:
opacity .15s ease;
}
#${PANEL_ID}
.tm-logo-host:hover,
#${PANEL_ID}
.tm-logo-host:focus,
#${PANEL_ID}
.tm-logo-host:focus-visible,
#${PANEL_ID}
.tm-logo-host:active {
background:
transparent !important;
outline:
none !important;
box-shadow:
none !important;
}
#${PANEL_ID}
.tm-head.dragging
.tm-logo-host {
cursor:
grabbing;
}
#${PANEL_ID}
.quick-busy
.tm-logo-host,
#${PANEL_ID}.quick-busy
.tm-logo-host {
opacity:
.48;
cursor:
wait;
}
#${PANEL_ID}
.tm-logo-host.is-svg
.tm-bili-official-svg {
display:
block !important;
width:
78px !important;
height:
30px !important;
max-width:
78px !important;
max-height:
30px !important;
margin:
0 !important;
padding:
0 !important;
flex:
none !important;
color:
var(
--bili-blue
) !important;
}
#${PANEL_ID}
.tm-logo-host.is-svg
.tm-bili-official-svg path,
#${PANEL_ID}
.tm-logo-host.is-svg
.tm-bili-official-svg use,
#${PANEL_ID}
.tm-logo-host.is-svg
.tm-bili-official-svg polygon {
fill:
var(
--bili-blue
) !important;
color:
var(
--bili-blue
) !important;
}
#${PANEL_ID} .tm-bili-fallback-logo {
display:
block;
width:
29px;
height:
29px;
object-fit:
contain;
}
#${PANEL_ID} .tm-brand-divider {
width:
1px;
height:
19px;
flex:
0 0 1px;
margin:
0 1px;
background:
var(
--border
);
}
#${PANEL_ID} .tm-brand-name {
flex:
0 0 auto;
color:
var(
--text
);
font-size:
18px;
line-height:
30px;
font-weight:
700;
letter-spacing:
-.25px;
white-space:
nowrap;
}
#${PANEL_ID} .tm-head-actions {
flex:
0 0 auto;
display:
flex;
align-items:
center;
}
#${PANEL_ID} .tm-icon-btn {
width:
36px;
height:
36px;
flex:
0 0 36px;
padding:
0;
display:
grid;
place-items:
center;
border:
1px solid
var(
--border
);
border-radius:
var(
--radius
);
color:
#61666d;
background:
#fff;
outline:
none;
font-size:
20px;
line-height:
1;
cursor:
pointer;
transition:
border-color .15s ease,
color .15s ease,
background .15s ease,
transform .1s ease;
}
#${PANEL_ID}
.tm-icon-btn:hover:not(:disabled) {
color:
var(
--bili-blue
);
border-color:
rgba(
0,
174,
236,
.44
);
background:
var(
--bili-blue-soft
);
}
#${PANEL_ID}
.tm-icon-btn:active:not(:disabled) {
transform:
scale(
.95
);
}
/*
* =====================================================
* 最小化
* =====================================================
*/
#${PANEL_ID}.minimized {
width:
150px;
box-shadow:
0 9px 30px
rgba(
0,
0,
0,
.13
);
}
#${PANEL_ID}.minimized .tm-body {
display:
none;
}
#${PANEL_ID}.minimized .tm-head {
height:
70px;
padding:
0 12px
0 14px;
gap:
5px;
border-radius:
var(
--radius
);
border-bottom-color:
transparent;
}
#${PANEL_ID}.minimized .tm-brand {
width:
78px;
height:
70px;
flex:
0 0 78px;
}
#${PANEL_ID}.minimized .tm-brand-line {
width:
78px;
}
#${PANEL_ID}.minimized .tm-brand-divider,
#${PANEL_ID}.minimized .tm-brand-name {
display:
none;
}
#${PANEL_ID}.minimized .tm-icon-btn {
width:
34px;
height:
34px;
flex-basis:
34px;
}
/*
* =====================================================
* Body
* =====================================================
*/
#${PANEL_ID} .tm-body {
padding:
12px 15px
14px;
display:
flex;
flex-direction:
column;
gap:
9px;
max-height:
calc(
100vh -
178px
);
background:
rgba(
255,
255,
255,
.985
);
border-radius:
0
0
var(
--radius
)
var(
--radius
);
}
#${PANEL_ID} .tm-row {
display:
grid;
grid-template-columns:
88px
1fr;
align-items:
center;
gap:
9px;
}
#${PANEL_ID} .tm-row label {
color:
#61666d;
font-size:
14px;
}
/*
* =====================================================
* 自定义下拉
* =====================================================
*/
#${PANEL_ID} .tm-dropdown {
position:
relative;
min-width:
0;
}
#${PANEL_ID} .tm-select-trigger {
width:
100%;
height:
42px;
padding:
0 13px;
display:
flex;
align-items:
center;
justify-content:
space-between;
gap:
12px;
border:
1px solid
var(
--border
);
border-radius:
var(
--radius
);
color:
var(
--text
);
background:
#fff;
outline:
none;
cursor:
pointer;
text-align:
left;
transition:
border-color .15s ease,
box-shadow .15s ease,
background .15s ease;
}
#${PANEL_ID}
.tm-select-trigger:hover:not(:disabled) {
border-color:
#c9ccd0;
}
#${PANEL_ID} .tm-select-label {
min-width:
0;
overflow:
hidden;
white-space:
nowrap;
text-overflow:
ellipsis;
}
/*
* 箭头永远固定朝下。
* 打开和关闭均不旋转、不移动。
*/
#${PANEL_ID} .tm-select-arrow {
width:
18px;
height:
18px;
flex:
0 0 18px;
display:
grid;
place-items:
center;
color:
#61666d;
transform:
none !important;
}
#${PANEL_ID} .tm-select-arrow svg {
width:
16px;
height:
16px;
display:
block;
fill:
none;
stroke:
currentColor;
stroke-width:
1.8;
stroke-linecap:
round;
stroke-linejoin:
round;
}
#${PANEL_ID}
.tm-dropdown.open
.tm-select-trigger {
border-color:
var(
--bili-blue
);
box-shadow:
0 0 0 3px
var(
--bili-blue-soft
);
}
#${PANEL_ID}
.tm-dropdown.open
.tm-select-arrow {
color:
#61666d;
transform:
none !important;
}
/*
* 菜单本体有内边距,
* 每个选项之间也有 6px 真正空隙。
*/
#${PANEL_ID} .tm-select-menu {
position:
absolute;
left:
0;
right:
0;
top:
calc(
100% +
7px
);
z-index:
60;
max-height:
255px;
padding:
7px;
display:
flex;
flex-direction:
column;
gap:
6px;
overflow-x:
hidden;
overflow-y:
auto;
opacity:
0;
visibility:
hidden;
transform:
translateY(
-4px
);
border:
1px solid
var(
--border
);
border-radius:
var(
--radius
);
background:
#fff;
box-shadow:
0 12px 30px
rgba(
0,
0,
0,
.15
);
transition:
opacity .12s ease,
transform .12s ease,
visibility .12s ease;
}
#${PANEL_ID}
.tm-dropdown.open
.tm-select-menu {
opacity:
1;
visibility:
visible;
transform:
translateY(
0
);
}
#${PANEL_ID} .tm-select-option {
width:
100%;
min-height:
40px;
flex:
0 0 auto;
padding:
0 12px;
display:
flex;
align-items:
center;
justify-content:
space-between;
gap:
10px;
border:
0;
border-radius:
var(
--radius
);
color:
var(
--text
);
background:
transparent;
outline:
none;
cursor:
pointer;
text-align:
left;
transition:
color .14s ease,
background-color .14s ease;
}
#${PANEL_ID}
.tm-select-option:hover:not(:disabled) {
color:
#008fc4;
background:
var(
--bili-blue-soft
);
}
#${PANEL_ID}
.tm-select-option.selected {
color:
#008fc4;
font-weight:
600;
background:
var(
--bili-blue-selected
);
}
#${PANEL_ID} .tm-option-text {
min-width:
0;
overflow:
hidden;
white-space:
nowrap;
text-overflow:
ellipsis;
}
#${PANEL_ID} .tm-option-check {
flex:
0 0 auto;
opacity:
0;
color:
var(
--bili-blue
);
font-size:
18px;
font-weight:
800;
}
#${PANEL_ID}
.tm-select-option.selected
.tm-option-check {
opacity:
1;
}
/*
* =====================================================
* 列表顶部
* =====================================================
*/
#${PANEL_ID} .tm-list-head {
min-height:
32px;
display:
flex;
align-items:
center;
justify-content:
space-between;
gap:
10px;
}
#${PANEL_ID} .tm-list-batch {
display:
none;
flex-direction:
column;
gap:
8px;
padding:
10px;
margin:
0 -4px 4px;
border-radius:
var(
--radius
);
background:
#f6f7f8;
}
#${PANEL_ID}.tm-list-mode
.tm-list-batch {
display:
flex;
}
#${PANEL_ID} .tm-list-batch-row {
display:
flex;
align-items:
center;
justify-content:
space-between;
gap:
8px;
}
#${PANEL_ID} .tm-list-batch-title {
color:
var(
--text
);
font-weight:
600;
font-size:
13px;
}
#${PANEL_ID} .tm-list-batch-count {
color:
var(
--muted
);
font-size:
11px;
}
#${PANEL_ID} .tm-list-batch input {
min-width:
0;
height:
32px;
padding:
0 8px;
border:
1px solid
var(
--border
);
border-radius:
var(
--radius
);
color:
var(
--text
);
background:
#fff;
outline:
none;
font-size:
12px;
}
#${PANEL_ID} .tm-list-batch input:focus {
border-color:
var(
--bili-blue
);
box-shadow:
0 0 0 3px
var(
--bili-blue-soft
);
}
#${PANEL_ID} .tm-list-limit {
flex:
0 0 72px;
}
#${PANEL_ID} .tm-list-date-start,
#${PANEL_ID} .tm-list-date-end {
flex:
1 1 0;
min-width:
0;
}
#${PANEL_ID} .tm-list-autoscroll {
display:
inline-flex;
align-items:
center;
gap:
6px;
color:
#61666d;
font-size:
12px;
cursor:
pointer;
user-select:
none;
}
#${PANEL_ID} .tm-list-load-btn {
flex:
0 0 auto;
height:
32px;
padding:
0 12px;
border:
0;
border-radius:
var(
--radius
);
color:
#fff;
background:
var(
--bili-blue
);
font-weight:
600;
font-size:
12px;
cursor:
pointer;
transition:
filter .15s ease,
transform .1s ease;
}
#${PANEL_ID}
.tm-list-load-btn:hover:not(:disabled) {
filter:
brightness(
1.05
);
}
#${PANEL_ID}
.tm-list-load-btn:disabled {
opacity:
.55;
cursor:
not-allowed;
}
#${PANEL_ID} .tm-list-tools {
min-width:
0;
display:
flex;
align-items:
center;
justify-content:
flex-end;
gap:
7px;
}
#${PANEL_ID} .tm-search-wrap {
min-width:
0;
display:
flex;
align-items:
center;
justify-content:
flex-end;
gap:
7px;
}
#${PANEL_ID} .tm-search {
width:
0;
min-width:
0;
height:
36px;
padding:
0;
opacity:
0;
pointer-events:
none;
border:
1px solid
transparent;
border-radius:
var(
--radius
);
color:
var(
--text
);
background:
#fff;
outline:
none;
transition:
width .18s ease,
padding .18s ease,
opacity .12s ease,
border-color .12s ease,
box-shadow .15s ease;
}
#${PANEL_ID}
.tm-search-wrap.open
.tm-search {
width:
180px;
padding:
0 10px;
opacity:
1;
pointer-events:
auto;
border-color:
var(
--border
);
}
#${PANEL_ID} .tm-search:focus {
border-color:
var(
--bili-blue
);
box-shadow:
0 0 0 3px
var(
--bili-blue-soft
);
}
#${PANEL_ID} .tm-search-toggle {
width:
36px;
height:
36px;
flex:
0 0 36px;
padding:
0;
display:
grid;
place-items:
center;
border:
1px solid
var(
--border
);
border-radius:
var(
--radius
);
color:
#61666d;
background:
#fff;
outline:
none;
cursor:
pointer;
}
#${PANEL_ID}
.tm-search-toggle:hover:not(:disabled),
#${PANEL_ID}
.tm-search-toggle.active {
color:
var(
--bili-blue
);
border-color:
rgba(
0,
174,
236,
.44
);
background:
var(
--bili-blue-soft
);
}
#${PANEL_ID} .tm-search-toggle svg {
width:
17px;
height:
17px;
fill:
none;
stroke:
currentColor;
stroke-width:
1.8;
stroke-linecap:
round;
}
#${PANEL_ID} .tm-select-all-wrap {
display:
inline-flex;
align-items:
center;
gap:
7px;
color:
#61666d;
font-weight:
500;
cursor:
pointer;
user-select:
none;
}
#${PANEL_ID} input[type="checkbox"] {
width:
18px;
height:
18px;
margin:
0;
accent-color:
var(
--bili-blue
);
cursor:
pointer;
}
#${PANEL_ID} .tm-items-hint {
color:
var(
--muted
);
font-size:
11px;
}
/*
* =====================================================
* 视频列表
* =====================================================
*/
#${PANEL_ID} .tm-items {
position:
relative;
min-height:
0;
max-height:
320px;
overflow:
auto;
border:
1px solid
var(
--border
);
border-radius:
var(
--radius
);
background:
#f7f8f9;
scroll-behavior:
auto;
}
#${PANEL_ID} .tm-items::-webkit-scrollbar,
#${PANEL_ID} .tm-select-menu::-webkit-scrollbar {
width:
6px;
}
#${PANEL_ID} .tm-items::-webkit-scrollbar-thumb,
#${PANEL_ID} .tm-select-menu::-webkit-scrollbar-thumb {
border-radius:
999px;
background:
#d3d6d9;
}
#${PANEL_ID} .tm-item {
min-height:
46px;
padding:
6px 10px;
display:
grid;
grid-template-columns:
20px
max-content
minmax(
0,
1fr
);
align-items:
center;
column-gap:
7px;
background:
transparent;
transition:
background-color .2s ease;
}
#${PANEL_ID}
.tm-item + .tm-item {
border-top:
1px solid
#e9eaec;
}
#${PANEL_ID}
.tm-item:hover {
background:
#fff;
}
#${PANEL_ID} .tm-item-index {
color:
var(
--muted
);
white-space:
nowrap;
font-variant-numeric:
tabular-nums;
transition:
color .2s ease;
}
#${PANEL_ID} .tm-item-title {
min-width:
0;
padding:
6px 7px;
overflow:
hidden;
white-space:
nowrap;
text-overflow:
ellipsis;
border-radius:
var(
--radius
);
color:
var(
--text
);
cursor:
pointer;
transition:
color .2s ease,
background-color .2s ease;
}
#${PANEL_ID}
.tm-item-title:hover,
#${PANEL_ID}
.tm-item.tm-current-flash
.tm-item-title {
color:
var(
--bili-blue
);
background:
var(
--bili-blue-soft
);
}
#${PANEL_ID}
.tm-item.tm-current-flash
.tm-item-index {
color:
var(
--bili-blue
);
}
#${PANEL_ID} .tm-empty {
padding:
20px;
color:
var(
--muted
);
text-align:
center;
}
#${PANEL_ID} .tm-summary {
display:
flex;
align-items:
center;
justify-content:
space-between;
color:
var(
--muted
);
font-size:
11px;
}
/*
* =====================================================
* 下载
* =====================================================
*/
#${PANEL_ID} .tm-download-actions,
#${PANEL_ID} .tm-subtitle-actions {
display:
grid;
grid-template-columns:
minmax(
0,
1fr
)
minmax(
0,
1fr
);
gap:
8px;
}
#${PANEL_ID} .tm-subtitle-actions {
grid-template-columns:
166px
minmax(
0,
1fr
);
}
#${PANEL_ID} .tm-download {
width:
100%;
min-height:
42px;
padding:
0 10px;
border:
0;
border-radius:
var(
--radius
);
color:
#fff;
background:
linear-gradient(
135deg,
#00aeec 0%,
#22bce9 100%
);
font-weight:
700;
cursor:
pointer;
box-shadow:
0 5px 15px
rgba(
0,
174,
236,
.18
);
transition:
filter .15s ease,
transform .1s ease,
box-shadow .15s ease;
}
#${PANEL_ID}
.tm-download:hover:not(:disabled) {
filter:
brightness(
1.055
);
box-shadow:
0 6px 18px
rgba(
0,
174,
236,
.26
);
}
#${PANEL_ID}
.tm-download:active:not(:disabled) {
transform:
scale(
.985
);
}
#${PANEL_ID} button:disabled,
#${PANEL_ID} input:disabled {
opacity:
.52;
cursor:
not-allowed;
}
/*
* =====================================================
* 状态 / 进度
* =====================================================
*/
#${PANEL_ID} .tm-status-card {
padding:
9px 10px
7px;
border-radius:
var(
--radius
);
background:
#f6f7f8;
}
#${PANEL_ID} .tm-status-line {
min-width:
0;
display:
flex;
align-items:
center;
justify-content:
space-between;
gap:
8px;
}
#${PANEL_ID} .tm-status {
min-width:
0;
flex:
1 1 auto;
overflow:
hidden;
color:
#61666d;
white-space:
nowrap;
text-overflow:
ellipsis;
}
#${PANEL_ID}
.tm-status[data-type="ok"] {
color:
#008fc4;
}
#${PANEL_ID}
.tm-status[data-type="error"] {
color:
#d64242;
}
#${PANEL_ID} .tm-progress-text {
flex:
0 0 auto;
max-width:
150px;
overflow:
hidden;
color:
var(
--muted
);
font-size:
10.5px;
white-space:
nowrap;
text-overflow:
ellipsis;
text-align:
right;
}
#${PANEL_ID} .tm-progress-track {
height:
3px;
margin-top:
6px;
overflow:
hidden;
border-radius:
999px;
background:
var(
--border
);
}
#${PANEL_ID} .tm-progress-fill {
width:
0;
height:
100%;
border-radius:
999px;
background:
var(
--bili-blue
);
transition:
width .18s ease;
}
/*
* =====================================================
* Logo 下载提示
* =====================================================
*/
#${PANEL_ID}-quick-toast {
position:
fixed;
z-index:
2147483647;
max-width:
330px;
padding:
10px 13px;
opacity:
0;
visibility:
hidden;
transform:
translateY(
-5px
);
border:
1px solid
rgba(
0,
174,
236,
.22
);
border-radius:
12px;
color:
#0078a8;
background:
rgba(
255,
255,
255,
.985
);
box-shadow:
0 10px 28px
rgba(
0,
0,
0,
.16
);
font:
13px/1.45
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"Microsoft YaHei",
sans-serif;
transition:
opacity .14s ease,
transform .14s ease,
visibility .14s ease;
pointer-events:
none;
}
#${PANEL_ID}-quick-toast.show {
opacity:
1;
visibility:
visible;
transform:
translateY(
0
);
}
#${PANEL_ID}-quick-toast[data-type="error"] {
color:
#d64242;
border-color:
rgba(
214,
66,
66,
.22
);
}
#${PANEL_ID}-quick-toast[data-type="ok"] {
color:
#008fc4;
}
#${PANEL_ID}-quick-toast[data-type="warn"] {
color:
#a66a00;
border-color:
rgba(
166,
106,
0,
.20
);
}
/* =====================================================
* 信息浮窗
* ===================================================== */
#${PANEL_ID}-author-popup {
position: fixed;
width: 400px;
height: 420px;
background: #fff;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
z-index: 2147483647;
display: none;
padding: 24px;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
color: #333;
border: 1px solid #e3e5e7;
}
#${PANEL_ID}-author-popup.show {
display: flex;
gap: 20px;
}
#${PANEL_ID}-author-popup .popup-qr {
width: 140px;
height: 140px;
flex-shrink: 0;
background: #f6f7f8;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
margin-top: 140px;
}
#${PANEL_ID}-author-popup .popup-qr canvas {
width: 120px !important;
height: 120px !important;
}
#${PANEL_ID}-author-popup .popup-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
overflow: hidden;
}
#${PANEL_ID}-author-popup .popup-title {
font-size: 18px;
font-weight: bold;
color: #00AEEC;
margin-bottom: 4px;
}
#${PANEL_ID}-author-popup .popup-desc {
font-size: 13px;
line-height: 1.6;
color: #666;
}
#${PANEL_ID}-author-popup .popup-tutorial {
font-size: 13px;
color: #888;
border-top: 1px solid #eee;
padding-top: 12px;
margin-top: auto;
line-height: 1.6;
}
#${PANEL_ID}-author-popup .popup-tutorial strong {
color: #333;
}
@media (
max-width:
900px
) {
#${PANEL_ID} {
left:
8px;
right:
auto;
top:
62px;
width:
min(
430px,
calc(
100vw -
16px
)
);
}
#${PANEL_ID}.minimized {
width:
min(
150px,
calc(
100vw -
16px
)
);
}
}
`);
GM_addStyle(`
#${PANEL_ID}.tm-tampered {
border-color: #ff4d4f !important;
box-shadow: 0 0 0 1px #ff4d4f, 0 12px 36px rgba(255, 77, 79, 0.25) !important;
}
#${PANEL_ID}.tm-tampered .tm-brand-name {
color: #ff4d4f !important;
}
#${PANEL_ID}.tm-tampered .tm-brand-name::after {
content: '(非原版)';
color: #ff4d4f;
font-size: 12px;
font-weight: 400;
}
#${PANEL_ID}.tm-tampered .tm-download {
background: linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%) !important;
box-shadow: 0 5px 15px rgba(255, 77, 79, 0.3) !important;
}
#${PANEL_ID}-tamper-card {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2147483647;
width: 340px;
max-width: calc(100vw - 32px);
background: #fff;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
padding: 24px 20px 20px;
text-align: center;
color: #1f1f1f;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
box-sizing: border-box;
}
#${PANEL_ID}-tamper-card .tm-tamper-box {
position: relative;
}
#${PANEL_ID}-tamper-card .tm-tamper-close {
position: absolute;
top: -10px;
right: -6px;
width: 26px;
height: 26px;
border: 0;
border-radius: 50%;
background: #f2f3f5;
color: #666;
font-size: 16px;
line-height: 1;
cursor: pointer;
}
#${PANEL_ID}-tamper-card .tm-tamper-close:hover {
background: #ffd6d6;
color: #ff4d4f;
}
#${PANEL_ID}-tamper-card .tm-tamper-title {
font-size: 18px;
font-weight: 700;
color: #ff4d4f;
margin-bottom: 10px;
}
#${PANEL_ID}-tamper-card .tm-tamper-desc {
font-size: 14px;
line-height: 1.6;
color: #444;
margin-bottom: 10px;
}
#${PANEL_ID}-tamper-card .tm-tamper-detail {
font-size: 12px;
color: #888;
margin-bottom: 14px;
word-break: break-all;
}
#${PANEL_ID}-tamper-card .tm-tamper-qr {
width: 150px;
height: 150px;
margin: 0 auto 10px;
border: 1px solid #eee;
border-radius: 8px;
overflow: hidden;
}
#${PANEL_ID}-tamper-card .tm-tamper-qr canvas {
display: block;
width: 150px !important;
height: 150px !important;
}
#${PANEL_ID}-tamper-card .tm-tamper-info {
font-size: 14px;
color: #666;
}
`);
downloadOne = protectEntry(
'downloadOne',
downloadOne
);
startSingle = protectEntry(
'startSingle',
startSingle
);
startQuickCurrent = protectEntry(
'startQuickCurrent',
startQuickCurrent
);
startSelected = protectEntry(
'startSelected',
startSelected
);
startSelectedAudio = protectEntry(
'startSelectedAudio',
startSelectedAudio
);
startSelectedSubtitles = protectEntry(
'startSelectedSubtitles',
startSelectedSubtitles
);
createPanel();
installSpaWatcher();
initPage()
.catch(
err => {
console.error(
'[BiliDL] init failed:',
err
);
setStatus(
`初始化失败:${
err
?.message ||
err
}`,
'error'
);
}
);
})();