// ==UserScript==
// @name Bilibili Accelerator
// @name:zh-CN Bilibili Accelerator - B站海外播放加速
// @namespace https://github.com/realzza/bilibili-accelerator
// @version 0.3.0
// @description Smoother Bilibili playback for overseas viewers.
// @description:zh-CN 缓解海外用户看 B 站冷门视频时的卡顿。
// @author realzza
// @license MIT
// @homepageURL https://github.com/realzza/bilibili-accelerator
// @supportURL https://github.com/realzza/bilibili-accelerator/issues
// @match https://*.bilibili.com/*
// @match https://*.bilibili.tv/*
// @run-at document-start
// @grant none
// ==/UserScript==
(function initBiliAcceleratorCore(root, factory) {
const core = factory();
if (typeof module === "object" && module.exports) {
module.exports = core;
}
root.BiliAcceleratorCore = core;
})(typeof globalThis !== "undefined" ? globalThis : window, function createCore() {
"use strict";
const SCHEMA_VERSION = 2;
// Healthy UPOS mirrors we are willing to rewrite toward. The first entry is
// the default target; the whole list seeds the auto-selection candidate pool.
const CDN_HOSTS = Object.freeze([
"upos-sz-mirrorcos.bilivideo.com",
"upos-sz-mirrorali.bilivideo.com",
"upos-sz-mirrorhw.bilivideo.com",
"upos-tf-all-hw.bilivideo.com",
"upos-tf-all-tx.bilivideo.com",
"upos-hz-mirrorakam.akamaized.net",
"upos-sz-mirrorakam.akamaized.net",
"upos-sz-mirroraliov.bilivideo.com",
"upos-sz-mirrorcosov.bilivideo.com",
"upos-sz-mirrorhwov.bilivideo.com"
]);
// Panel appearance options. Keys only — the actual accent hexes and dark/light
// surface tokens live in the page UI; core just owns the allowed values so a
// stored or bridged config can be validated back to a known-good default.
const ACCENT_KEYS = Object.freeze([
"bili", // Bilibili blue (default — unchanged for existing users)
"teal",
"emerald",
"violet",
"pink", // Little-TV pink
"sunset",
"graphite"
]);
const THEME_MODES = Object.freeze(["system", "light", "dark"]);
// Candidates that are safe to auto-probe and rank: non-akamai, non-overseas
// mirrors that work well as generic rewrite targets.
const CANDIDATE_POOL = Object.freeze([
"upos-sz-mirrorcos.bilivideo.com",
"upos-sz-mirrorali.bilivideo.com",
"upos-sz-mirrorhw.bilivideo.com",
"upos-tf-all-hw.bilivideo.com",
"upos-tf-all-tx.bilivideo.com"
]);
const DEFAULT_CONFIG = Object.freeze({
enabled: true,
lang: "en", // en | zh (UI language)
accent: "bili", // panel accent (see ACCENT_KEYS)
theme: "system", // system | light | dark surface
mode: "bad-only", // bad-only | force | off
selection: "auto", // auto | fixed
pcdnHost: "upos-sz-mirrorcos.bilivideo.com",
candidatePool: CANDIDATE_POOL.slice(),
mcdnStrategy: "proxy-all", // proxy-all | proxy-v1 | replace
proxyHost: "proxy-tf-all-ws.bilivideo.com",
rewriteAkamai: false,
portHeuristic: true, // non-default port ⇒ PCDN
stallRecovery: true, // live failover on buffering
p2pGuard: false, // opt-in WebRTC/PCDN neutralizer
maxDepth: 20,
schemaVersion: SCHEMA_VERSION
});
const MEDIA_PATH_RE = /\.(m4s|mp4|flv|m3u8)(?:$|[?#])/i;
const IP_RE = /^(?:\d{1,3}\.){3}\d{1,3}$/;
const XY_MCDN_RE = /^xy(?:\d+x){3}\d+xy\.mcdn\.bilivideo\.(?:cn|com|net)$/i;
// P2P/PCDN families known from community research (Bilibili-Evolved, MBGTEB):
// szbdyd is the legacy scheduler, mountaintoys the 2025 rename, nexusedgeio and
// ahdohpiechei are where the upos-*302* redirect hosts land, and mirror14b is a
// mirror-named host that actually serves PCDN (its TLS cert is *.bilivideo.cn).
const KNOWN_P2P_SUFFIXES = Object.freeze([
".szbdyd.com",
".mountaintoys.cn",
".nexusedgeio.com",
".ahdohpiechei.com"
]);
const KNOWN_P2P_HOSTS = Object.freeze([
"upos-sz-mirror14b.bilivideo.com"
]);
function isKnownP2pHost(hostname) {
if (KNOWN_P2P_HOSTS.indexOf(hostname) !== -1) {
return true;
}
for (let i = 0; i < KNOWN_P2P_SUFFIXES.length; i += 1) {
if (hostname.length > KNOWN_P2P_SUFFIXES[i].length &&
hostname.indexOf(KNOWN_P2P_SUFFIXES[i]) === hostname.length - KNOWN_P2P_SUFFIXES[i].length) {
return true;
}
}
// upos-sz-302ppio / upos-sz-302kodo style hosts answer with an HTTP 302 to a
// residential P2P node; the "302" only ever appears in that first label.
return hostname.indexOf("upos-") === 0 && hostname.split(".")[0].indexOf("302") !== -1;
}
// Forward-migrate any stored config (v1 or partial) onto the v2 defaults.
function normalizeConfig(config) {
const merged = Object.assign({}, DEFAULT_CONFIG, config || {});
if (!Array.isArray(merged.candidatePool) || merged.candidatePool.length === 0) {
merged.candidatePool = CANDIDATE_POOL.slice();
}
if (merged.mode !== "bad-only" && merged.mode !== "force" && merged.mode !== "off") {
merged.mode = DEFAULT_CONFIG.mode;
}
if (merged.selection !== "auto" && merged.selection !== "fixed") {
merged.selection = DEFAULT_CONFIG.selection;
}
if (merged.lang !== "en" && merged.lang !== "zh") {
merged.lang = DEFAULT_CONFIG.lang;
}
if (ACCENT_KEYS.indexOf(merged.accent) === -1) {
merged.accent = DEFAULT_CONFIG.accent;
}
if (THEME_MODES.indexOf(merged.theme) === -1) {
merged.theme = DEFAULT_CONFIG.theme;
}
merged.schemaVersion = SCHEMA_VERSION;
return merged;
}
function hasBiliMediaSignal(value) {
return typeof value === "string" &&
(value.includes("bilivideo") ||
value.includes("akamaized.net") ||
value.includes("szbdyd.com") ||
value.includes("mountaintoys") ||
value.includes("nexusedgeio") ||
value.includes("ahdohpiechei") ||
value.includes("mcdn.bili") ||
value.includes("os=mcdn") ||
value.includes("/upgcxcode/") ||
value.includes("/v1/resource/"));
}
function parseUrl(value) {
if (!hasBiliMediaSignal(value)) {
return null;
}
try {
// Payloads occasionally carry protocol-relative URLs ("//host/path").
const url = new URL(value.slice(0, 2) === "//" ? "https:" + value : value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return null;
}
return url;
} catch (_) {
return null;
}
}
function isMediaUrl(url) {
return MEDIA_PATH_RE.test(url.pathname + url.search) ||
url.pathname.startsWith("/upgcxcode/") ||
url.pathname.startsWith("/v1/resource/");
}
// Live streams (/live-bvc/ FLV & HLS) are served by a separate CDN tier — the
// VOD upos mirrors and the MCDN proxy cannot serve them, so a host swap or
// proxy wrap would hard-break live playback. Live PCDN is handled upstream by
// filtering the getRoomPlayInfo host list instead (see filterLiveUrlInfo).
function isLiveMediaUrl(url) {
return url.pathname.indexOf("/live-bvc/") !== -1;
}
function isMcdnHost(hostname) {
return /\.mcdn\.bilivideo\.(?:cn|com|net)$/i.test(hostname);
}
function isBiliCdnHost(hostname) {
return hostname.endsWith(".bilivideo.com") ||
hostname.endsWith(".bilivideo.cn") ||
hostname.endsWith(".bilivideo.net") ||
hostname.endsWith(".akamaized.net");
}
function hasNonDefaultPort(url) {
// URL drops the port when it matches the protocol default (80/443), so any
// remaining port string means a non-standard endpoint — a strong PCDN tell.
return url.port !== "" && url.port !== "80" && url.port !== "443";
}
function hasMcdnQuery(url) {
return url.searchParams.get("os") === "mcdn" || /(?:^|[?&])os=mcdn(?:&|$)/i.test(url.search);
}
function isOverseasMirror(hostname) {
return hostname.includes("mirroraliov") ||
hostname.includes("mirrorcosov") ||
hostname.includes("mirrorhwov");
}
// Single source of truth for "what is this host, and is it slow for us".
// Behavior-based so renamed PCDN families (e.g. *.edge.mountaintoys.cn) are
// caught by the port/os=mcdn heuristics without needing a hostname update.
function classify(url, rawConfig) {
const config = normalizeConfig(rawConfig);
const hostname = url.hostname.toLowerCase();
let schedulerSource = null;
if (hostname.endsWith(".szbdyd.com")) {
schedulerSource = cleanHost(url.searchParams.get("xy_usource") || "") || null;
}
const ipLike = IP_RE.test(hostname);
const xyMcdn = XY_MCDN_RE.test(hostname);
const mcdn = isMcdnHost(hostname);
const akamai = hostname.endsWith(".akamaized.net");
const portPcdn = config.portHeuristic && hasNonDefaultPort(url);
const queryMcdn = hasMcdnQuery(url);
const knownP2p = isKnownP2pHost(hostname);
const isPcdn = ipLike || xyMcdn || portPcdn || queryMcdn || knownP2p;
let kind = "unknown";
if (schedulerSource !== null || hostname.endsWith(".szbdyd.com")) {
kind = "scheduler";
} else if (mcdn) {
kind = "mcdn";
} else if (isPcdn) {
kind = "pcdn";
} else if (akamai) {
kind = "akamai";
} else if (hostname.startsWith("upos-") || hostname.endsWith(".bilivideo.com")) {
kind = "upos";
}
const isSlow = isPcdn ||
isOverseasMirror(hostname) ||
(config.rewriteAkamai && akamai);
return {
host: hostname,
port: url.port || "",
kind,
isPcdn,
isMcdn: mcdn,
isAkamai: akamai,
isSlow,
schedulerSource
};
}
function cleanHost(host) {
const trimmed = String(host || "").trim();
return trimmed.replace(/^https?:\/\//i, "").replace(/\/.*$/, "");
}
function replaceHost(url, host) {
const next = new URL(url.toString());
next.protocol = "https:";
next.host = cleanHost(host);
if (!cleanHost(host).includes(":")) {
next.port = "";
}
return next.toString();
}
function proxyUrl(url, config) {
const next = new URL("https://" + cleanHost(config.proxyHost) + "/");
next.searchParams.set("url", url.toString());
return next.toString();
}
function shouldProxyMcdn(verdict, url, config) {
if (!verdict.isMcdn) {
return false;
}
if (config.mcdnStrategy === "proxy-all") {
return true;
}
return config.mcdnStrategy === "proxy-v1" && url.pathname.startsWith("/v1/resource/");
}
// The host to rewrite slow UPOS/PCDN URLs toward. In auto mode the runtime
// keeps config.pcdnHost pointed at the current best-ranked candidate.
function selectTarget(config) {
return cleanHost(config.pcdnHost) || CDN_HOSTS[0];
}
function rewriteUrlDetail(value, rawConfig) {
const config = normalizeConfig(rawConfig);
const original = String(value || "");
const url = parseUrl(original);
if (!config.enabled || config.mode === "off" || !url || !isMediaUrl(url) ||
url.hostname === cleanHost(config.proxyHost)) {
return { changed: false, original, url: original, reason: "ignored" };
}
if (isLiveMediaUrl(url)) {
return { changed: false, original, url: original, reason: "live-skip" };
}
const verdict = classify(url, config);
if (verdict.schedulerSource) {
const rewritten = replaceHost(url, verdict.schedulerSource);
return {
changed: rewritten !== original,
original,
url: rewritten,
reason: "szbdyd-source",
targetHost: cleanHost(verdict.schedulerSource)
};
}
if (shouldProxyMcdn(verdict, url, config)) {
const rewritten = proxyUrl(url, config);
return {
changed: rewritten !== original,
original,
url: rewritten,
reason: "mcdn-proxy",
targetHost: cleanHost(config.proxyHost)
};
}
const force = config.mode === "force";
if (verdict.isSlow || verdict.isMcdn || (force && isBiliCdnHost(url.hostname))) {
const target = selectTarget(config);
const rewritten = replaceHost(url, target);
return {
changed: rewritten !== original,
original,
url: rewritten,
reason: verdict.isPcdn ? "pcdn-host" : (verdict.isMcdn ? "mcdn-host" : "cdn-host"),
targetHost: target
};
}
return { changed: false, original, url: original, reason: "ok" };
}
function rewriteUrl(value, config) {
return rewriteUrlDetail(value, config).url;
}
// Build host-swapped alternatives of a media URL for DASH backupUrl fan-out.
// Returns rewritten URL strings for each candidate host except the current one.
function alternativesFor(value, rawConfig, hosts) {
const config = normalizeConfig(rawConfig);
const url = parseUrl(String(value || ""));
if (!url || !isMediaUrl(url) || isLiveMediaUrl(url)) {
return [];
}
const pool = (hosts && hosts.length ? hosts : config.candidatePool) || [];
const current = url.hostname.toLowerCase();
const seen = {};
const out = [];
pool.forEach(function eachHost(host) {
const clean = cleanHost(host).toLowerCase();
if (!clean || clean === current || seen[clean]) {
return;
}
seen[clean] = true;
out.push(replaceHost(url, host));
});
return out;
}
// Convert a transferred byte count over a duration into megabits per second.
// Pure so the speed meter's math stays unit-tested.
function throughputMbps(bytes, durationMs) {
if (!(bytes > 0) || !(durationMs > 0)) {
return 0;
}
return (bytes * 8 / 1e6) / (durationMs / 1000);
}
// Total length of a set of [start, end] intervals with overlaps merged, so
// two segments downloaded in parallel count their shared time only once.
function unionDurationMs(intervals) {
if (!intervals || !intervals.length) {
return 0;
}
const sorted = intervals.slice().sort(function byStart(a, b) {
return a[0] - b[0];
});
let total = 0;
let curStart = sorted[0][0];
let curEnd = sorted[0][1];
for (let i = 1; i < sorted.length; i += 1) {
const s = sorted[i][0];
const e = sorted[i][1];
if (s > curEnd) {
total += curEnd - curStart;
curStart = s;
curEnd = e;
} else if (e > curEnd) {
curEnd = e;
}
}
total += curEnd - curStart;
return total;
}
// Aggregate "active" throughput: bytes moved per second of time actually spent
// transferring, measured over the trailing `windowMs`. Unlike dividing by
// wall-clock, idle gaps between the player's burst downloads don't drag the
// rate to zero — this reflects the link's real capacity. Bytes from transfers
// straddling the window edge are prorated to the in-window fraction, and the
// active time is the union of all transfer intervals (parallel video+audio
// segments count their overlap once). transfers: [{ start, end, bytes }] in ms.
function aggregateThroughput(transfers, now, windowMs) {
if (!transfers || !transfers.length || !(windowMs > 0)) {
return 0;
}
const windowStart = now - windowMs;
let bytes = 0;
const intervals = [];
for (let i = 0; i < transfers.length; i += 1) {
const tr = transfers[i];
if (!tr || !(tr.bytes > 0) || !(tr.end > tr.start)) {
continue;
}
const s = Math.max(tr.start, windowStart);
const e = Math.min(tr.end, now);
if (e <= s) {
continue;
}
bytes += tr.bytes * ((e - s) / (tr.end - tr.start));
intervals.push([s, e]);
}
return throughputMbps(bytes, unionDurationMs(intervals));
}
// Bare host[:port] of a URL, for diagnostics that must never carry the query
// string — segment URLs pack the viewer's mid, buvid, IP-derived oi and signed
// access tokens there, and the report is meant to be shared. "" on failure.
function hostOf(value) {
try {
return new URL(String(value)).host;
} catch (_) {
return "";
}
}
// Pure ranking of probed hosts. samples: [{host, ttfb:number|null, ok:bool}].
// Healthy hosts first (lowest TTFB wins); failures sink to the bottom.
function rankHosts(samples) {
return (samples || [])
.slice()
.sort(function compare(a, b) {
const aOk = a.ok && typeof a.ttfb === "number";
const bOk = b.ok && typeof b.ttfb === "number";
if (aOk !== bOk) {
return aOk ? -1 : 1;
}
if (aOk && bOk) {
return a.ttfb - b.ttfb;
}
return 0;
})
.map(function pickHost(sample) {
return cleanHost(sample.host);
});
}
function rewriteObject(value, rawConfig, state, depth, seen) {
const config = normalizeConfig(rawConfig);
const tracker = state || { changed: false, rewrites: [] };
const level = depth || 0;
const visited = seen || new WeakSet();
if (!config.enabled || value == null || level > config.maxDepth) {
return value;
}
if (typeof value === "string") {
const detail = rewriteUrlDetail(value, config);
if (detail.changed) {
tracker.changed = true;
tracker.rewrites.push(detail);
}
return detail.url;
}
if (typeof value !== "object") {
return value;
}
if (visited.has(value)) {
return value;
}
visited.add(value);
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index += 1) {
value[index] = rewriteObject(value[index], config, tracker, level + 1, visited);
}
return value;
}
for (const key of Object.keys(value)) {
value[key] = rewriteObject(value[key], config, tracker, level + 1, visited);
}
return value;
}
// Live playurl payloads (getRoomPlayInfo) don't carry full media URLs — each
// stream/format/codec entry lists candidate hosts in url_info: [{host, extra}].
// A host like "https://xy…xy.mcdn.bilivideo.cn:486" is a residential PCDN node;
// slow for overseas viewers. This decides "is this live host slow" from the
// same behavioral signals as classify(), minus the media-path requirement.
function isSlowLiveHost(hostValue, extra, rawConfig) {
const config = normalizeConfig(rawConfig);
const raw = String(hostValue || "");
let url;
try {
url = new URL(raw.indexOf("://") !== -1 ? raw : "https://" + raw.replace(/^\/\//, ""));
} catch (_) {
return false;
}
const hostname = url.hostname.toLowerCase();
if (IP_RE.test(hostname) || XY_MCDN_RE.test(hostname) ||
isMcdnHost(hostname) || isKnownP2pHost(hostname)) {
return true;
}
if (config.portHeuristic && hasNonDefaultPort(url)) {
return true;
}
return typeof extra === "string" && /(?:^|[?&])os=mcdn(?:&|$)/i.test(extra);
}
// Drop PCDN/MCDN entries from live url_info host lists, keeping the official
// CDN entries the player can fail over to. Never removes the last usable host:
// if every entry looks slow, the list is left untouched. Returns rewrite-shaped
// entries ({original, url, reason}) so callers can log them like URL rewrites.
function filterLiveUrlInfo(payload, rawConfig, depth, seen) {
const config = normalizeConfig(rawConfig);
const level = depth || 0;
const visited = seen || new WeakSet();
const result = { changed: false, rewrites: [] };
if (!config.enabled || config.mode === "off" ||
payload == null || typeof payload !== "object" ||
level > config.maxDepth || visited.has(payload)) {
return result;
}
visited.add(payload);
const list = payload.url_info;
if (Array.isArray(list) && list.length > 1 &&
list.every(function (item) { return item && typeof item.host === "string"; })) {
const kept = list.filter(function (item) {
return !isSlowLiveHost(item.host, item.extra, config);
});
if (kept.length > 0 && kept.length < list.length) {
list.forEach(function (item) {
if (kept.indexOf(item) === -1) {
result.rewrites.push({
changed: true,
original: item.host,
url: kept[0].host,
reason: "live-pcdn-filter"
});
}
});
list.length = 0;
kept.forEach(function (item) { list.push(item); });
result.changed = true;
}
}
const keys = Array.isArray(payload)
? payload.map(function (_, i) { return i; })
: Object.keys(payload);
for (let i = 0; i < keys.length; i += 1) {
const child = filterLiveUrlInfo(payload[keys[i]], config, level + 1, visited);
if (child.changed) {
result.changed = true;
result.rewrites = result.rewrites.concat(child.rewrites);
}
}
return result;
}
function rewriteJsonText(text, rawConfig) {
const state = { changed: false, rewrites: [] };
const parsed = JSON.parse(text);
rewriteObject(parsed, rawConfig, state);
return {
changed: state.changed,
text: state.changed ? JSON.stringify(parsed) : text,
value: parsed,
rewrites: state.rewrites
};
}
return {
SCHEMA_VERSION,
CDN_HOSTS,
CANDIDATE_POOL,
ACCENT_KEYS,
THEME_MODES,
DEFAULT_CONFIG,
normalizeConfig,
hasMediaSignal: hasBiliMediaSignal,
classify,
isSlowLiveHost,
filterLiveUrlInfo,
selectTarget,
alternativesFor,
throughputMbps,
unionDurationMs,
aggregateThroughput,
hostOf,
rankHosts,
rewriteJsonText,
rewriteObject,
rewriteUrl,
rewriteUrlDetail
};
});
(function installBiliAccelerator(root) {
"use strict";
const core = root.BiliAcceleratorCore;
if (!core || root.__BILI_ACCELERATOR_INSTALLED__) {
return;
}
root.__BILI_ACCELERATOR_INSTALLED__ = true;
const VERSION = "0.3.0";
const STORAGE_KEY = "biliAccelerator.config.v2";
const LEGACY_KEY = "biliAccelerator.config.v1";
const RANK_PREFIX = "biliAccelerator.rank.";
const RANK_TTL_MS = 6 * 60 * 60 * 1000;
const BUTTON_ID = "bili-accelerator-button";
const PANEL_ID = "bili-accelerator-panel";
const IMMERSED_CLASS = "ba-immersed";
const LIFTED_CLASS = "ba-lifted";
const REVEAL_HOTZONE = 150;
const REVEAL_TIMEOUT = 2600;
const STALL_GRACE_MS = 2500;
const STALL_RETRY_MS = 5000;
const PROBE_TIMEOUT_MS = 4000;
const nativeJsonParse = JSON.parse;
const nativeFetch = root.fetch;
const NativeXHR = root.XMLHttpRequest;
let immersive = false;
let revealTimer = null;
let playerObserver = null;
let observedContainer = null;
let probed = false;
let watchedVideo = null;
let stallTimer = null;
const recovery = { avoidHost: null, clearTimer: null };
const state = {
rewrites: [],
rewriteCount: 0,
lastSource: "",
lastMediaHost: null,
status: "idle",
stalls: 0,
recoveries: 0,
p2pBlocked: 0,
ranking: [],
probedAt: null,
installedAt: new Date().toISOString()
};
// ---- config -------------------------------------------------------------
function loadConfig() {
try {
const stored = root.localStorage.getItem(STORAGE_KEY) ||
root.localStorage.getItem(LEGACY_KEY);
return core.normalizeConfig(stored ? JSON.parse(stored) : null);
} catch (_) {
return core.normalizeConfig();
}
}
let config = loadConfig();
function saveConfig(nextConfig) {
config = core.normalizeConfig(nextConfig);
try {
root.localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch (_) {
// storage may be unavailable; in-memory config still applies.
}
}
// ---- panel appearance ---------------------------------------------------
// Accent presets keyed by core.ACCENT_KEYS. `hex` is the primary accent,
// `strong` the pressed/hover shade, `gradA`/`gradB` the ⚡ toggle gradient.
const ACCENT_PRESETS = {
bili: { hex: "#00aeec", strong: "#0091cc", gradA: "#00b5f5", gradB: "#0091cc" },
teal: { hex: "#0d9488", strong: "#0b7d73", gradA: "#14b8a6", gradB: "#0b7d73" },
emerald: { hex: "#10b981", strong: "#0e9d6e", gradA: "#25c894", gradB: "#0e9d6e" },
violet: { hex: "#7c5cff", strong: "#6544e0", gradA: "#8f74ff", gradB: "#6544e0" },
pink: { hex: "#fb7299", strong: "#e85d86", gradA: "#ff86ab", gradB: "#e85d86" },
sunset: { hex: "#f97316", strong: "#db5f0c", gradA: "#ff8a3d", gradB: "#db5f0c" },
graphite: { hex: "#46566a", strong: "#33404f", gradA: "#556579", gradB: "#33404f" }
};
// Header theme-toggle glyphs; swapped by updateAppearanceControls per resolved theme.
const SUN_SVG = "";
const MOON_SVG = "";
// Surface tokens per resolved theme; the accent tokens are layered on top.
const SURFACES = {
light: {
"--ba-surface": "rgba(255,255,255,.97)", "--ba-card": "#ffffff",
"--ba-border": "#e5eaf0", "--ba-border-in": "#d5dde5",
"--ba-ink": "#17202a", "--ba-ink-strong": "#111827", "--ba-ink-mid": "#46515c",
"--ba-ink-soft": "#6b7785", "--ba-ink-faint": "#8a95a1",
"--ba-dot-bg": "#eef2f6", "--ba-dot": "#9aa6b2",
"--ba-good-bg": "#e6f8ee", "--ba-good": "#19a974",
"--ba-warn-bg": "#fff4e0", "--ba-warn": "#e8910c",
"--ba-slider-off": "#c9d3dd", "--ba-panel-shadow": "rgba(21,32,43,.24)"
},
dark: {
"--ba-surface": "rgba(22,26,32,.975)", "--ba-card": "#1c222b",
"--ba-border": "#2b323d", "--ba-border-in": "#38414d",
"--ba-ink": "#e8edf2", "--ba-ink-strong": "#f4f7fa", "--ba-ink-mid": "#b9c3ce",
"--ba-ink-soft": "#93a0ac", "--ba-ink-faint": "#6f7b87",
"--ba-dot-bg": "#262d37", "--ba-dot": "#6f7b87",
"--ba-good-bg": "rgba(25,169,116,.16)", "--ba-good": "#2ed3a0",
"--ba-warn-bg": "rgba(232,145,12,.16)", "--ba-warn": "#f0a838",
"--ba-slider-off": "#3a434f", "--ba-panel-shadow": "rgba(0,0,0,.5)"
}
};
// The speed canvas can't cheaply read CSS vars per frame, so applyTheme caches
// the values it needs: accent for the line/fill, card for the endpoint halo.
const speedPaint = { accent: "#00aeec", accentRgb: "0,174,236", card: "#ffffff" };
function resolveTheme() {
if (config.theme === "light" || config.theme === "dark") {
return config.theme;
}
try {
return root.matchMedia && root.matchMedia("(prefers-color-scheme: dark)").matches
? "dark" : "light";
} catch (_) {
return "light";
}
}
function hexToRgb(hex) {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255].join(",");
}
// Push the resolved accent + surface onto the shadow host as CSS custom
// properties; the panel styles read them through var(). Custom properties
// inherit across the shadow boundary, so setting them on the host is enough.
function applyTheme() {
const host = document.getElementById(BUTTON_ID);
if (!host) {
return;
}
const accent = ACCENT_PRESETS[config.accent] || ACCENT_PRESETS.bili;
const surface = SURFACES[resolveTheme()] || SURFACES.light;
Object.keys(surface).forEach(function (name) {
host.style.setProperty(name, surface[name]);
});
const rgb = hexToRgb(accent.hex);
host.style.setProperty("--ba-accent", accent.hex);
host.style.setProperty("--ba-accent-strong", accent.strong);
host.style.setProperty("--ba-grad-a", accent.gradA);
host.style.setProperty("--ba-grad-b", accent.gradB);
host.style.setProperty("--ba-accent-shadow", "rgba(" + rgb + ",.42)");
speedPaint.accent = accent.hex;
speedPaint.accentRgb = rgb;
speedPaint.card = surface["--ba-card"];
drawSpeed();
updateAppearanceControls();
}
function watchSystemTheme() {
try {
const mq = root.matchMedia("(prefers-color-scheme: dark)");
const onChange = function () {
if (config.theme === "system") {
applyTheme();
}
};
if (typeof mq.addEventListener === "function") {
mq.addEventListener("change", onChange);
} else if (typeof mq.addListener === "function") {
mq.addListener(onChange);
}
} catch (_) {
// matchMedia unavailable — system theme just won't live-update.
}
}
// A two-option sliding toggle (the header language + theme pills share it).
// Both cells are equal width, so the 50%-wide thumb slides exactly one cell.
// Mounts with the active index already set, so it never animates on first
// paint — only later taps slide. options: [{ html, value, label }].
function createSegToggle(id, options, activeIndex, onSelect) {
const seg = document.createElement("div");
seg.className = "ba-seg";
seg.id = id;
seg.dataset.active = String(activeIndex);
const thumb = document.createElement("span");
thumb.className = "ba-seg-thumb";
seg.appendChild(thumb);
options.forEach(function (opt, i) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "ba-seg-opt";
btn.innerHTML = opt.html;
btn.setAttribute("aria-pressed", i === activeIndex ? "true" : "false");
if (opt.label) {
btn.title = opt.label;
btn.setAttribute("aria-label", opt.label);
}
btn.addEventListener("click", function () { onSelect(opt.value, i); });
seg.appendChild(btn);
});
return seg;
}
function setSegActive(seg, index) {
if (!seg) {
return;
}
seg.dataset.active = String(index);
seg.querySelectorAll(".ba-seg-opt").forEach(function (opt, i) {
opt.setAttribute("aria-pressed", i === index ? "true" : "false");
});
}
function regionKey() {
try {
return (Intl.DateTimeFormat().resolvedOptions().timeZone || "unknown") +
"|" + (root.navigator && root.navigator.language || "");
} catch (_) {
return "unknown";
}
}
function loadRanking() {
try {
const raw = root.localStorage.getItem(RANK_PREFIX + regionKey());
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.ranking) || !parsed.at) {
return null;
}
if (Date.now() - parsed.at > RANK_TTL_MS) {
return null;
}
return parsed.ranking;
} catch (_) {
return null;
}
}
function saveRanking(ranking) {
try {
root.localStorage.setItem(RANK_PREFIX + regionKey(),
JSON.stringify({ ranking, at: Date.now() }));
} catch (_) {
// best-effort cache only.
}
}
// Apply a learned ranking by pointing the active target at the best host.
function applyRanking(ranking) {
if (!ranking || !ranking.length || config.selection !== "auto") {
return;
}
state.ranking = ranking;
config.pcdnHost = ranking[0];
}
// ---- rewrite plumbing ---------------------------------------------------
function record(rewrites, source) {
if (!rewrites || rewrites.length === 0) {
return;
}
state.lastSource = source;
state.rewriteCount += rewrites.length;
state.rewrites = state.rewrites.concat(rewrites.map(function mapRewrite(item) {
// Keep only bare host + reason — never the full media URL. Segment URLs
// carry the viewer's mid, buvid, IP-derived oi and signed tokens, and the
// diagnostics report is built to be pasted into public issues. Redacting
// here (not just at display) means those tokens never persist in memory.
return {
at: new Date().toISOString(),
source,
reason: item.reason,
fromHost: core.hostOf(item.original),
toHost: core.hostOf(item.url)
};
})).slice(-50);
if (state.status === "idle") {
state.status = "smooth";
}
renderStatus();
}
function rememberSample(payload) {
if (probed || config.selection !== "auto") {
return;
}
const sample = findMediaUrl(payload, 0, new WeakSet());
if (sample) {
scheduleProbe(sample);
}
}
function findMediaUrl(value, depth, seen) {
if (value == null || depth > config.maxDepth) {
return null;
}
if (typeof value === "string") {
return /\.(m4s|mp4|flv|m3u8)(?:$|[?#])/i.test(value) && core.hasMediaSignal(value)
? value
: null;
}
if (typeof value !== "object" || seen.has(value)) {
return null;
}
seen.add(value);
const keys = Array.isArray(value) ? value.map(function (_, i) { return i; }) : Object.keys(value);
for (let i = 0; i < keys.length; i += 1) {
const found = findMediaUrl(value[keys[i]], depth + 1, seen);
if (found) {
return found;
}
}
return null;
}
function backupPool() {
return state.ranking.length ? state.ranking : config.candidatePool;
}
// Merge host-swapped alternatives of `base` into entry[key], deduped, max 8.
function mergeBackups(entry, key, base) {
const alts = core.alternativesFor(base, config, backupPool());
if (!alts.length) {
return;
}
const existing = Array.isArray(entry[key]) ? entry[key] : [];
const merged = alts.concat(existing).filter(function uniq(u, i, arr) {
return arr.indexOf(u) === i;
});
entry[key] = merged.slice(0, 8);
}
// Add host-swapped alternatives to DASH/durl entries so Bilibili's own
// backup-URL failover can recover for free if the primary host stalls.
// Payload shapes: data.dash (web player), result.video_info.dash (bangumi),
// result.dash, bare dash; durl carries the legacy FLV/MP4 lists. Web-API DASH
// uses camelCase (baseUrl/backupUrl); app-style payloads and durl use
// snake_case (base_url/backup_url).
function enrichBackups(payload) {
if (config.selection !== "auto" || !payload || typeof payload !== "object") {
return;
}
const containers = [
payload.data,
payload.result,
payload.result && payload.result.video_info,
payload
];
containers.forEach(function eachContainer(container) {
if (!container || typeof container !== "object") {
return;
}
enrichDash(container.dash);
enrichDurl(container.durl);
});
}
function enrichDash(dash) {
if (!dash || typeof dash !== "object") {
return;
}
["video", "audio"].forEach(function eachKind(kind) {
const list = dash[kind];
if (!Array.isArray(list)) {
return;
}
list.forEach(function eachEntry(entry) {
if (!entry) {
return;
}
if (typeof entry.baseUrl === "string") {
mergeBackups(entry, "backupUrl", entry.baseUrl);
}
if (typeof entry.base_url === "string") {
mergeBackups(entry, "backup_url", entry.base_url);
}
});
});
}
function enrichDurl(durl) {
if (!Array.isArray(durl)) {
return;
}
durl.forEach(function eachEntry(entry) {
if (entry && typeof entry.url === "string") {
mergeBackups(entry, "backup_url", entry.url);
}
});
}
function rewritePayload(payload, source) {
const tracker = { changed: false, rewrites: [] };
try {
const rewritten = core.rewriteObject(payload, config, tracker);
enrichBackups(rewritten);
record(tracker.rewrites, source);
filterLivePcdn(rewritten, source);
rememberSample(rewritten);
return rewritten;
} catch (error) {
console.warn("[BiliAccelerator] rewrite failed", error);
return payload;
}
}
// Live playurl payloads list candidate hosts (url_info) instead of full URLs;
// drop the PCDN/MCDN entries so the live player only ever dials official CDN.
function filterLivePcdn(payload, source) {
try {
const filtered = core.filterLiveUrlInfo(payload, config);
if (filtered.changed) {
record(filtered.rewrites, source);
}
} catch (_) {
// never let live filtering break payload delivery.
}
}
// Quick check on a response body: does it plausibly carry media URLs?
// Broader than "bilivideo" so renamed PCDN payloads aren't skipped.
function bodyHasSignal(text) {
return typeof text === "string" &&
(text.indexOf("bilivideo") !== -1 ||
text.indexOf("mcdn") !== -1 ||
text.indexOf("upgcxcode") !== -1 ||
text.indexOf("os=mcdn") !== -1 ||
text.indexOf("akamaized") !== -1);
}
// Rewrite a single outgoing request URL (segment fetches, live failover).
function rewriteRequestUrl(rawUrl) {
if (!core.hasMediaSignal(rawUrl)) {
return rawUrl;
}
try {
let host = "";
try {
host = new URL(rawUrl, root.location.href).hostname;
} catch (_) {
host = "";
}
// During recovery, force-redirect away from the stalling host even if it
// would normally be considered healthy.
const cfg = (recovery.avoidHost && host === recovery.avoidHost)
? Object.assign({}, config, { mode: "force" })
: config;
const detail = core.rewriteUrlDetail(rawUrl, cfg);
// Track which host actually serves the media: the