// ==UserScript==
// @name bililive-go Live Room Sender
// @namespace https://github.com/bililive-go/bililive-go
// @version 0.2.1
// @description Send the current live room to bililive-go with dedupe, remote endpoint support and dynamic port detection
// @author bililive-go
// @license GPL-3.0-only
// @match *://live.bilibili.com/*
// @match *://live.douyin.com/*
// @match *://v.douyin.com/*
// @match *://www.douyu.com/*
// @match *://www.huya.com/*
// @match *://live.kuaishou.com/*
// @match *://www.yy.com/*
// @match *://live.acfun.cn/*
// @match *://www.lang.live/*
// @match *://fm.missevan.com/*
// @match *://www.openrec.tv/*
// @match *://weibo.com/*
// @match *://live.weibo.com/*
// @match *://www.xiaohongshu.com/*
// @match *://xhslink.com/*
// @match *://www.yizhibo.com/*
// @match *://www.hongdoufm.com/*
// @match *://live.kilakila.cn/*
// @match *://www.zhanqi.tv/*
// @match *://cc.163.com/*
// @match *://www.twitch.tv/*
// @match *://egame.qq.com/*
// @match *://www.huajiao.com/*
// @match *://play.sooplive.com/*
// @match http://127.0.0.1:*/*
// @match http://localhost:*/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @connect 127.0.0.1
// @connect localhost
// @connect *
// @run-at document-idle
// @downloadURL https://update.greasyfork.icu/scripts/569635/bililive-go%20Live%20Room%20Sender.user.js
// @updateURL https://update.greasyfork.icu/scripts/569635/bililive-go%20Live%20Room%20Sender.meta.js
// ==/UserScript==
(function () {
'use strict';
if (window.top !== window.self) {
return;
}
var STORAGE_KEYS = {
manualEndpoint: 'bgo_manual_endpoint',
lastEndpoint: 'bgo_last_endpoint',
recentPorts: 'bgo_recent_ports',
extraHeaders: 'bgo_extra_headers',
};
var LOCAL_HOSTS = {
'127.0.0.1': true,
'localhost': true,
};
var LOCAL_ALIAS_HOSTS = {
'live.weibo.com': 'weibo.com',
};
var DEFAULT_LOCAL_ENDPOINTS = [
'http://127.0.0.1:8080',
'http://localhost:8080',
];
var FLOATING_LAYOUT = {
dragThreshold: 4,
viewportMargin: 12,
panelGap: 12,
};
var state = {
endpoint: '',
room: null,
roomUrl: getCurrentRoomUrl(),
opened: false,
refreshing: false,
hasChecked: false,
lastCheckSignature: '',
};
var ui = {
launcher: null,
launcherStatus: null,
panel: null,
pageValue: null,
endpointValue: null,
statusValue: null,
primaryButton: null,
refreshButton: null,
settingsButton: null,
closeButton: null,
guideButton: null,
guideModal: null,
guideModalClose: null,
guideModalBackdrop: null,
};
var dragState = {
active: false,
moved: false,
ignoreClick: false,
pointerId: null,
offsetX: 0,
offsetY: 0,
};
registerMenuCommands();
if (isLocalPage()) {
autoBindCurrentOrigin();
return;
}
injectStyles();
createUI();
updatePageUrlDisplay();
watchLocationChange();
refreshPanelState(true);
function registerMenuCommands() {
if (typeof GM_registerMenuCommand !== 'function') {
return;
}
GM_registerMenuCommand('设置 bililive 地址', function () {
promptAndSaveEndpoint().then(function (changed) {
if (changed) {
refreshPanelState(true);
}
});
});
GM_registerMenuCommand('设置额外请求头', function () {
promptAndSaveExtraHeaders().then(function (changed) {
if (changed) {
refreshPanelState(true);
}
});
});
GM_registerMenuCommand('清除 bililive 地址', function () {
GM_setValue(STORAGE_KEYS.manualEndpoint, '');
GM_setValue(STORAGE_KEYS.lastEndpoint, '');
refreshPanelState(true);
});
GM_registerMenuCommand('清除额外请求头', function () {
GM_setValue(STORAGE_KEYS.extraHeaders, '{}');
refreshPanelState(true);
});
}
function isLocalPage() {
return !!LOCAL_HOSTS[window.location.hostname];
}
function autoBindCurrentOrigin() {
probeEndpoint(window.location.origin).then(function (ok) {
if (!ok) {
return;
}
rememberEndpoint(window.location.origin);
console.info('[bililive-go] 已自动绑定本地服务地址:', window.location.origin);
});
}
function injectStyles() {
GM_addStyle([
'#bgo-tm-launcher {',
' position: fixed;',
' right: 18px;',
' bottom: 116px;',
' z-index: 2147483646;',
' display: inline-flex;',
' align-items: center;',
' gap: 8px;',
' padding: 10px 11px 10px 14px;',
' border: 1px solid rgba(148, 163, 184, 0.24);',
' border-radius: 999px;',
' background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(241, 245, 249, 0.9));',
' color: #0f172a;',
' font-size: 12px;',
' font-weight: 700;',
' letter-spacing: 0.02em;',
' cursor: grab;',
' user-select: none;',
' touch-action: none;',
' box-shadow: 0 20px 38px rgba(15, 23, 42, 0.18);',
' backdrop-filter: blur(18px);',
' box-shadow: 0 20px 38px rgba(15, 23, 42, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.72);',
' transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, background 160ms ease;',
'}',
'.bgo-tm-launcher-brand {',
' white-space: nowrap;',
'}',
'.bgo-tm-launcher-status {',
' display: inline-flex;',
' align-items: center;',
' justify-content: center;',
' min-width: 54px;',
' padding: 6px 10px;',
' border-radius: 999px;',
' background: rgba(15, 23, 42, 0.08);',
' color: #334155;',
' font-size: 11px;',
' font-weight: 800;',
' letter-spacing: 0.01em;',
' transition: background 160ms ease, color 160ms ease;',
'}',
'#bgo-tm-launcher[data-state="loading"] .bgo-tm-launcher-status,',
'#bgo-tm-launcher[data-state="sending"] .bgo-tm-launcher-status {',
' background: rgba(59, 130, 246, 0.12);',
' color: #1d4ed8;',
'}',
'#bgo-tm-launcher[data-state="ready"] .bgo-tm-launcher-status {',
' background: rgba(20, 184, 166, 0.14);',
' color: #0f766e;',
'}',
'#bgo-tm-launcher[data-state="added"] .bgo-tm-launcher-status {',
' background: rgba(22, 163, 74, 0.14);',
' color: #166534;',
'}',
'#bgo-tm-launcher[data-state="offline"] .bgo-tm-launcher-status,',
'#bgo-tm-launcher[data-state="unsupported"] .bgo-tm-launcher-status {',
' background: rgba(148, 163, 184, 0.18);',
' color: #475569;',
'}',
'#bgo-tm-launcher[data-state="error"] .bgo-tm-launcher-status {',
' background: rgba(239, 68, 68, 0.14);',
' color: #b91c1c;',
'}',
'#bgo-tm-launcher:hover {',
' transform: translateY(-2px);',
' box-shadow: 0 24px 46px rgba(15, 23, 42, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.82);',
' border-color: rgba(20, 184, 166, 0.28);',
' background: #ffffff;',
'}',
'#bgo-tm-launcher[data-dragging="true"] {',
' cursor: grabbing;',
' transition: none;',
'}',
'#bgo-tm-launcher[data-dragging="true"]:hover {',
' transform: none;',
'}',
'#bgo-tm-launcher::before {',
' content: "";',
' width: 9px;',
' height: 9px;',
' border-radius: 999px;',
' background: radial-gradient(circle at 30% 30%, #99f6e4, #14b8a6 65%, #0f766e 100%);',
' box-shadow: 0 0 18px rgba(20, 184, 166, 0.46);',
'}',
'#bgo-tm-panel {',
' position: fixed;',
' right: 18px;',
' bottom: 168px;',
' width: min(380px, calc(100vw - 24px));',
' z-index: 2147483646;',
' border-radius: 24px;',
' overflow: hidden;',
' background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.96));',
' color: #0f172a;',
' box-shadow: 0 30px 70px rgba(15, 23, 42, 0.26);',
' border: 1px solid rgba(148, 163, 184, 0.22);',
' display: none;',
' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;',
' backdrop-filter: blur(22px);',
' max-height: min(760px, calc(100vh - 28px));',
'}',
'#bgo-tm-panel::before {',
' content: "";',
' position: absolute;',
' inset: 0 0 auto 0;',
' height: 164px;',
' background: radial-gradient(circle at top right, rgba(45, 212, 191, 0.24), transparent 54%), linear-gradient(135deg, rgba(15, 23, 42, 0.08), transparent 72%);',
' pointer-events: none;',
'}',
'#bgo-tm-panel.bgo-open {',
' display: block;',
'}',
'#bgo-tm-panel * {',
' box-sizing: border-box;',
'}',
'.bgo-tm-header {',
' display: flex;',
' align-items: flex-start;',
' justify-content: space-between;',
' gap: 12px;',
' padding: 18px 18px 10px;',
' position: relative;',
' color: #0f172a;',
'}',
'.bgo-tm-header-main {',
' min-width: 0;',
'}',
'.bgo-tm-kicker {',
' display: inline-flex;',
' align-items: center;',
' gap: 6px;',
' padding: 4px 9px;',
' border-radius: 999px;',
' background: rgba(15, 23, 42, 0.06);',
' color: #0f766e;',
' font-size: 11px;',
' font-weight: 700;',
' letter-spacing: 0.06em;',
' text-transform: uppercase;',
'}',
'.bgo-tm-kicker::before {',
' content: "";',
' width: 7px;',
' height: 7px;',
' border-radius: 999px;',
' background: #14b8a6;',
' box-shadow: 0 0 14px rgba(20, 184, 166, 0.38);',
'}',
'.bgo-tm-title {',
' margin-top: 10px;',
' font-size: 20px;',
' line-height: 1.1;',
' font-weight: 800;',
' letter-spacing: -0.02em;',
'}',
'.bgo-tm-subtitle {',
' margin-top: 6px;',
' color: #475569;',
' font-size: 12px;',
' line-height: 1.5;',
'}',
'.bgo-tm-actions {',
' display: flex;',
' gap: 8px;',
' flex-wrap: wrap;',
' justify-content: flex-end;',
' flex-shrink: 0;',
'}',
'.bgo-tm-icon-btn {',
' min-width: 52px;',
' border: 1px solid rgba(148, 163, 184, 0.28);',
' background: rgba(255, 255, 255, 0.8);',
' color: #334155;',
' border-radius: 12px;',
' padding: 7px 10px;',
' cursor: pointer;',
' font-size: 12px;',
' font-weight: 600;',
' transition: background 160ms ease, border-color 160ms ease, transform 160ms ease;',
'}',
'.bgo-tm-icon-btn:hover {',
' background: #ffffff;',
' border-color: rgba(20, 184, 166, 0.34);',
' transform: translateY(-1px);',
'}',
'.bgo-tm-body {',
' position: relative;',
' padding: 8px 18px 18px;',
' overflow: auto;',
'}',
'.bgo-tm-row {',
' margin-bottom: 10px;',
' padding: 12px 14px;',
' border-radius: 16px;',
' background: rgba(255, 255, 255, 0.82);',
' border: 1px solid rgba(226, 232, 240, 0.92);',
' box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.58);',
'}',
'.bgo-tm-label {',
' display: inline-block;',
' margin-bottom: 6px;',
' color: #64748b;',
' font-size: 12px;',
' font-weight: 700;',
' letter-spacing: 0.02em;',
'}',
'.bgo-tm-value {',
' font-size: 13px;',
' line-height: 1.65;',
' color: #0f172a;',
' word-break: break-all;',
'}',
'.bgo-tm-status {',
' min-height: 56px;',
' padding: 12px 14px;',
' border-radius: 16px;',
' background: linear-gradient(135deg, rgba(15, 118, 110, 0.1), rgba(45, 212, 191, 0.08));',
' border: 1px solid rgba(20, 184, 166, 0.18);',
' box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);',
'}',
'.bgo-tm-help-trigger {',
' display: inline-flex;',
' align-items: center;',
' justify-content: center;',
' gap: 10px;',
' padding: 12px 14px;',
' border-radius: 18px;',
' border: 1px solid rgba(148, 163, 184, 0.24);',
' background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(248, 250, 252, 0.9));',
' color: #0f172a;',
' font-size: 13px;',
' font-weight: 700;',
' cursor: pointer;',
' box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);',
' transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;',
'}',
'.bgo-tm-help-trigger:hover {',
' transform: translateY(-1px);',
' border-color: rgba(20, 184, 166, 0.3);',
' box-shadow: 0 12px 26px rgba(15, 23, 42, 0.08);',
' background: #ffffff;',
'}',
'.bgo-tm-help-trigger::before {',
' content: "";',
' width: 10px;',
' height: 10px;',
' border-radius: 999px;',
' background: linear-gradient(135deg, #0f766e, #2dd4bf);',
' box-shadow: 0 0 16px rgba(20, 184, 166, 0.3);',
'}',
'.bgo-tm-guide-list {',
' margin: 0;',
' padding-left: 18px;',
' color: #475569;',
' font-size: 12px;',
' line-height: 1.7;',
'}',
'.bgo-tm-guide-list li + li {',
' margin-top: 6px;',
'}',
'.bgo-tm-modal {',
' position: fixed;',
' inset: 0;',
' z-index: 2147483647;',
' display: none;',
'}',
'.bgo-tm-modal.bgo-visible {',
' display: block;',
'}',
'.bgo-tm-modal-backdrop {',
' position: absolute;',
' inset: 0;',
' background: rgba(15, 23, 42, 0.46);',
' backdrop-filter: blur(6px);',
'}',
'.bgo-tm-modal-card {',
' position: absolute;',
' top: 50%;',
' left: 50%;',
' width: min(420px, calc(100vw - 28px));',
' max-height: min(560px, calc(100vh - 32px));',
' overflow: auto;',
' transform: translate(-50%, -50%);',
' border-radius: 24px;',
' background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.96));',
' border: 1px solid rgba(203, 213, 225, 0.74);',
' box-shadow: 0 34px 80px rgba(15, 23, 42, 0.34);',
' padding: 18px;',
'}',
'.bgo-tm-modal-head {',
' display: flex;',
' align-items: center;',
' justify-content: space-between;',
' gap: 12px;',
' margin-bottom: 12px;',
'}',
'.bgo-tm-modal-title {',
' font-size: 18px;',
' font-weight: 800;',
' color: #0f172a;',
' letter-spacing: -0.02em;',
'}',
'.bgo-tm-modal-close {',
' border: 1px solid rgba(148, 163, 184, 0.28);',
' background: rgba(255, 255, 255, 0.82);',
' color: #334155;',
' border-radius: 12px;',
' padding: 8px 12px;',
' font-size: 12px;',
' font-weight: 700;',
' cursor: pointer;',
'}',
'.bgo-tm-modal-copy {',
' padding: 14px 14px 12px;',
' border-radius: 18px;',
' background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.88));',
' border: 1px solid rgba(203, 213, 225, 0.72);',
'}',
'.bgo-tm-modal-copy .bgo-tm-guide-list {',
' color: #334155;',
' font-size: 13px;',
'}',
'.bgo-tm-buttons {',
' display: flex;',
' gap: 10px;',
' margin-top: 16px;',
'}',
'.bgo-tm-primary, .bgo-tm-secondary {',
' flex: 1;',
' border-radius: 16px;',
' padding: 12px 14px;',
' font-size: 13px;',
' font-weight: 700;',
' cursor: pointer;',
' transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, background 160ms ease;',
'}',
'.bgo-tm-primary {',
' border: 0;',
' background: linear-gradient(135deg, #0f766e, #14b8a6);',
' color: #f8fafc;',
' box-shadow: 0 14px 30px rgba(15, 118, 110, 0.24);',
'}',
'.bgo-tm-primary:hover:not([disabled]) {',
' transform: translateY(-1px);',
' box-shadow: 0 18px 34px rgba(15, 118, 110, 0.28);',
'}',
'.bgo-tm-primary[disabled] {',
' cursor: not-allowed;',
' background: linear-gradient(135deg, #cbd5e1, #e2e8f0);',
' color: #475569;',
' box-shadow: none;',
'}',
'.bgo-tm-secondary {',
' border: 1px solid rgba(148, 163, 184, 0.28);',
' background: rgba(255, 255, 255, 0.84);',
' color: #0f172a;',
'}',
'.bgo-tm-secondary:hover {',
' transform: translateY(-1px);',
' border-color: rgba(20, 184, 166, 0.3);',
' background: #ffffff;',
'}',
'@media (max-width: 480px) {',
' #bgo-tm-launcher {',
' right: 12px;',
' bottom: 92px;',
' padding-right: 10px;',
' }',
' #bgo-tm-panel {',
' right: 12px;',
' bottom: 142px;',
' width: calc(100vw - 24px);',
' }',
' .bgo-tm-buttons {',
' flex-direction: column;',
' }',
' .bgo-tm-actions {',
' width: 100%;',
' justify-content: flex-start;',
' }',
'}',
].join('\n'));
}
function createUI() {
ui.launcher = document.createElement('button');
ui.launcher.id = 'bgo-tm-launcher';
ui.launcher.type = 'button';
ui.launcher.setAttribute('data-state', 'loading');
ui.launcher.innerHTML = [
'bililive',
'检测中',
].join('');
ui.launcher.addEventListener('click', handleLauncherClick);
ui.launcherStatus = ui.launcher.querySelector('[data-role="launcher-status"]');
ui.panel = document.createElement('section');
ui.panel.id = 'bgo-tm-panel';
ui.panel.innerHTML = [
'
',
'',
'
',
'
',
'
bililive 地址',
'
未检测
',
'
',
'
',
'
状态',
'
点击面板后开始检测
',
'
',
'
',
' ',
' ',
'
',
'
',
'',
'
',
'
',
'
',
'
',
'
',
' - 首次使用先点右上角“设置”,填写 bililive-go 地址。
',
' - 本机 Docker 填宿主机地址;远程或反代请填完整 URL,例如 https://example.com/bgo。
',
' - 如果远程入口有鉴权,请先在 Tampermonkey 菜单里设置额外请求头。
',
' - 确认状态正常后,点击“发送到 bililive”即可新增监控。
',
' - 显示“已添加过”表示当前直播间已经在监控列表里。
',
'
',
'
',
'
',
'
',
].join('');
ui.pageValue = ui.panel.querySelector('[data-role="page"]');
ui.endpointValue = ui.panel.querySelector('[data-role="endpoint"]');
ui.statusValue = ui.panel.querySelector('[data-role="status"]');
ui.primaryButton = ui.panel.querySelector('[data-role="primary"]');
ui.refreshButton = ui.panel.querySelector('[data-role="refresh"]');
ui.settingsButton = ui.panel.querySelector('[data-role="settings"]');
ui.closeButton = ui.panel.querySelector('[data-role="close"]');
ui.guideButton = ui.panel.querySelector('[data-role="guide-open"]');
ui.guideModal = ui.panel.querySelector('[data-role="guide-modal"]');
ui.guideModalClose = ui.panel.querySelector('[data-role="guide-close"]');
ui.guideModalBackdrop = ui.panel.querySelector('[data-role="guide-backdrop"]');
ui.primaryButton.addEventListener('click', handlePrimaryAction);
ui.refreshButton.addEventListener('click', function () {
refreshPanelState(true);
});
ui.settingsButton.addEventListener('click', function () {
promptAndSaveEndpoint().then(function (changed) {
if (changed) {
refreshPanelState(true);
}
});
});
ui.closeButton.addEventListener('click', closePanel);
ui.guideButton.addEventListener('click', openGuideModal);
ui.guideModalClose.addEventListener('click', closeGuideModal);
ui.guideModalBackdrop.addEventListener('click', closeGuideModal);
document.body.appendChild(ui.launcher);
document.body.appendChild(ui.panel);
bindLauncherDrag();
}
function bindLauncherDrag() {
if (!ui.launcher) {
return;
}
ui.launcher.addEventListener('pointerdown', handleLauncherPointerDown);
window.addEventListener('pointermove', handleLauncherPointerMove);
window.addEventListener('pointerup', handleLauncherPointerEnd);
window.addEventListener('pointercancel', handleLauncherPointerEnd);
window.addEventListener('resize', handleViewportChange);
}
function handleLauncherClick(event) {
if (dragState.ignoreClick) {
dragState.ignoreClick = false;
event.preventDefault();
return;
}
togglePanel();
}
function handleLauncherPointerDown(event) {
if (!ui.launcher) {
return;
}
if (event.pointerType === 'mouse' && event.button !== 0) {
return;
}
var rect = ui.launcher.getBoundingClientRect();
dragState.active = true;
dragState.moved = false;
dragState.pointerId = event.pointerId;
dragState.offsetX = event.clientX - rect.left;
dragState.offsetY = event.clientY - rect.top;
ui.launcher.setAttribute('data-dragging', 'true');
if (typeof ui.launcher.setPointerCapture === 'function') {
try {
ui.launcher.setPointerCapture(event.pointerId);
} catch (error) {
}
}
}
function handleLauncherPointerMove(event) {
if (!dragState.active || dragState.pointerId !== event.pointerId || !ui.launcher) {
return;
}
var currentRect = ui.launcher.getBoundingClientRect();
var position = clampLauncherPosition(
event.clientX - dragState.offsetX,
event.clientY - dragState.offsetY
);
if (!dragState.moved) {
dragState.moved =
Math.abs(position.left - currentRect.left) >= FLOATING_LAYOUT.dragThreshold ||
Math.abs(position.top - currentRect.top) >= FLOATING_LAYOUT.dragThreshold;
if (!dragState.moved) {
return;
}
}
applyLauncherPosition(position.left, position.top);
if (state.opened) {
updatePanelPosition();
}
}
function handleLauncherPointerEnd(event) {
if (!dragState.active || dragState.pointerId !== event.pointerId) {
return;
}
dragState.ignoreClick = dragState.moved;
dragState.active = false;
dragState.moved = false;
dragState.pointerId = null;
if (!ui.launcher) {
return;
}
ui.launcher.removeAttribute('data-dragging');
if (typeof ui.launcher.releasePointerCapture === 'function') {
try {
ui.launcher.releasePointerCapture(event.pointerId);
} catch (error) {
}
}
}
function handleViewportChange() {
if (hasCustomLauncherPosition()) {
var position = clampLauncherPosition(getCustomLauncherLeft(), getCustomLauncherTop());
applyLauncherPosition(position.left, position.top);
}
if (state.opened) {
updatePanelPosition();
}
}
function hasCustomLauncherPosition() {
return !!(ui.launcher && ui.launcher.style.left && ui.launcher.style.top);
}
function getCustomLauncherLeft() {
if (!ui.launcher) {
return 0;
}
return parseFloat(ui.launcher.style.left) || 0;
}
function getCustomLauncherTop() {
if (!ui.launcher) {
return 0;
}
return parseFloat(ui.launcher.style.top) || 0;
}
function applyLauncherPosition(left, top) {
if (!ui.launcher) {
return;
}
var position = clampLauncherPosition(left, top);
ui.launcher.style.left = position.left + 'px';
ui.launcher.style.top = position.top + 'px';
ui.launcher.style.right = 'auto';
ui.launcher.style.bottom = 'auto';
}
function clampLauncherPosition(left, top) {
var launcherWidth = ui.launcher ? ui.launcher.offsetWidth : 0;
var launcherHeight = ui.launcher ? ui.launcher.offsetHeight : 0;
var maxLeft = Math.max(FLOATING_LAYOUT.viewportMargin, window.innerWidth - launcherWidth - FLOATING_LAYOUT.viewportMargin);
var maxTop = Math.max(FLOATING_LAYOUT.viewportMargin, window.innerHeight - launcherHeight - FLOATING_LAYOUT.viewportMargin);
return {
left: clamp(left, FLOATING_LAYOUT.viewportMargin, maxLeft),
top: clamp(top, FLOATING_LAYOUT.viewportMargin, maxTop),
};
}
function updatePanelPosition() {
if (!ui.launcher || !ui.panel) {
return;
}
var launcherRect = ui.launcher.getBoundingClientRect();
var panelRect = ui.panel.getBoundingClientRect();
var maxLeft = Math.max(FLOATING_LAYOUT.viewportMargin, window.innerWidth - panelRect.width - FLOATING_LAYOUT.viewportMargin);
var maxTop = Math.max(FLOATING_LAYOUT.viewportMargin, window.innerHeight - panelRect.height - FLOATING_LAYOUT.viewportMargin);
var left = clamp(launcherRect.right - panelRect.width, FLOATING_LAYOUT.viewportMargin, maxLeft);
var top = launcherRect.top - panelRect.height - FLOATING_LAYOUT.panelGap;
if (top < FLOATING_LAYOUT.viewportMargin) {
top = launcherRect.bottom + FLOATING_LAYOUT.panelGap;
}
ui.panel.style.left = left + 'px';
ui.panel.style.top = clamp(top, FLOATING_LAYOUT.viewportMargin, maxTop) + 'px';
ui.panel.style.right = 'auto';
ui.panel.style.bottom = 'auto';
}
function clamp(value, min, max) {
if (value < min) {
return min;
}
if (value > max) {
return max;
}
return value;
}
function togglePanel() {
if (state.opened) {
closePanel();
return;
}
openPanel();
}
function openPanel() {
state.opened = true;
ui.panel.classList.add('bgo-open');
updatePanelPosition();
refreshPanelState(false);
}
function closePanel() {
state.opened = false;
ui.panel.classList.remove('bgo-open');
closeGuideModal();
}
function openGuideModal() {
if (!ui.guideModal) {
return;
}
ui.guideModal.classList.add('bgo-visible');
}
function closeGuideModal() {
if (!ui.guideModal) {
return;
}
ui.guideModal.classList.remove('bgo-visible');
}
function updatePageUrlDisplay() {
state.roomUrl = getCurrentRoomUrl();
if (ui.pageValue) {
ui.pageValue.textContent = state.roomUrl;
}
}
function getCurrentRoomUrl() {
return window.location.href.split('#')[0];
}
function watchLocationChange() {
var lastUrl = state.roomUrl;
window.setInterval(function () {
var nextUrl = getCurrentRoomUrl();
if (nextUrl === lastUrl) {
return;
}
lastUrl = nextUrl;
state.room = null;
state.endpoint = '';
state.hasChecked = false;
state.lastCheckSignature = '';
updatePageUrlDisplay();
setEndpointText('待重新检测');
setStatusText('页面已变化,请重新检测当前直播间');
setPrimaryState('发送到 bililive', true);
setLauncherState('loading', '检测中');
refreshPanelState(true);
}, 1000);
}
function buildCheckSignature() {
return JSON.stringify({
roomUrl: getCurrentRoomUrl(),
manualEndpoint: normalizeEndpoint(GM_getValue(STORAGE_KEYS.manualEndpoint, '')),
lastEndpoint: normalizeEndpoint(GM_getValue(STORAGE_KEYS.lastEndpoint, '')),
extraHeaders: String(GM_getValue(STORAGE_KEYS.extraHeaders, '{}') || '{}'),
});
}
function canReusePanelState() {
if (!state.hasChecked) {
return false;
}
return state.lastCheckSignature === buildCheckSignature();
}
async function refreshPanelState(force) {
if (state.refreshing) {
return;
}
if (force !== true && canReusePanelState()) {
return;
}
state.refreshing = true;
state.room = null;
setPrimaryState('检测中', true);
setStatusText('正在连接 bililive-go...');
setEndpointText('检测中');
setLauncherState('loading', '检测中');
try {
var endpoint = await resolveEndpoint();
if (!endpoint) {
state.endpoint = '';
setEndpointText('未连接');
setStatusText('未找到可用的 bililive-go。请先在菜单中设置地址。远程和 Docker 部署建议填写完整 URL。');
setPrimaryState('未连接 bililive', true);
setLauncherState('offline', '未连接');
return;
}
state.endpoint = endpoint;
setEndpointText(endpoint);
var room = await resolveCurrentRoom(endpoint);
if (!room) {
state.room = null;
setStatusText('当前页面不是 bililive-go 支持的直播间地址,或页面本身不是直播间。');
setPrimaryState('当前页面不支持', true);
setLauncherState('unsupported', '不支持');
return;
}
state.room = room;
var tasks = await getTasks(endpoint);
if (isAlreadyAdded(tasks, room)) {
setStatusText('当前直播间已存在于 bililive-go 监控列表。');
setPrimaryState('已添加过', true);
setLauncherState('added', '已添加');
return;
}
setStatusText('已识别平台:' + room.platform + ',可以发送到 bililive-go。');
setPrimaryState('发送到 bililive', false);
setLauncherState('ready', '可发送');
} catch (error) {
setStatusText('检测失败:' + error.message);
setPrimaryState('发送失败', true);
setLauncherState('error', '失败');
} finally {
state.hasChecked = true;
state.lastCheckSignature = buildCheckSignature();
state.refreshing = false;
}
}
async function handlePrimaryAction() {
if (!state.endpoint || !state.room) {
return;
}
setPrimaryState('发送中', true);
setStatusText('正在向 bililive-go 添加直播间...');
setLauncherState('sending', '发送中');
try {
var latestTasks = await getTasks(state.endpoint);
if (isAlreadyAdded(latestTasks, state.room)) {
setStatusText('当前直播间已经在 bililive-go 监控列表里,无需重复添加。');
setPrimaryState('已添加过', true);
setLauncherState('added', '已添加');
return;
}
await addTask(state.endpoint, state.room.canonical_url);
setStatusText('添加成功,当前直播间已进入 bililive-go 监控列表。');
setPrimaryState('已添加过', true);
setLauncherState('added', '已添加');
} catch (error) {
setStatusText('发送失败:' + error.message);
setPrimaryState('发送失败', true);
setLauncherState('error', '失败');
}
}
function setEndpointText(value) {
ui.endpointValue.textContent = value;
}
function setStatusText(value) {
ui.statusValue.textContent = value;
}
function setPrimaryState(text, disabled) {
ui.primaryButton.textContent = text;
ui.primaryButton.disabled = disabled;
}
function setLauncherState(stateKey, text) {
if (!ui.launcher) {
return;
}
ui.launcher.setAttribute('data-state', stateKey || 'idle');
if (ui.launcherStatus) {
ui.launcherStatus.textContent = text || '';
}
}
async function promptAndSaveEndpoint() {
var currentValue = GM_getValue(STORAGE_KEYS.manualEndpoint, '') || GM_getValue(STORAGE_KEYS.lastEndpoint, '') || '';
var input = window.prompt('请输入 bililive-go 地址,例如 http://127.0.0.1:8080 或 https://example.com/bgo', currentValue);
if (input === null) {
return false;
}
input = normalizeEndpoint(input);
if (!input) {
window.alert('地址不能为空');
return false;
}
var ok = await probeEndpoint(input);
if (!ok) {
window.alert('该地址无法连接到 bililive-go,请确认地址、端口、路径和网络连通性是否正确');
return false;
}
GM_setValue(STORAGE_KEYS.manualEndpoint, input);
rememberEndpoint(input);
return true;
}
async function promptAndSaveExtraHeaders() {
var currentValue = GM_getValue(STORAGE_KEYS.extraHeaders, '{}');
var input = window.prompt('请输入额外请求头 JSON,例如 {"Authorization":"Bearer xxx"}', currentValue);
if (input === null) {
return false;
}
input = String(input).trim();
if (!input) {
GM_setValue(STORAGE_KEYS.extraHeaders, '{}');
return true;
}
try {
var parsed = JSON.parse(input);
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error('请求头必须是 JSON 对象');
}
GM_setValue(STORAGE_KEYS.extraHeaders, JSON.stringify(parsed));
return true;
} catch (error) {
window.alert('额外请求头格式错误:' + error.message);
return false;
}
}
async function resolveEndpoint() {
var candidates = collectEndpointCandidates();
for (var i = 0; i < candidates.length; i += 1) {
var candidate = candidates[i];
var ok = await probeEndpoint(candidate);
if (!ok) {
continue;
}
rememberEndpoint(candidate);
return candidate;
}
return '';
}
function collectEndpointCandidates() {
var values = [];
pushCandidate(values, GM_getValue(STORAGE_KEYS.manualEndpoint, ''));
pushCandidate(values, GM_getValue(STORAGE_KEYS.lastEndpoint, ''));
if (isLocalPage()) {
pushCandidate(values, window.location.origin);
}
var recentPorts = GM_getValue(STORAGE_KEYS.recentPorts, []);
if (Array.isArray(recentPorts)) {
for (var i = 0; i < recentPorts.length; i += 1) {
var port = String(recentPorts[i]).trim();
if (!port) {
continue;
}
pushCandidate(values, 'http://127.0.0.1:' + port);
pushCandidate(values, 'http://localhost:' + port);
}
}
for (var j = 0; j < DEFAULT_LOCAL_ENDPOINTS.length; j += 1) {
pushCandidate(values, DEFAULT_LOCAL_ENDPOINTS[j]);
}
return values;
}
function pushCandidate(list, value) {
value = normalizeEndpoint(value);
if (!value) {
return;
}
if (list.indexOf(value) !== -1) {
return;
}
list.push(value);
}
function normalizeEndpoint(value) {
if (!value) {
return '';
}
value = String(value).trim();
if (!value) {
return '';
}
if (!/^https?:\/\//i.test(value)) {
value = 'http://' + value;
}
try {
var parsed = new URL(value);
var pathname = parsed.pathname || '/';
pathname = pathname.replace(/\/+$/, '');
if (!pathname || pathname === '/') {
return parsed.origin;
}
return parsed.origin + pathname;
} catch (error) {
return '';
}
}
function rememberEndpoint(endpoint) {
endpoint = normalizeEndpoint(endpoint);
if (!endpoint) {
return;
}
GM_setValue(STORAGE_KEYS.lastEndpoint, endpoint);
try {
var port = new URL(endpoint).port;
if (!port) {
return;
}
var recentPorts = GM_getValue(STORAGE_KEYS.recentPorts, []);
if (!Array.isArray(recentPorts)) {
recentPorts = [];
}
recentPorts = recentPorts.filter(function (item) {
return String(item) !== port;
});
recentPorts.unshift(port);
GM_setValue(STORAGE_KEYS.recentPorts, recentPorts.slice(0, 6));
} catch (error) {
console.warn('[bililive-go] 记录端口失败:', error);
}
}
async function probeEndpoint(endpoint) {
try {
var response = await requestJSON('GET', joinUrl(endpoint, '/osrp/v1/info'));
return response.status >= 200 && response.status < 300 && response.data && response.data.success === true;
} catch (error) {
return false;
}
}
async function resolveCurrentRoom(endpoint) {
var inputUrls = buildResolveUrlCandidates();
var lastError = null;
for (var i = 0; i < inputUrls.length; i += 1) {
try {
var response = await requestJSON('POST', joinUrl(endpoint, '/osrp/v1/resolve'), {
url: inputUrls[i],
});
return getSuccessData(response);
} catch (error) {
lastError = error;
}
}
if (lastError) {
console.warn('[bililive-go] 解析当前页面失败:', lastError.message);
}
return null;
}
function buildResolveUrlCandidates() {
var currentUrl = getCurrentRoomUrl();
var values = [currentUrl];
try {
var parsed = new URL(currentUrl);
var aliasHost = LOCAL_ALIAS_HOSTS[parsed.hostname];
if (aliasHost) {
parsed.hostname = aliasHost;
values.push(parsed.toString());
}
} catch (error) {
console.warn('[bililive-go] 当前页面 URL 解析失败:', error);
}
return values.filter(function (value, index, list) {
return list.indexOf(value) === index;
});
}
function normalizeCompareUrl(input) {
if (!input) {
return '';
}
try {
var parsed = new URL(String(input).trim());
var aliasHost = LOCAL_ALIAS_HOSTS[parsed.hostname];
if (aliasHost) {
parsed.hostname = aliasHost;
}
parsed.hash = '';
var trackingParams = {
spm_id_from: true,
visit_id: true,
session_id: true,
live_from: true,
from: true,
from_id: true,
share_from: true,
share_source: true,
share_medium: true,
share_plat: true,
share_token: true,
source: true,
refer_from: true,
utm_source: true,
utm_medium: true,
utm_campaign: true,
utm_content: true,
utm_term: true,
launch_id: true,
broadcast_type: true,
is_room_feed: true,
};
Object.keys(trackingParams).forEach(function (key) {
if (parsed.searchParams.has(key)) {
parsed.searchParams.delete(key);
}
});
var pathname = parsed.pathname || '/';
if (pathname.length > 1) {
pathname = pathname.replace(/\/+$/, '');
}
parsed.pathname = pathname || '/';
var queryPairs = [];
parsed.searchParams.forEach(function (value, key) {
queryPairs.push([key, value]);
});
queryPairs.sort(function (a, b) {
if (a[0] === b[0]) {
return a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0;
}
return a[0] < b[0] ? -1 : 1;
});
parsed.search = '';
for (var i = 0; i < queryPairs.length; i += 1) {
parsed.searchParams.append(queryPairs[i][0], queryPairs[i][1]);
}
return parsed.toString();
} catch (error) {
return String(input).trim();
}
}
function collectRoomUrlCandidates(room) {
var candidates = [];
function addCandidate(value) {
if (!value) {
return;
}
var normalized = normalizeCompareUrl(value);
if (!normalized) {
return;
}
if (candidates.indexOf(normalized) !== -1) {
return;
}
candidates.push(normalized);
}
addCandidate(getCurrentRoomUrl());
var resolveCandidates = buildResolveUrlCandidates();
for (var i = 0; i < resolveCandidates.length; i += 1) {
addCandidate(resolveCandidates[i]);
}
if (room) {
addCandidate(room.canonical_url);
}
return candidates;
}
async function getTasks(endpoint) {
var response = await requestJSON('GET', joinUrl(endpoint, '/osrp/v1/tasks'));
var data = getSuccessData(response);
if (!data || !Array.isArray(data.tasks)) {
return [];
}
return data.tasks;
}
function isAlreadyAdded(tasks, room) {
if (!Array.isArray(tasks) || !room) {
return false;
}
var roomUrlCandidates = collectRoomUrlCandidates(room);
for (var i = 0; i < tasks.length; i += 1) {
var task = tasks[i];
if (!task) {
continue;
}
if (task.id && room.stream_id && task.id === room.stream_id) {
return true;
}
if (task.stream_id && room.stream_id && task.stream_id === room.stream_id) {
return true;
}
var taskUrl = normalizeCompareUrl(task.url);
if (taskUrl && roomUrlCandidates.indexOf(taskUrl) !== -1) {
return true;
}
}
return false;
}
async function addTask(endpoint, url) {
var response = await requestJSON('POST', joinUrl(endpoint, '/osrp/v1/tasks'), {
url: url,
auto_start: true,
});
getSuccessData(response);
}
function joinUrl(base, path) {
return String(base).replace(/\/+$/, '') + '/' + String(path).replace(/^\/+/, '');
}
function getExtraHeaders() {
var rawValue = GM_getValue(STORAGE_KEYS.extraHeaders, '{}');
if (!rawValue) {
return {};
}
try {
var parsed = typeof rawValue === 'string' ? JSON.parse(rawValue) : rawValue;
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
return {};
}
var headers = {};
Object.keys(parsed).forEach(function (key) {
var headerName = String(key).trim();
if (!headerName) {
return;
}
var headerValue = parsed[key];
if (headerValue === undefined || headerValue === null) {
return;
}
headers[headerName] = String(headerValue);
});
return headers;
} catch (error) {
console.warn('[bililive-go] 额外请求头解析失败:', error);
return {};
}
}
function getSuccessData(response) {
if (!response) {
throw new Error('响应为空');
}
if (response.status < 200 || response.status >= 300) {
throw new Error(extractErrorMessage(response) || '请求失败,HTTP ' + response.status);
}
if (!response.data || response.data.success !== true) {
throw new Error(extractErrorMessage(response) || '接口返回失败');
}
return response.data.data;
}
function extractErrorMessage(response) {
if (!response || !response.data) {
return '';
}
if (response.data.error && response.data.error.message) {
return response.data.error.message;
}
if (typeof response.data.message === 'string') {
return response.data.message;
}
return '';
}
function requestJSON(method, url, body) {
return new Promise(function (resolve, reject) {
var headers = getExtraHeaders();
if (body) {
headers['Content-Type'] = 'application/json';
}
GM_xmlhttpRequest({
method: method,
url: url,
timeout: 4000,
headers: Object.keys(headers).length > 0 ? headers : undefined,
data: body ? JSON.stringify(body) : undefined,
onload: function (response) {
var data = null;
try {
data = response.responseText ? JSON.parse(response.responseText) : null;
} catch (error) {
reject(new Error('返回了非 JSON 数据'));
return;
}
resolve({
status: response.status,
data: data,
});
},
onerror: function () {
reject(new Error('网络请求失败'));
},
ontimeout: function () {
reject(new Error('请求超时'));
},
});
});
}
})();