// ==UserScript==
// @name B站推流码获取工具
// @namespace https://github.com/smathsp
// @version 1.5
// @description 获取第三方推流码
// @author smathsp
// @license GPL-3.0
// @match *://*.bilibili.com/*
// @icon https://www.bilibili.com/favicon.ico
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_setClipboard
// @grant GM_notification
// @connect api.live.bilibili.com
// @connect passport.bilibili.com
// @run-at document-end
// ==/UserScript==
(function () {
"use strict";
// 存储键名常量
const STORAGE_KEYS = {
LAST_ROOM_ID: "bili_last_roomid",
DARK_MODE: "bili_dark_mode",
IS_LIVE_STARTED: "isLiveStarted",
STREAM_INFO: "streamInfo",
LAST_GROUP_ID: "bili_last_groupid",
LAST_AREA_ID: "bili_last_areaid",
AREA_LIST_TIME: "bili_area_list_time",
AREA_LIST: "bili_area_list",
USER_MID: "bili_user_mid",
LAST_TITLE: "bili_last_title",
};
// API URL Constants
const API_URL_AREA_LIST =
"https://api.live.bilibili.com/room/v1/Area/getList?show_pinyin=1";
const API_URL_START_LIVE =
"https://api.live.bilibili.com/room/v1/Room/startLive";
const API_URL_UPDATE_ROOM =
"https://api.live.bilibili.com/room/v1/Room/update";
const API_URL_STOP_LIVE =
"https://api.live.bilibili.com/room/v1/Room/stopLive";
// 示例:将 GM_xmlhttpRequest Promise 化
function gmRequest(options) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
...options,
onload: resolve,
onerror: reject, // GM_xmlhttpRequest 的 onerror 通常也传递 response 对象
ontimeout: reject, // 同样
onabort: reject, // 同样
});
});
}
// SVG 图标常量
const SUN_SVG =
'<svg viewBox="0 0 24 24" width="20" height="20"><circle cx="12" cy="12" r="5" fill="#FFD600"/><g stroke="#FFD600" stroke-width="2"><line x1="12" y1="1" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="6.34" y2="6.34"/><line x1="17.66" y1="17.66" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="6.34" y2="17.66"/><line x1="17.66" y1="6.34" x2="19.78" y2="4.22"/></g></svg>';
const MOON_SVG =
'<svg viewBox="0 0 24 24" width="20" height="20"><path d="M21 12.79A9 9 0 0 1 12.21 3c-.55 0-.66.71-.19.93A7 7 0 1 0 20.07 12c.22.47-.38.74-.87.79z" fill="#888"/></svg>';
const CLOSE_SVG =
'<svg viewBox="0 0 1024 1024" width="16" height="16"><path d="M512 421.49 331.09 240.58c-24.74-24.74-64.54-24.71-89.28 0.03-24.74 24.74-24.72 64.54 0.03 89.28L422.75 510.8 241.84 691.71c-24.74 24.74-24.72 64.54 0.03 89.33 24.74 24.74 64.54 24.71 89.28-0.03L512 600.1l180.91 180.91c24.74 24.74 64.54 24.71 89.28-0.03 24.74-24.74 24.72-64.54-0.03-89.28L601.25 510.8 782.16 329.89c24.74-24.74 24.72-64.54-0.03-89.33-24.74-24.74-64.54-24.71-89.28 0.03L512 421.49z" fill="#888888"></path></svg>';
// 插入全局样式表,统一亮暗色模式
function insertGlobalStyle() {
if (document.getElementById("bili-stream-global-style")) return;
const style = document.createElement("style");
style.id = "bili-stream-global-style";
style.innerHTML = `
:root {
--bili-bg: #fff;
--bili-fg: #222;
--bili-panel-shadow: 0 2px 10px rgba(0,0,0,0.1);
--bili-border: #eee;
--bili-input-bg: #fff;
--bili-input-fg: #222;
--bili-input-border: #ddd;
--bili-tip-bg: #fef0f1;
--bili-tip-fg: #d92b46;
--bili-tip-border: #fb7299;
--bili-btn-main: #fb7299;
--bili-btn-main-hover: #fc8bab;
--bili-btn-main-disabled: #bfbfbf;
--bili-btn-stop: #ff4b4b;
--bili-btn-stop-hover: #d9363e;
--bili-btn-stop-disabled: #999;
--bili-btn-text: #fff;
--bili-title-color: #fb7299;
--bili-label-color: #666;
--bili-tip-yellow-bg: #fffbe6;
--bili-tip-yellow-border: #faad14;
--bili-tip-yellow-fg: #faad14;
--bili-tip-green-bg: #e6ffed;
--bili-tip-green-border: #52c41a;
--bili-tip-green-fg: #389e0d;
}
.bili-dark-mode {
--bili-bg: #232324;
--bili-fg: #eee;
--bili-panel-shadow: 0 2px 10px rgba(0,0,0,0.6);
--bili-border: #444;
--bili-input-bg: #18181a;
--bili-input-fg: #eee;
--bili-input-border: #444;
--bili-tip-bg: #2d2326;
--bili-tip-fg: #ffb6c1;
--bili-tip-border: #fb7299;
--bili-btn-main: #fb7299;
--bili-btn-main-hover: #fc8bab;
--bili-btn-main-disabled: #bfbfbf;
--bili-btn-stop: #ff4b4b;
--bili-btn-stop-hover: #d9363e;
--bili-btn-stop-disabled: #999;
--bili-btn-text: #fff;
--bili-title-color: #fb7299;
--bili-label-color: #aaa;
--bili-tip-yellow-bg: #3a2d1a;
--bili-tip-yellow-border: #faad14;
--bili-tip-yellow-fg: #ffd666;
--bili-tip-green-bg: #1e2b22;
--bili-tip-green-border: #52c41a;
--bili-tip-green-fg: #b7eb8f;
}
#bili-stream-code-panel {
background-color: var(--bili-bg) !important;
color: var(--bili-fg) !important;
box-shadow: var(--bili-panel-shadow) !important;
border-radius: 8px;
padding: 15px;
font-family: "Microsoft YaHei", sans-serif;
}
#bili-result {
background-color: var(--bili-bg) !important;
color: var(--bili-fg) !important;
border: 1px solid var(--bili-border) !important;
border-radius: 4px;
margin-top: 15px;
padding: 10px;
}
.bili-input {
background: var(--bili-input-bg) !important;
color: var(--bili-input-fg) !important;
border: 1px solid var(--bili-input-border) !important;
border-radius: 4px;
padding: 8px;
font-size: 14px;
}
.bili-select {
background: var(--bili-input-bg) !important;
color: var(--bili-input-fg) !important;
border: 1px solid var(--bili-input-border) !important;
border-radius: 4px;
padding: 8px;
font-size: 14px;
}
#bili-room-id, #bili-title, #server-addr, #stream-code {
background: var(--bili-input-bg) !important;
color: var(--bili-input-fg) !important;
border: 1px solid var(--bili-input-border) !important;
border-radius: 4px;
padding: 8px;
font-size: 14px;
}
#bili-area-group, #bili-area {
background: var(--bili-input-bg) !important;
color: var(--bili-input-fg) !important;
border: 1px solid var(--bili-input-border) !important;
border-radius: 4px;
padding: 8px;
font-size: 14px;
}
.bili-important-tip {
background-color: var(--bili-tip-bg) !important;
color: var(--bili-tip-fg) !important;
border-left: 4px solid var(--bili-tip-border) !important;
border-radius: 4px;
margin-top: 8px;
padding: 8px;
}
.bili-tip-yellow {
background: var(--bili-tip-yellow-bg);
border-left: 4px solid var(--bili-tip-yellow-border);
color: var(--bili-tip-yellow-fg);
border-radius: 4px;
margin-top: 8px;
padding: 8px;
}
.bili-tip-green {
background: var(--bili-tip-green-bg);
border-left: 4px solid var(--bili-tip-green-border);
color: var(--bili-tip-green-fg);
border-radius: 4px;
margin-top: 8px;
padding: 8px;
}
.bili-btn-main {
background: var(--bili-btn-main);
color: var(--bili-btn-text);
border: none;
border-radius: 4px;
padding: 10px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s, opacity 0.3s;
}
.bili-btn-main:hover:not(:disabled) {
background: var(--bili-btn-main-hover);
}
.bili-btn-main:disabled {
background: var(--bili-btn-main-disabled);
opacity: 0.5;
cursor: not-allowed;
}
.bili-btn-stop {
background: var(--bili-btn-stop);
color: var(--bili-btn-text);
border: none;
border-radius: 4px;
padding: 10px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s, opacity 0.3s;
}
.bili-btn-stop:hover:not(:disabled) {
background: var(--bili-btn-stop-hover);
}
.bili-btn-stop:disabled {
background: var(--bili-btn-stop-disabled);
opacity: 0.5;
cursor: not-allowed;
}
.bili-title {
color: var(--bili-title-color);
font-size: 18px;
margin: 0;
}
.bili-label {
color: var(--bili-label-color);
font-size: 14px;
}
.bili-copy-btn {
margin-left: 5px;
background: var(--bili-btn-main);
color: var(--bili-btn-text);
border: none;
border-radius: 4px;
padding: 8px;
cursor: pointer;
transition: background 0.3s;
}
.bili-copy-btn:disabled {
background: var(--bili-btn-main-disabled);
cursor: not-allowed;
}
.bili-copy-btn:hover:not(:disabled) {
background: var(--bili-btn-main-hover);
}
.bili-message {
color: var(--bili-fg);
font-size: 15px;
margin: 0;
}
.bili-message-error {
color: red;
}
`;
document.head.appendChild(style);
}
// 全局变量
let roomId = null; // 当前房间ID
let csrf = null; // CSRF令牌
let startLiveButton = null; // “开始直播”按钮引用
let stopLiveButton = null; // “结束直播”按钮引用
let isLiveStarted = GM_getValue(STORAGE_KEYS.IS_LIVE_STARTED, false); // 直播状态
let streamInfo = GM_getValue(STORAGE_KEYS.STREAM_INFO, null); // 推流信息缓存
// 请求头
const headers = {
accept: "application/json, text/plain, */*",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
origin: "https://link.bilibili.com",
referer: "https://link.bilibili.com/p/center/index",
"user-agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
};
// 开始直播数据模板
const startData = {
room_id: "",
platform: "android_link",
area_v2: "",
backup_stream: "0",
csrf_token: "",
csrf: "",
};
// 停止直播数据模板
const stopData = {
room_id: "",
platform: "android_link",
csrf_token: "",
csrf: "",
};
// 修改直播标题数据模板
const titleData = {
room_id: "",
platform: "android_link",
title: "",
csrf_token: "",
csrf: "",
};
// 初始化入口
function init() {
try {
insertGlobalStyle(); // 插入全局样式
removeExistingComponents(); // 清理旧组件
createUI(); // 创建UI(只创建一次主面板)
restoreLiveState(); // 恢复直播状态
// setInterval(checkFloatButton, 5000); // 定期检查浮动按钮 - REMOVED
} catch (error) {
console.error("B站推流码获取工具初始化失败:", error);
}
}
// 移除已存在的组件
function removeExistingComponents() {
const existingPanel = document.getElementById("bili-stream-code-panel");
if (existingPanel) existingPanel.remove();
const existingButton = document.getElementById("bili-stream-float-button");
if (existingButton) existingButton.remove();
// 清空按钮引用,防止旧引用干扰
startLiveButton = null;
stopLiveButton = null;
}
// 创建UI(只创建一次主面板)
function createUI() {
// 若主面板已存在则不再重复创建
if (!document.getElementById("bili-stream-code-panel")) {
const panel = createPanel();
panel.style.display = "none";
}
// 浮动按钮可重复创建(防止丢失)
createFloatButton();
// 自动填充房间ID的逻辑已移至 createPanelForm
}
// 创建面板
function createPanel() {
const panel = document.createElement("div");
panel.id = "bili-stream-code-panel";
panel.style.cssText = `
position: fixed;
top: 70px;
right: 10px;
width: 300px;
z-index: 10000;
display: none;
`;
// 头部区域
const header = createPanelHeader();
panel.appendChild(header);
// 表单区域
const form = createPanelForm();
panel.appendChild(form);
// 结果区域
const resultArea = document.createElement("div");
resultArea.id = "bili-result";
resultArea.style.cssText = `
margin-top: 15px;
padding: 10px;
border: 1px solid #eee;
border-radius: 4px;
background-color: #f9f9f9;
display: none;
`;
panel.appendChild(resultArea);
document.body.appendChild(panel);
// 缓存常用元素引用
return panel;
}
// 创建面板头部
function createPanelHeader() {
const header = document.createElement("div");
header.style.cssText =
"display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;";
// 标题
const title = document.createElement("h2");
title.textContent = "B站推流码获取工具";
title.className = "bili-title";
// 亮暗模式切换按钮
const modeBtn = document.createElement("button");
modeBtn.id = "bili-mode-toggle";
modeBtn.style.cssText =
"width: 28px; height: 28px; border: none; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; margin-left: 8px;";
// SVG 图标
let isDarkMode = GM_getValue(STORAGE_KEYS.DARK_MODE, false);
modeBtn.innerHTML = isDarkMode ? MOON_SVG : SUN_SVG;
modeBtn.title = isDarkMode ? "切换为亮色模式" : "切换为暗色模式";
modeBtn.onclick = function () {
isDarkMode = !isDarkMode;
GM_setValue(STORAGE_KEYS.DARK_MODE, isDarkMode);
modeBtn.innerHTML = isDarkMode ? MOON_SVG : SUN_SVG;
modeBtn.title = isDarkMode ? "切换为亮色模式" : "切换为暗色模式";
applyColorMode(isDarkMode);
};
// 首次渲染时应用模式
setTimeout(() => applyColorMode(isDarkMode), 0);
// 关闭按钮
const closeButton = document.createElement("button");
closeButton.innerHTML = CLOSE_SVG;
closeButton.style.cssText =
"width: 24px; height: 24px; border: none; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center;";
closeButton.onclick = () => {
document.getElementById("bili-stream-code-panel").style.display = "none";
};
// 头部右侧按钮组
const rightBtns = document.createElement("div");
rightBtns.style.cssText = "display: flex; align-items: center; gap: 4px;";
rightBtns.appendChild(modeBtn);
rightBtns.appendChild(closeButton);
header.appendChild(title);
header.appendChild(rightBtns);
return header;
}
// 亮暗模式应用函数
function applyColorMode(isDark) {
// 只切换 class,不再手动设置 style
const root = document.documentElement;
if (isDark) {
root.classList.add("bili-dark-mode");
} else {
root.classList.remove("bili-dark-mode");
}
}
// 创建面板表单
function createPanelForm() {
const form = document.createElement("div");
form.style.cssText = "display: flex; flex-direction: column; gap: 10px;";
// 房间ID输入
form.appendChild(createRoomIdInput());
// 分区选择
const areaSelectionElement = createAreaSelection();
form.appendChild(areaSelectionElement);
if (areaSelectionElement.loadAndBindAreaListPromise) {
areaSelectionElement.loadAndBindAreaListPromise
.then(() => {
autoFillRoomId();
})
.catch((error) => {
console.error(
"Error during area list loading, or in autoFillRoomId:",
error
);
// Fallback if promise rejects
setTimeout(autoFillRoomId, 300);
});
} else {
// Fallback if the promise wasn't attached
console.warn(
"loadAndBindAreaListPromise not found, falling back for autoFillRoomId."
);
setTimeout(autoFillRoomId, 300);
}
// 标题输入
form.appendChild(createTitleInput());
// 按钮组
form.appendChild(createButtonGroup());
return form;
}
// 创建房间ID输入
function createRoomIdInput() {
const container = document.createElement("div");
container.style.cssText =
"display: flex; flex-direction: column; gap: 5px;";
const label = document.createElement("label");
label.textContent = "房间ID (Room ID):";
label.className = "bili-label";
const input = document.createElement("input");
input.type = "text";
input.id = "bili-room-id";
input.placeholder = "请输入你的房间ID";
input.className = "bili-input";
// 新增:输入时保存
input.addEventListener("blur", function () {
GM_setValue(STORAGE_KEYS.LAST_ROOM_ID, input.value.trim());
});
container.appendChild(label);
container.appendChild(input);
return container;
}
// 创建分区选择
function createAreaSelection() {
const container = document.createElement("div");
container.id = "bili-area-selection-container"; // 新增ID,方便获取
container.style.cssText =
"display: flex; flex-direction: column; gap: 5px;";
const label = document.createElement("label");
label.textContent = "直播分区:";
label.className = "bili-label";
// 加载指示器
const loading = document.createElement("div");
loading.id = "bili-area-loading";
loading.textContent = "正在加载分区列表...";
loading.style.cssText =
"padding: 8px; color: #666; font-size: 14px; text-align: center; cursor: pointer;";
// 分区组选择器
const groupSelect = document.createElement("select");
groupSelect.id = "bili-area-group";
groupSelect.className = "bili-select";
groupSelect.style.cssText = "margin-bottom: 8px; display: none;";
// 子分区选择器
const areaSelect = document.createElement("select");
areaSelect.id = "bili-area";
areaSelect.className = "bili-select";
areaSelect.style.cssText = "display: none;";
// 统一事件绑定
groupSelect.addEventListener("change", function () {
const areaList = getCachedAreaList() || [];
const selectedIndex = this.options[this.selectedIndex].dataset.index;
GM_setValue(STORAGE_KEYS.LAST_GROUP_ID, groupSelect.value);
updateAreaSelectors(
areaList,
Number(selectedIndex),
groupSelect,
areaSelect
);
});
areaSelect.addEventListener("change", function () {
GM_setValue(STORAGE_KEYS.LAST_AREA_ID, areaSelect.value);
GM_setValue(STORAGE_KEYS.LAST_GROUP_ID, groupSelect.value);
});
loading.onclick = function () {
if (
loading.style.color === "rgb(255, 75, 75)" ||
loading.style.color === "#ff4b4b"
) {
loadAndBindAreaList();
}
};
container.appendChild(label);
container.appendChild(loading);
container.appendChild(groupSelect);
container.appendChild(areaSelect);
// 合并后的分区刷新函数
function updateAreaSelectors(
areaList,
groupIdx = 0,
groupSel = groupSelect,
areaSel = areaSelect
) {
groupSel.innerHTML = "";
areaSel.innerHTML = "";
areaList.forEach((group, idx) => {
const option = document.createElement("option");
option.value = group.id;
option.textContent = group.name;
option.dataset.index = idx;
groupSel.appendChild(option);
});
// 恢复上次大类
const lastGroupId = GM_getValue(STORAGE_KEYS.LAST_GROUP_ID);
if (lastGroupId) {
for (let i = 0; i < groupSel.options.length; i++) {
if (groupSel.options[i].value == lastGroupId) {
groupSel.selectedIndex = i;
groupIdx = i;
break;
}
}
}
if (areaList[groupIdx] && areaList[groupIdx].list) {
areaList[groupIdx].list.forEach((area) => {
const option = document.createElement("option");
option.value = area.id;
option.textContent = area.name;
areaSel.appendChild(option);
});
}
// 恢复上次分区id
const lastAreaId = GM_getValue(STORAGE_KEYS.LAST_AREA_ID);
if (lastAreaId && areaSel.options.length > 0) {
for (let i = 0; i < areaSel.options.length; i++) {
if (areaSel.options[i].value == lastAreaId) {
areaSel.selectedIndex = i;
break;
}
}
}
// 显示选择器
loading.style.display = "none";
groupSel.style.display = "block";
areaSel.style.display = "block";
}
// 加载分区数据
function loadAndBindAreaList() {
return new Promise(async (resolve, reject) => {
// Make the wrapping function async
// 返回 Promise
loading.style.display = "block";
groupSelect.style.display = "none";
areaSelect.style.display = "none";
loading.textContent = "正在加载分区列表...";
loading.style.color = "#666";
const cachedList = getCachedAreaList();
if (cachedList) {
updateAreaSelectors(cachedList, 0, groupSelect, areaSelect);
resolve(); // 解析 Promise
return;
}
try {
const response = await gmRequest({
// Use await with gmRequest
method: "GET",
url: API_URL_AREA_LIST,
headers: headers,
});
const result = JSON.parse(response.responseText);
if (result.code === 0) {
cacheAreaList(result.data);
updateAreaSelectors(result.data, 0, groupSelect, areaSelect);
resolve(); // 解析 Promise
} else {
console.error("Area list API error:", result);
showAreaLoadError();
reject(new Error("Failed to load area list")); // 拒绝 Promise
}
} catch (errorResponse) {
// Catch errors from gmRequest
console.error("Area list request error:", errorResponse);
showAreaLoadError();
reject(errorResponse); // 拒绝 Promise
}
});
}
// 将 Promise 附加到容器元素,以便在 createUI 中访问
container.loadAndBindAreaListPromise = loadAndBindAreaList();
return container;
}
// 创建标题输入
function createTitleInput() {
const container = document.createElement("div");
container.style.cssText =
"display: flex; flex-direction: column; gap: 5px;";
const label = document.createElement("label");
label.textContent = "直播标题:";
label.className = "bili-label";
const input = document.createElement("input");
input.type = "text";
input.id = "bili-title";
input.placeholder = "请输入直播标题";
input.className = "bili-input";
// 新增:输入时保存
input.addEventListener("blur", function () {
GM_setValue(STORAGE_KEYS.LAST_TITLE, input.value.trim());
});
container.appendChild(label);
container.appendChild(input);
return container;
}
// 创建按钮组
function createButtonGroup() {
const container = document.createElement("div");
container.style.cssText = "display: flex; gap: 10px; margin-top: 10px;";
// 开始直播按钮
startLiveButton = document.createElement("button");
startLiveButton.textContent = "获取推流码并开始直播";
startLiveButton.className = "bili-btn-main";
startLiveButton.style.flex = "1";
startLiveButton.onclick = startLive;
// 结束直播按钮
stopLiveButton = document.createElement("button");
stopLiveButton.textContent = "结束直播";
stopLiveButton.className = "bili-btn-stop";
stopLiveButton.style.flex = "1";
stopLiveButton.disabled = true;
stopLiveButton.onclick = stopLive;
container.appendChild(startLiveButton);
container.appendChild(stopLiveButton);
return container;
}
// 创建浮动按钮
function createFloatButton() {
const button = document.createElement("div");
button.id = "bili-stream-float-button";
button.innerHTML =
'<svg viewBox="0 0 1024 1024" width="24" height="24"><path d="M718.3 183.7H305.7c-122 0-221 99-221 221v214.6c0 122 99 221 221 221h412.6c122 0 221-99 221-221V404.7c0-122-99-221-221-221z m159.1 435.6c0 87.6-71.5 159.1-159.1 159.1H305.7c-87.6 0-159.1-71.5-159.1-159.1V404.7c0-87.6 71.5-159.1 159.1-159.1h412.6c87.6 0 159.1 71.5 159.1 159.1v214.6z" fill="#FFFFFF"></path><path d="M415.5 532.2v-131c0-7.1 3.8-13.6 10-17.1 6.2-3.5 13.7-3.5 19.9 0l131 75.1c6.2 3.5 10 10.1 10 17.1 0 7.1-3.8 13.6-10 17.1l-131 65.5c-6.2 3.5-13.7 3.5-19.9 0-6.2-3.5-10-10.1-10-17.1v-9.6z" fill="#FFFFFF"></path></svg>';
button.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
width: 48px;
height: 48px;
border-radius: 50%;
background-color: #fb7299;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
z-index: 10001;
transition: transform 0.3s;
`;
button.onmouseover = function () {
this.style.transform = "scale(1.1)";
};
button.onmouseout = function () {
this.style.transform = "scale(1)";
};
button.onclick = togglePanel;
document.body.appendChild(button);
return button;
}
// 显示/隐藏面板
function togglePanel() {
const panel = document.getElementById("bili-stream-code-panel");
if (!panel) return; // 理论上不会发生
panel.style.display =
panel.style.display === "none" || !panel.style.display ? "block" : "none";
}
// 检查浮动按钮
function checkFloatButton() {
if (!document.getElementById("bili-stream-float-button")) {
createFloatButton();
}
}
// 全局 loadAreaList 函数已移除,其功能由 createAreaSelection 内的 loadAndBindAreaList 处理
// 显示分区加载错误信息
function showAreaLoadError() {
const loading = document.getElementById("bili-area-loading");
if (loading) {
loading.textContent = "无法加载分区列表,请稍后刷新重试";
loading.style.color = "#ff4b4b";
}
// 显示通知
GM_notification({
text: "无法加载直播分区列表,请检查网络连接或登录状态",
title: "B站推流码获取工具",
timeout: 5000,
});
}
// 更新分区选择器
function updateAreaSelectors(areaList) {
const loading = document.getElementById("bili-area-loading");
const groupSelect = document.getElementById("bili-area-group");
const areaSelect = document.getElementById("bili-area");
// 防止 loading 取不到时报错
if (!loading || !groupSelect || !areaSelect) return;
// 隐藏加载提示
loading.style.display = "none";
// 显示选择器
groupSelect.style.display = "block";
areaSelect.style.display = "block";
// 清空选择器
groupSelect.innerHTML = "";
areaSelect.innerHTML = "";
// 添加分区大类
areaList.forEach((group, index) => {
const option = document.createElement("option");
option.value = group.id;
option.textContent = group.name;
option.dataset.index = index;
groupSelect.appendChild(option);
});
// 默认显示第一个分区大类的子分区
if (areaList.length > 0 && areaList[0].list) {
areaList[0].list.forEach((area) => {
const option = document.createElement("option");
option.value = area.id;
option.textContent = area.name;
areaSelect.appendChild(option);
});
}
// 分区大类变更事件
groupSelect.addEventListener("change", function () {
const selectedIndex = this.options[this.selectedIndex].dataset.index;
const selectedGroup = areaList[selectedIndex];
// 清空子分区
areaSelect.innerHTML = "";
if (selectedGroup && selectedGroup.list) {
selectedGroup.list.forEach((area) => {
const option = document.createElement("option");
option.value = area.id;
option.textContent = area.name;
areaSelect.appendChild(option);
});
}
});
}
// 获取缓存的分区列表
function getCachedAreaList() {
const timeStamp = GM_getValue(STORAGE_KEYS.AREA_LIST_TIME);
if (!timeStamp) return null;
const now = new Date().getTime();
const oneDay = 24 * 60 * 60 * 1000;
// 超过一天则认为过期
if (now - timeStamp > oneDay) return null;
const listStr = GM_getValue(STORAGE_KEYS.AREA_LIST);
if (!listStr) return null;
try {
return JSON.parse(listStr);
} catch (e) {
return null;
}
}
// 缓存分区列表
function cacheAreaList(areaList) {
GM_setValue(STORAGE_KEYS.AREA_LIST, JSON.stringify(areaList));
GM_setValue(STORAGE_KEYS.AREA_LIST_TIME, new Date().getTime());
}
// 拆分 autoFillRoomId 内部逻辑
function getRoomIdFromUrl() {
const urlMatch = window.location.href.match(/live\.bilibili\.com\/(\d+)/);
return urlMatch && urlMatch[1] ? urlMatch[1] : null;
}
function getRoomIdFromElement() {
const roomElement = document.querySelector(".room-info-anchor-name");
if (roomElement) {
const href = roomElement.getAttribute("href");
if (href) {
const match = href.match(/\/(\d+)/);
if (match && match[1]) {
return match[1];
}
}
}
return null;
}
function getRoomIdFromHistory() {
return GM_getValue(STORAGE_KEYS.LAST_ROOM_ID);
}
function getCsrfToken() {
const csrfCookie = document.cookie
.split("; ")
.find((row) => row.startsWith("bili_jct="));
return csrfCookie ? csrfCookie.split("=")[1] : null;
}
function autoFillRoomId() {
const lastRoomId = GM_getValue(STORAGE_KEYS.LAST_ROOM_ID);
const lastAreaId = GM_getValue(STORAGE_KEYS.LAST_AREA_ID);
const lastTitle = GM_getValue(STORAGE_KEYS.LAST_TITLE);
if (streamInfo && streamInfo.roomId) {
document.getElementById("bili-room-id").value = streamInfo.roomId;
roomId = streamInfo.roomId;
if (document.getElementById("bili-title") && streamInfo.title) {
document.getElementById("bili-title").value = streamInfo.title;
}
} else {
let foundRoomId = getRoomIdFromUrl() || getRoomIdFromElement();
if (!foundRoomId && window.location.href.includes("space.bilibili.com")) {
const midMatch = window.location.href.match(
/space\.bilibili\.com\/(\d+)/
);
if (midMatch && midMatch[1]) {
GM_setValue(STORAGE_KEYS.USER_MID, midMatch[1]);
}
}
if (!foundRoomId) {
foundRoomId = getRoomIdFromHistory();
}
if (foundRoomId) {
document.getElementById("bili-room-id").value = foundRoomId;
roomId = foundRoomId;
GM_setValue(STORAGE_KEYS.LAST_ROOM_ID, foundRoomId);
} else if (lastRoomId) {
document.getElementById("bili-room-id").value = lastRoomId;
roomId = lastRoomId;
}
}
if (document.getElementById("bili-title") && lastTitle) {
document.getElementById("bili-title").value = lastTitle;
}
// 移除 setTimeout,因为现在依赖 Promise
// setTimeout(() => {
if (lastAreaId) {
const areaSelect = document.getElementById("bili-area");
if (areaSelect) {
for (let i = 0; i < areaSelect.options.length; i++) {
if (areaSelect.options[i].value == lastAreaId) {
areaSelect.selectedIndex = i;
break;
}
}
}
}
// }, 500);
csrf = getCsrfToken();
}
// 恢复直播状态
function restoreLiveState() {
if (isLiveStarted && streamInfo) {
setTimeout(() => {
const panel = document.getElementById("bili-stream-code-panel");
if (panel) {
// 不再自动展开面板,只恢复按钮和推流信息
// panel.style.display = 'block';
// 更新按钮状态
updateButtonsForLive(true);
// 恢复推流信息
restoreStreamInfo();
}
}, 500);
}
}
// 推流信息区输入框和按钮也用 class
function restoreStreamInfo() {
if (!streamInfo) return;
const resultArea = document.getElementById("bili-result");
if (!resultArea) return;
const rtmpAddr = streamInfo.rtmpAddr;
const rtmpCode = streamInfo.rtmpCode;
const resultHTML = `
<div style="display: flex; flex-direction: column; gap: 8px;">
<h3 class="bili-title" style="font-size: 16px;">推流信息 (进行中)</h3>
<div>
<p style="margin: 0; font-weight: bold;">服务器地址:</p>
<div style="display: flex; align-items: center; margin-bottom: 8px;">
<input id="server-addr" readonly value="${rtmpAddr}" title="${rtmpAddr}" class="bili-input" />
<button id="copy-addr" class="bili-copy-btn">复制</button>
</div>
<p style="margin: 0; font-weight: bold;">推流码:</p>
<div style="display: flex; align-items: center;">
<input id="stream-code" readonly value="${rtmpCode}" title="${rtmpCode}" class="bili-input" />
<button id="copy-code" class="bili-copy-btn">复制</button>
</div>
</div>
<div class="bili-important-tip">
<p style="margin: 0; font-weight: bold;">重要提示:</p>
<p style="margin: 3px 0 0; font-size: 13px;">1. 长时间无信号会自动关闭直播</p>
<p style="margin: 3px 0 0; font-size: 13px;">2. 推流码如果变动会有提示</p>
</div>
</div>
`;
resultArea.innerHTML = resultHTML;
resultArea.style.display = "block";
// 添加复制按钮事件
const copyAddrBtn = document.getElementById("copy-addr");
if (copyAddrBtn) {
copyAddrBtn.addEventListener("click", function () {
copyToClipboardWithButton(rtmpAddr, copyAddrBtn);
});
}
const copyCodeBtn = document.getElementById("copy-code");
if (copyCodeBtn) {
copyCodeBtn.addEventListener("click", function () {
copyToClipboardWithButton(rtmpCode, copyCodeBtn);
});
}
// 新增:推流信息区插入后,重新应用颜色模式
const isDarkMode = GM_getValue("bili_dark_mode", false);
applyColorMode(isDarkMode);
}
// 更新按钮状态(开始/结束直播)
function updateButtonsForLive(isLive) {
if (startLiveButton) {
startLiveButton.disabled = isLive;
}
// 只要 roomId 有效(非空字符串),就允许结束直播按钮可用
if (stopLiveButton) {
stopLiveButton.disabled = !(roomId && String(roomId).trim() !== "");
}
}
// 恢复直播状态
function restoreLiveState() {
if (isLiveStarted && streamInfo) {
setTimeout(() => {
const panel = document.getElementById("bili-stream-code-panel");
if (panel) {
// 不再自动展开面板,只恢复按钮和推流信息
// panel.style.display = 'block';
// 更新按钮状态
updateButtonsForLive(true);
// 恢复推流信息
restoreStreamInfo();
}
}, 500);
}
}
// 推流信息区输入框和按钮也用 class
function restoreStreamInfo() {
if (!streamInfo) return;
const resultArea = document.getElementById("bili-result");
if (!resultArea) return;
const rtmpAddr = streamInfo.rtmpAddr;
const rtmpCode = streamInfo.rtmpCode;
const resultHTML = `
<div style="display: flex; flex-direction: column; gap: 8px;">
<h3 class="bili-title" style="font-size: 16px;">推流信息 (进行中)</h3>
<div>
<p style="margin: 0; font-weight: bold;">服务器地址:</p>
<div style="display: flex; align-items: center; margin-bottom: 8px;">
<input id="server-addr" readonly value="${rtmpAddr}" title="${rtmpAddr}" class="bili-input" />
<button id="copy-addr" class="bili-copy-btn">复制</button>
</div>
<p style="margin: 0; font-weight: bold;">推流码:</p>
<div style="display: flex; align-items: center;">
<input id="stream-code" readonly value="${rtmpCode}" title="${rtmpCode}" class="bili-input" />
<button id="copy-code" class="bili-copy-btn">复制</button>
</div>
</div>
<div class="bili-important-tip">
<p style="margin: 0; font-weight: bold;">重要提示:</p>
<p style="margin: 3px 0 0; font-size: 13px;">1. 长时间无信号会自动关闭直播</p>
<p style="margin: 3px 0 0; font-size: 13px;">2. 推流码如果变动会有提示</p>
</div>
</div>
`;
resultArea.innerHTML = resultHTML;
resultArea.style.display = "block";
// 添加复制按钮事件
const copyAddrBtn = document.getElementById("copy-addr");
if (copyAddrBtn) {
copyAddrBtn.addEventListener("click", function () {
copyToClipboardWithButton(rtmpAddr, copyAddrBtn);
});
}
const copyCodeBtn = document.getElementById("copy-code");
if (copyCodeBtn) {
copyCodeBtn.addEventListener("click", function () {
copyToClipboardWithButton(rtmpCode, copyCodeBtn);
});
}
// 新增:推流信息区插入后,重新应用颜色模式
const isDarkMode = GM_getValue("bili_dark_mode", false);
applyColorMode(isDarkMode);
}
// 更新按钮状态(用于直播开始/结束)
function updateButtonsForLive(isLive) {
if (isLive) {
// 直播开始状态
if (startLiveButton) {
startLiveButton.disabled = true;
startLiveButton.style.opacity = "0.5";
}
if (stopLiveButton) {
stopLiveButton.disabled = false;
stopLiveButton.style.opacity = "1";
stopLiveButton.style.backgroundColor = "#ff4b4b";
}
} else {
// 直播结束状态
if (startLiveButton) {
startLiveButton.disabled = false;
startLiveButton.style.opacity = "1";
}
if (stopLiveButton) {
stopLiveButton.disabled = true;
stopLiveButton.style.opacity = "0.5";
stopLiveButton.style.backgroundColor = "#999";
}
}
}
// 开始直播
async function startLive() {
// Make startLive async
// 获取输入值
roomId = document.getElementById("bili-room-id").value.trim();
const areaId = document.getElementById("bili-area").value;
const liveTitle = document.getElementById("bili-title").value.trim();
// 验证输入
if (!roomId) {
showMessage("请输入房间ID", true);
return;
}
if (!liveTitle) {
showMessage("请输入直播标题", true);
return;
}
if (!csrf) {
showMessage("无法获取CSRF令牌,请确保已登录B站", true);
return;
}
// 更新直播标题
// updateLiveTitle(roomId, liveTitle, (success) => { // Original callback structure
// if (!success) {
// showMessage(
// "设置直播标题失败,请确认是否已登录或有权限修改此直播间",
// true
// );
// return;
// }
// // 设置请求参数
// startData.room_id = roomId;
// startData.csrf_token = csrf;
// startData.csrf = csrf;
// startData.area_v2 = areaId;
// // 获取推流码
// showMessage("正在获取推流码...");
// GM_xmlhttpRequest({
// method: "POST",
// url: API_URL_START_LIVE, // Replaced
// headers: headers,
// data: new URLSearchParams(startData).toString(),
// onload: function (response) {
// try {
// const result = JSON.parse(response.responseText);
// if (result.code === 0) {
// // 成功获取
// handleStartLiveSuccess(result.data, liveTitle, areaId);
// } else {
// console.error("Start live API error:", result); // Added
// showMessage(
// `获取推流码失败: ${result.message || "未知错误"}`,
// true
// );
// }
// } catch (error) {
// console.error(
// "Error parsing start live response:",
// error,
// "Response text:",
// response.responseText
// ); // Enhanced
// showMessage("解析响应失败,请稍后重试", true);
// }
// },
// onerror: function (response) {
// // Changed 'error' to 'response' to match GM_xmlhttpRequest
// console.error("Start live request error:", response); // Added
// showMessage("网络请求失败,请检查网络连接", true);
// },
// });
// });
try {
const titleUpdated = await updateLiveTitle(roomId, liveTitle);
if (!titleUpdated) {
showMessage(
"设置直播标题失败,请确认是否已登录或有权限修改此直播间",
true
);
return;
}
// 设置请求参数
startData.room_id = roomId;
startData.csrf_token = csrf;
startData.csrf = csrf;
startData.area_v2 = areaId;
// 获取推流码
showMessage("正在获取推流码...");
const startLiveResponse = await gmRequest({
method: "POST",
url: API_URL_START_LIVE,
headers: headers,
data: new URLSearchParams(startData).toString(),
});
const startLiveResult = JSON.parse(startLiveResponse.responseText);
if (startLiveResult.code === 0) {
// 成功获取
handleStartLiveSuccess(startLiveResult.data, liveTitle, areaId);
} else {
console.error("Start live API error:", startLiveResult);
showMessage(
`获取推流码失败: ${startLiveResult.message || "未知错误"}`,
true
);
}
} catch (errorResponse) {
console.error("API request failed in startLive:", errorResponse);
let errorMessage = "网络请求失败或解析错误";
if (errorResponse && errorResponse.responseText) {
try {
const parsedError = JSON.parse(errorResponse.responseText);
errorMessage = `API错误: ${parsedError.message || "未知API错误"}`;
} catch (e) {
// Ignore if responseText is not JSON
}
} else if (errorResponse instanceof Error) {
errorMessage = `请求错误: ${errorResponse.message}`;
}
showMessage(errorMessage, true);
}
}
// 处理开始直播成功
function handleStartLiveSuccess(data, title, areaId) {
const rtmpAddr = data.rtmp.addr;
const rtmpCode = data.rtmp.code;
// 新增:保存本次推流信息到本地用于下次对比
GM_setValue("bili_last_rtmp_addr", rtmpAddr);
GM_setValue("bili_last_rtmp_code", rtmpCode);
// 检查上次推流信息是否有变动
let changeTip = "";
const prevAddr = GM_getValue("bili_prev_rtmp_addr");
const prevCode = GM_getValue("bili_prev_rtmp_code");
if (prevAddr && prevCode) {
if (prevAddr !== rtmpAddr || prevCode !== rtmpCode) {
changeTip = `<div class=\"bili-tip-yellow\"><span style=\"font-weight:bold;\">注意:</span>本次推流信息与上次不同,请确认已更新到OBS等推流软件!</div>`;
} else {
changeTip = `<div class=\"bili-tip-green\"><span style=\"font-weight:bold;\">推流信息没有变动 🎉🎉</span></div>`;
}
}
// 更新本地上次推流信息为本次
GM_setValue("bili_prev_rtmp_addr", rtmpAddr);
GM_setValue("bili_prev_rtmp_code", rtmpCode);
// 显示推流信息
const resultHTML = `
<div style="display: flex; flex-direction: column; gap: 8px;">
<h3 class="bili-title" style="font-size: 16px;">推流信息</h3>
<div>
<p style="margin: 0; font-weight: bold;">服务器地址:</p>
<div style="display: flex; align-items: center; margin-bottom: 8px;">
<input id="server-addr" readonly value="${rtmpAddr}" title="${rtmpAddr}" class="bili-input" />
<button id="copy-addr" class="bili-copy-btn">复制</button>
</div>
<p style="margin: 0; font-weight: bold;">推流码:</p>
<div style="display: flex; align-items: center;">
<input id="stream-code" readonly value="${rtmpCode}" title="${rtmpCode}" class="bili-input" />
<button id="copy-code" class="bili-copy-btn">复制</button>
</div>
</div>
${changeTip}
<div class="bili-important-tip">
<p style="margin: 0; font-weight: bold;">重要提示:</p>
<p style="margin: 3px 0 0; font-size: 13px;">1. 长时间无信号会自动关闭直播</p>
<p style="margin: 3px 0 0; font-size: 13px;">2. 推流码如果变动会有提示</p>
</div>
</div>
`;
const resultArea = document.getElementById("bili-result");
resultArea.innerHTML = resultHTML;
resultArea.style.display = "block";
// 添加复制按钮事件
const copyAddrBtn = document.getElementById("copy-addr");
if (copyAddrBtn) {
copyAddrBtn.addEventListener("click", function () {
copyToClipboardWithButton(rtmpAddr, copyAddrBtn);
});
}
const copyCodeBtn = document.getElementById("copy-code");
if (copyCodeBtn) {
copyCodeBtn.addEventListener("click", function () {
copyToClipboardWithButton(rtmpCode, copyCodeBtn);
});
}
// 新增:推流信息区插入后,重新应用颜色模式
const isDarkMode = GM_getValue("bili_dark_mode", false);
applyColorMode(isDarkMode);
// 更新按钮状态
updateButtonsForLive(true);
// 保存直播状态
isLiveStarted = true;
streamInfo = {
rtmpAddr,
rtmpCode,
roomId,
areaId,
title,
};
GM_setValue("isLiveStarted", true);
GM_setValue("streamInfo", streamInfo);
// 显示通知
GM_notification({
text: "已成功获取推流码并开始直播",
title: "B站推流码获取工具",
timeout: 5000,
});
}
// 更新直播标题
async function updateLiveTitle(roomId, title) {
// Removed callback, make async
titleData.room_id = roomId;
titleData.title = title;
titleData.csrf_token = csrf;
titleData.csrf = csrf;
// GM_xmlhttpRequest({ // Original GM_xmlhttpRequest call
// method: "POST",
// url: API_URL_UPDATE_ROOM, // Replaced
// headers: headers,
// data: new URLSearchParams(titleData).toString(),
// onload: function (response) {
// try {
// const result = JSON.parse(response.responseText);
// if (result.code !== 0) {
// // Added condition for logging
// console.error("Update title API error:", result); // Added
// }
// callback(result.code === 0);
// } catch (error) {
// console.error(
// "Error parsing update title response:",
// error,
// "Response text:",
// response.responseText
// ); // Enhanced
// callback(false);
// }
// },
// onerror: function (response) {
// // Changed 'error' to 'response' to match GM_xmlhttpRequest
// console.error("Update title request error:", response); // Added
// callback(false);
// },
// });
try {
const response = await gmRequest({
method: "POST",
url: API_URL_UPDATE_ROOM,
headers: headers,
data: new URLSearchParams(titleData).toString(),
});
const result = JSON.parse(response.responseText);
if (result.code !== 0) {
console.error("Update title API error:", result);
}
return result.code === 0;
} catch (errorResponse) {
console.error("Update title request error:", errorResponse);
return false;
}
}
// 停止直播
async function stopLive() {
// Make stopLive async
if (!isLiveStarted) return;
if (!confirm("确定要结束直播吗?")) return;
// 设置请求参数
stopData.room_id = roomId;
stopData.csrf_token = csrf;
stopData.csrf = csrf;
// GM_xmlhttpRequest({ // Original GM_xmlhttpRequest call
// method: "POST",
// url: API_URL_STOP_LIVE, // Replaced
// headers: headers,
// data: new URLSearchParams(stopData).toString(),
// onload: function (response) {
// try {
// const result = JSON.parse(response.responseText);
// if (result.code === 0) {
// // 成功结束直播
// showMessage("直播已成功结束");
// // 更新按钮状态
// updateButtonsForLive(false);
// // 清除直播状态
// isLiveStarted = false;
// streamInfo = null;
// GM_setValue("isLiveStarted", false);
// GM_setValue("streamInfo", null);
// } else {
// console.error("Stop live API error:", result); // Added
// showMessage(`结束直播失败: ${result.message || "未知错误"}`, true);
// }
// } catch (error) {
// console.error(
// "Error parsing stop live response:",
// error,
// "Response text:",
// response.responseText
// ); // Enhanced
// showMessage("解析响应失败,请稍后重试", true);
// }
// },
// onerror: function (response) {
// // Changed 'error' to 'response' to match GM_xmlhttpRequest
// console.error("Stop live request error:", response); // Added
// showMessage("网络请求失败,请检查网络连接", true);
// },
// });
try {
const response = await gmRequest({
method: "POST",
url: API_URL_STOP_LIVE,
headers: headers,
data: new URLSearchParams(stopData).toString(),
});
const result = JSON.parse(response.responseText);
if (result.code === 0) {
// 成功结束直播
showMessage("直播已成功结束");
// 更新按钮状态
updateButtonsForLive(false);
// 清除直播状态
isLiveStarted = false;
streamInfo = null;
GM_setValue("isLiveStarted", false);
GM_setValue("streamInfo", null);
} else {
console.error("Stop live API error:", result);
showMessage(`结束直播失败: ${result.message || "未知错误"}`, true);
}
} catch (errorResponse) {
console.error("Stop live request error:", errorResponse);
let errorMessage = "网络请求失败或解析错误";
if (errorResponse && errorResponse.responseText) {
try {
const parsedError = JSON.parse(errorResponse.responseText);
errorMessage = `API错误: ${parsedError.message || "未知API错误"}`;
} catch (e) {
// Ignore if responseText is not JSON
}
} else if (errorResponse instanceof Error) {
errorMessage = `请求错误: ${errorResponse.message}`;
}
showMessage(errorMessage, true);
}
}
// 显示消息
function showMessage(message, isError = false) {
const resultArea = document.getElementById("bili-result");
if (resultArea) {
resultArea.innerHTML = `<p class="bili-message${
isError ? " bili-message-error" : ""
}">${message}</p>`;
resultArea.style.display = "block";
}
GM_notification({
text: message,
title: isError ? "错误" : "B站推流码获取工具",
timeout: 5000,
});
}
// 复制到剪贴板
function copyToClipboard(text) {
GM_setClipboard(text);
showMessage("已复制到剪贴板");
}
// 复制到剪贴板(按钮变✅,不弹窗)
function copyToClipboardWithButton(text, btn) {
GM_setClipboard(text);
if (!btn) return;
const oldText = btn.textContent;
btn.textContent = "✅";
btn.disabled = true;
btn.classList.add("bili-copy-btn");
setTimeout(() => {
btn.textContent = oldText;
btn.disabled = false;
btn.classList.add("bili-copy-btn");
}, 2000);
}
// 页面导航事件监听
window.addEventListener("popstate", init);
window.addEventListener("hashchange", init);
// 监听页面可见性变化,页面可见时检查浮动按钮
document.addEventListener("visibilitychange", function () {
if (document.visibilityState === "visible") {
checkFloatButton();
}
});
// 使用MutationObserver监听DOM变化,动态检查浮动按钮
const observer = new MutationObserver(function () {
checkFloatButton();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
// 初始加载
setTimeout(init, 500);
})();