chore: 升级依赖包版本并添加 EventFetcher 功能开关

- 升级核心依赖:@hyperdx/browser、@microsoft/signalr、@tauri-apps 系列插件、@vueuse 系列、vue、vue-router 等至最新版本
- 新增 obs-websocket-js 依赖用于 OBS 集成
- 添加 EventFetcher 功能总开关,支持完全禁用事件收集功能
- 优化弹幕客户端配置界面,增加功能说明和状态提示
- 改进首页布局,添加快速入口导航
- 在客户端布局中新增直播管理菜单项
This commit is contained in:
2025-11-17 18:27:14 +08:00
parent b51257f861
commit 9691704da5
18 changed files with 3260 additions and 77 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -11,29 +11,29 @@
"knip": "knip" "knip": "knip"
}, },
"dependencies": { "dependencies": {
"@hyperdx/browser": "^0.21.2", "@hyperdx/browser": "^0.22.0",
"@hyperdx/cli": "^0.1.0", "@hyperdx/cli": "^0.1.0",
"@microsoft/signalr": "^9.0.6", "@microsoft/signalr": "^10.0.0",
"@microsoft/signalr-protocol-msgpack": "^9.0.6", "@microsoft/signalr-protocol-msgpack": "^10.0.0",
"@mixer/postmessage-rpc": "^1.1.4", "@mixer/postmessage-rpc": "^1.1.4",
"@oneidentity/zstd-js": "^1.0.3", "@oneidentity/zstd-js": "^1.0.3",
"@tauri-apps/api": "^2.8.0", "@tauri-apps/api": "^2.9.0",
"@tauri-apps/plugin-autostart": "^2.5.0", "@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-http": "^2.5.2", "@tauri-apps/plugin-http": "^2.5.4",
"@tauri-apps/plugin-log": "^2.7.0", "@tauri-apps/plugin-log": "^2.7.1",
"@tauri-apps/plugin-notification": "^2.3.1", "@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.5.0", "@tauri-apps/plugin-opener": "^2.5.2",
"@tauri-apps/plugin-os": "^2.3.1", "@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.0", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-store": "^2.4.0", "@tauri-apps/plugin-store": "^2.4.1",
"@tauri-apps/plugin-updater": "^2.9.0", "@tauri-apps/plugin-updater": "^2.9.0",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/md5": "^2.3.5", "@types/md5": "^2.3.6",
"@vicons/fluent": "^0.13.0", "@vicons/fluent": "^0.13.0",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"@vueuse/core": "^13.9.0", "@vueuse/core": "^14.0.0",
"@vueuse/integrations": "^13.9.0", "@vueuse/integrations": "^14.0.0",
"@vueuse/router": "^13.9.0", "@vueuse/router": "^14.0.0",
"@wangeditor/editor": "^5.1.23", "@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12", "@wangeditor/editor-for-vue": "^5.1.12",
"bilibili-live-danmaku": "^0.7.14", "bilibili-live-danmaku": "^0.7.14",
@@ -41,7 +41,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"easy-speech": "^2.4.0", "easy-speech": "^2.4.0",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"fast-xml-parser": "^5.2.5", "fast-xml-parser": "^5.3.2",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"grapheme-splitter": "^1.0.4", "grapheme-splitter": "^1.0.4",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
@@ -52,24 +52,25 @@
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"md5": "^2.3.0", "md5": "^2.3.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"monaco-editor": "^0.53.0", "monaco-editor": "^0.54.0",
"naive-ui": "2.42.0", "naive-ui": "2.43.2",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"obs-websocket-js": "^5.0.7",
"peerjs": "^1.5.5", "peerjs": "^1.5.5",
"pinia": "^3.0.3", "pinia": "^3.0.4",
"qrcode.vue": "^3.6.0", "qrcode.vue": "^3.6.0",
"unplugin-auto-import": "^20.2.0", "unplugin-auto-import": "^20.2.0",
"unplugin-vue-components": "^29.1.0", "unplugin-vue-components": "^30.0.0",
"unplugin-vue-markdown": "^29.2.0", "unplugin-vue-markdown": "^29.2.0",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"vite": "npm:rolldown-vite@latest", "vite": "npm:rolldown-vite@7.2.5",
"vite-plugin-monaco-editor-nls": "^3.0.1", "vite-plugin-monaco-editor-nls": "^3.0.1",
"vite-svg-loader": "^5.1.0", "vite-svg-loader": "^5.1.0",
"vue": "3.5.22", "vue": "3.5.24",
"vue-echarts": "^8.0.0", "vue-echarts": "^8.0.1",
"vue-img-cutter": "^3.0.7", "vue-img-cutter": "^3.0.7",
"vue-request": "^2.0.4", "vue-request": "^2.0.4",
"vue-router": "^4.5.1", "vue-router": "^4.6.3",
"vue-toastification": "^1.7.14", "vue-toastification": "^1.7.14",
"vue-turnstile": "^1.0.11", "vue-turnstile": "^1.0.11",
"vue3-aplayer": "^1.7.3", "vue3-aplayer": "^1.7.3",
@@ -79,22 +80,22 @@
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^5.4.1", "@antfu/eslint-config": "^6.2.0",
"@types/bun": "^1.2.23", "@types/bun": "^1.3.2",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/jszip": "^3.4.1", "@types/jszip": "^3.4.1",
"@types/uuid": "^11.0.0", "@types/uuid": "^11.0.0",
"@vicons/ionicons5": "^0.13.0", "@vicons/ionicons5": "^0.13.0",
"@vitejs/plugin-vue-jsx": "^5.1.1", "@vitejs/plugin-vue-jsx": "^5.1.1",
"@vue-vine/eslint-config": "^1.1.9", "@vue-vine/eslint-config": "^1.1.11",
"eslint": "^9.36.0", "eslint": "^9.39.1",
"eslint-plugin-oxlint": "^1.19.0", "eslint-plugin-oxlint": "^1.28.0",
"oxlint": "^1.19.0", "oxlint": "^1.28.0",
"rollup-plugin-visualizer": "^6.0.4", "rollup-plugin-visualizer": "^6.0.5",
"stylus": "^0.64.0", "stylus": "^0.64.0",
"typescript": "^5.9.2", "typescript": "^5.9.3",
"vite-plugin-cdn-import": "^1.0.1", "vite-plugin-cdn-import": "^1.0.1",
"vscode-loc": "git+https://github.com/microsoft/vscode-loc.git", "vscode-loc": "git+https://github.com/microsoft/vscode-loc.git",
"vue-vine": "^1.7.6" "vue-vine": "^1.7.23"
} }
} }

View File

@@ -208,6 +208,7 @@ declare global {
const lastDayOfYear: typeof import('date-fns')['lastDayOfYear'] const lastDayOfYear: typeof import('date-fns')['lastDayOfYear']
const lightFormat: typeof import('date-fns')['lightFormat'] const lightFormat: typeof import('date-fns')['lightFormat']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable'] const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
const manualResetRef: typeof import('@vueuse/core')['manualResetRef']
const mapActions: typeof import('pinia')['mapActions'] const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters'] const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState'] const mapState: typeof import('pinia')['mapState']
@@ -282,6 +283,7 @@ declare global {
const refAutoReset: typeof import('@vueuse/core')['refAutoReset'] const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
const refDebounced: typeof import('@vueuse/core')['refDebounced'] const refDebounced: typeof import('@vueuse/core')['refDebounced']
const refDefault: typeof import('@vueuse/core')['refDefault'] const refDefault: typeof import('@vueuse/core')['refDefault']
const refManualReset: typeof import('@vueuse/core')['refManualReset']
const refThrottled: typeof import('@vueuse/core')['refThrottled'] const refThrottled: typeof import('@vueuse/core')['refThrottled']
const refWithControl: typeof import('@vueuse/core')['refWithControl'] const refWithControl: typeof import('@vueuse/core')['refWithControl']
const resolveComponent: typeof import('vue')['resolveComponent'] const resolveComponent: typeof import('vue')['resolveComponent']

View File

@@ -71,6 +71,7 @@ import {
NRadioGroup, NRadioGroup,
NSpin, NSpin,
NStatistic, NStatistic,
NSwitch,
NTabPane, NTabPane,
NTabs, NTabs,
NTag, NTag,
@@ -85,7 +86,7 @@ import { useAccount } from '@/api/account'
import { useWebFetcher } from '@/store/useWebFetcher' import { useWebFetcher } from '@/store/useWebFetcher'
import { getLoginInfoAsync, getLoginUrlDataAsync } from './data/biliLogin' import { getLoginInfoAsync, getLoginUrlDataAsync } from './data/biliLogin'
import { currentStatistic, getHistoricalStatistics, streamingInfo } from './data/info' import { currentStatistic, getHistoricalStatistics, streamingInfo } from './data/info'
import { callStartDanmakuClient } from './data/initialize' import { callStartDanmakuClient, resetDanmakuClientInitState } from './data/initialize'
import { COOKIE_CLOUD_KEY, useBiliCookie } from './store/useBiliCookie' import { COOKIE_CLOUD_KEY, useBiliCookie } from './store/useBiliCookie'
import { useSettings } from './store/useSettings' import { useSettings } from './store/useSettings'
import { useTauriStore } from './store/useTauriStore' import { useTauriStore } from './store/useTauriStore'
@@ -607,6 +608,30 @@ async function logout() {
message.info('已退出登录') message.info('已退出登录')
} }
// 处理 EventFetcher 开关切换
async function handleToggleEventFetcher(enabled: boolean) {
await settings.save()
if (enabled) {
// 启用 EventFetcher
message.info('正在启动 EventFetcher...')
const result = await callStartDanmakuClient()
if (result.success) {
message.success('EventFetcher 已启动')
} else {
message.error(`EventFetcher 启动失败: ${result.message}`)
}
} else {
// 禁用 EventFetcher
if (webfetcher.state !== 'disconnected') {
webfetcher.Stop()
message.info('EventFetcher 已停止')
}
// 重置弹幕客户端初始化状态,确保重新启用时能正确连接
resetDanmakuClientInitState()
}
}
// --- Watchers --- // --- Watchers ---
watch(() => webfetcher.state, (newState) => { watch(() => webfetcher.state, (newState) => {
if (newState === 'connected') { if (newState === 'connected') {
@@ -727,10 +752,76 @@ onUnmounted(() => {
embedded embedded
style="width: 100%; max-width: 800px;" style="width: 100%; max-width: 800px;"
> >
<NFlex vertical> <NFlex
vertical
gap="large"
>
<!-- EventFetcher 功能开关 -->
<div>
<NFlex
align="center"
justify="space-between"
style="margin-bottom: 0.5rem;"
>
<div>
<NText strong>
EventFetcher 功能
</NText>
<NTooltip>
<template #trigger>
<NIcon
:component="HelpCircle"
style="margin-left: 0.25rem; cursor: help;"
/>
</template>
<div style="max-width: 300px;">
<p style="margin: 0 0 8px;">启用后系统将会</p>
<ul style="padding-left: 18px; margin: 0;">
<li>连接到 SignalR 服务器</li>
<li>启动弹幕客户端接收直播间消息</li>
<li>收集并上传直播间事件数据</li>
<li>显示实时统计信息</li>
</ul>
<p style="margin: 8px 0 0;">关闭后所有 EventFetcher 相关功能将停止工作</p>
</div>
</NTooltip>
</div>
<NSwitch
v-model:value="settings.settings.enableEventFetcher"
:disabled="webfetcher.state === 'connecting'"
@update:value="handleToggleEventFetcher"
>
<template #checked>
已启用
</template>
<template #unchecked>
已禁用
</template>
</NSwitch>
</NFlex>
<NAlert
v-if="!settings.settings.enableEventFetcher"
type="warning"
:bordered="false"
style="margin-top: 0.5rem;"
>
EventFetcher 功能已禁用直播间事件数据将不会被收集和上传
</NAlert>
</div>
<NDivider style="margin: 0;" />
<!-- 弹幕客户端模式选择 -->
<div>
<NText
strong
style="display: block; margin-bottom: 0.5rem;"
>
弹幕客户端模式
</NText>
<NRadioGroup <NRadioGroup
v-model:value="settings.settings.useDanmakuClientType" v-model:value="settings.settings.useDanmakuClientType"
:disabled="webfetcher.state === 'connecting'" :disabled="webfetcher.state === 'connecting' || !settings.settings.enableEventFetcher"
@update-value="v => onSwitchDanmakuClientMode(v)" @update-value="v => onSwitchDanmakuClientMode(v)"
> >
<NRadioButton value="openlive"> <NRadioButton value="openlive">
@@ -738,7 +829,7 @@ onUnmounted(() => {
</NRadioButton> </NRadioButton>
<NRadioButton <NRadioButton
value="direct" value="direct"
:disabled="!biliCookie.isCookieValid" :disabled="!biliCookie.isCookieValid || !settings.settings.enableEventFetcher"
> >
<NTooltip v-if="!biliCookie.isCookieValid"> <NTooltip v-if="!biliCookie.isCookieValid">
<template #trigger> <template #trigger>
@@ -751,9 +842,11 @@ onUnmounted(() => {
</NText> </NText>
</NRadioButton> </NRadioButton>
</NRadioGroup> </NRadioGroup>
</div>
<NPopconfirm <NPopconfirm
type="info" type="info"
:disabled="webfetcher.state === 'connecting'" :disabled="webfetcher.state === 'connecting' || !settings.settings.enableEventFetcher"
@positive-click="async () => { @positive-click="async () => {
await onSwitchDanmakuClientMode(settings.settings.useDanmakuClientType, true); await onSwitchDanmakuClientMode(settings.settings.useDanmakuClientType, true);
message.success('已重启弹幕服务器'); message.success('已重启弹幕服务器');
@@ -762,8 +855,8 @@ onUnmounted(() => {
<template #trigger> <template #trigger>
<NButton <NButton
type="error" type="error"
style="max-width: 150px;" style="max-width: 180px;"
:disabled="webfetcher.state === 'connecting'" :disabled="webfetcher.state === 'connecting' || !settings.settings.enableEventFetcher"
> >
强制重启弹幕客户端 强制重启弹幕客户端
</NButton> </NButton>
@@ -777,6 +870,7 @@ onUnmounted(() => {
<!-- Overall Status & Connection Details --> <!-- Overall Status & Connection Details -->
<NCard <NCard
v-if="settings.settings.enableEventFetcher"
title="运行状态 & 连接" title="运行状态 & 连接"
embedded embedded
style="width: 100%; max-width: 800px;" style="width: 100%; max-width: 800px;"
@@ -1148,7 +1242,7 @@ onUnmounted(() => {
<!-- Live Stream Info --> <!-- Live Stream Info -->
<NCard <NCard
v-if="settings.settings.useDanmakuClientType === 'openlive'" v-if="settings.settings.enableEventFetcher && settings.settings.useDanmakuClientType === 'openlive'"
title="直播间信息" title="直播间信息"
embedded embedded
style="width: 100%; max-width: 800px;" style="width: 100%; max-width: 800px;"
@@ -1207,6 +1301,7 @@ onUnmounted(() => {
<!-- Session Statistics --> <!-- Session Statistics -->
<NCard <NCard
v-if="settings.settings.enableEventFetcher"
title="会话实时统计" title="会话实时统计"
embedded embedded
style="width: 100%; max-width: 800px;" style="width: 100%; max-width: 800px;"
@@ -1286,6 +1381,7 @@ onUnmounted(() => {
<!-- Daily Statistics --> <!-- Daily Statistics -->
<NCard <NCard
v-if="settings.settings.enableEventFetcher"
title="今日统计" title="今日统计"
embedded embedded
style="width: 100%; max-width: 800px;" style="width: 100%; max-width: 800px;"
@@ -1346,6 +1442,7 @@ onUnmounted(() => {
<!-- Historical Statistics --> <!-- Historical Statistics -->
<NCard <NCard
v-if="settings.settings.enableEventFetcher"
title="历史事件量 (近30日)" title="历史事件量 (近30日)"
embedded embedded
style="width: 100%; max-width: 800px;" style="width: 100%; max-width: 800px;"

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import { useElementSize } from '@vueuse/core' import { useElementSize } from '@vueuse/core'
import { Live24Filled, CloudArchive24Filled, FlashAuto24Filled, Mic24Filled, Settings24Filled } from '@vicons/fluent'
import { cookie, useAccount } from '@/api/account' import { cookie, useAccount } from '@/api/account'
import { useWebFetcher } from '@/store/useWebFetcher' import { useWebFetcher } from '@/store/useWebFetcher'
import { roomInfo } from './data/info' import { roomInfo } from './data/info'
@@ -24,22 +25,55 @@ function logout() {
<template> <template>
<NFlex <NFlex
class="client-index-layout"
justify="center" justify="center"
align="center" align="flex-start"
gap="large" gap="large"
wrap
> >
<NCard <NCard
title="首页" title="首页"
embedded embedded
size="small" size="small"
class="client-index-card"
> >
<div> <NFlex
vertical
gap="small"
>
<NText>
你好, {{ accountInfo.name }} 你好, {{ accountInfo.name }}
</div> </NText>
<NDivider style="margin: 8px 0" />
<NText depth="3" style="font-size: 13px;">
快速入口
</NText>
<NFlex
vertical
gap="8"
style="margin-top: 4px;"
>
<NButton
type="primary"
block
class="client-index-quick-entry-button"
@click="$router.push({ name: 'client-live-manage' })"
>
<template #icon>
<NIcon :component="Live24Filled" />
</template>
进入直播管理
</NButton>
</NFlex>
</NFlex>
</NCard> </NCard>
<NCard <NCard
title="账号" title="账号"
embedded embedded
class="client-index-card"
> >
<div> <div>
<NFlex <NFlex
@@ -90,7 +124,7 @@ function logout() {
</div> </div>
</template> </template>
</NCard> </NCard>
<NCard> <NCard class="client-index-card">
<template #header> <template #header>
<NSpace align="center"> <NSpace align="center">
直播状态 直播状态
@@ -142,3 +176,19 @@ function logout() {
</NCard> </NCard>
</NFlex> </NFlex>
</template> </template>
<style scoped>
.client-index-layout {
max-width: 1200px;
margin: 0 auto;
padding: 16px;
}
.client-index-card {
min-width: 260px;
}
.client-index-quick-entry-button + .client-index-quick-entry-button {
margin-top: 4px;
}
</style>

View File

@@ -4,7 +4,7 @@ import type { MenuOption } from 'naive-ui'
// 引入 Tauri 插件 // 引入 Tauri 插件
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import { Chat24Filled, CloudArchive24Filled, FlashAuto24Filled, Mic24Filled, Settings24Filled } from '@vicons/fluent' import { Chat24Filled, CloudArchive24Filled, FlashAuto24Filled, Live24Filled, Mic24Filled, Settings24Filled } from '@vicons/fluent'
import { CheckmarkCircle, CloseCircle, Home } from '@vicons/ionicons5' import { CheckmarkCircle, CloseCircle, Home } from '@vicons/ionicons5'
import { NA, NButton, NCard, NInput, NLayout, NLayoutContent, NLayoutSider, NMenu, NSpace, NSpin, NText, NTooltip } from 'naive-ui' import { NA, NButton, NCard, NInput, NLayout, NLayoutContent, NLayoutSider, NMenu, NSpace, NSpin, NText, NTooltip } from 'naive-ui'
@@ -103,6 +103,12 @@ const menuOptions = computed(() => {
key: 'fetcher', key: 'fetcher',
icon: () => h(CloudArchive24Filled), icon: () => h(CloudArchive24Filled),
}, },
{
label: () =>
h(RouterLink, { to: { name: 'client-live-manage' } }, () => '直播管理'),
key: 'live-manage',
icon: () => h(Live24Filled),
},
{ {
label: () => label: () =>
h(RouterLink, { to: { name: 'client-danmaku-window-manage' } }, () => '弹幕机'), h(RouterLink, { to: { name: 'client-danmaku-window-manage' } }, () => '弹幕机'),

File diff suppressed because it is too large Load Diff

View File

@@ -34,6 +34,38 @@ let resetTimeout: number | null = null // 用于重置计数器的超时ID
const setting = useSettings() const setting = useSettings()
const currentVersion = await getVersion() const currentVersion = await getVersion()
// 更新检查
const isCheckingUpdate = ref(false)
const handleCheckUpdate = async () => {
isCheckingUpdate.value = true
try {
const { check } = await import('@tauri-apps/plugin-updater')
const update = await check()
if (update) {
window.$message.info(`发现新版本 ${update.version},正在下载更新...`)
// 下载并安装更新
await update.downloadAndInstall()
window.$message.success('更新已下载,重启应用以完成更新')
// 询问是否立即重启
const { relaunch } = await import('@tauri-apps/plugin-process')
setTimeout(() => relaunch(), 2000)
} else {
window.$message.success('当前已是最新版本')
}
}
catch (err: any) {
console.error('检查更新失败:', err)
window.$message.error(`检查更新失败: ${err}`)
}
finally {
isCheckingUpdate.value = false
}
}
// Navigation // Navigation
const navOptions: MenuOption[] = [ const navOptions: MenuOption[] = [
{ label: '常规', key: 'general' }, { label: '常规', key: 'general' },
@@ -361,6 +393,16 @@ function handleTitleClick() {
<p> <p>
反馈: 🐧 873260337 反馈: 🐧 873260337
</p> </p>
<NDivider />
<NFlex align="center" justify="space-between">
<NText>检查更新</NText>
<NButton
:loading="isCheckingUpdate"
@click="handleCheckUpdate"
>
检查更新
</NButton>
</NFlex>
</NCard> </NCard>
</template> </template>
</div> </div>

View File

@@ -0,0 +1,582 @@
import { QueryBiliAPI } from '../data/utils'
import { useBiliCookie } from '../store/useBiliCookie'
import CryptoJS from 'crypto-js'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
/**
* 直播姬版本信息
*/
export interface LiveVersionInfo {
curr_version: string // 直播姬最新版本号
build: number // 直播姬构建号
instruction: string // 更新说明(简要)
file_size: string // 文件大小(字节)
file_md5: string // 安装包文件MD5
content: string // HTML格式的更新内容
download_url: string // 安装包下载链接
hdiffpatch_switch: number // 增量更新开关
}
/**
* MD5 哈希实现 - 使用 crypto-js
*/
function md5(str: string): string {
return CryptoJS.MD5(str).toString()
}
/**
* APP签名函数 - 用于B站API签名
* @param params 已包含appkey的参数字典
* @param appsec app secret密钥
*/
function appSign(params: Record<string, any>, appsec: string): Record<string, any> {
// 按 key 排序参数
const sortedKeys = Object.keys(params).sort()
const sortedParams: Record<string, any> = {}
sortedKeys.forEach(key => {
sortedParams[key] = params[key]
})
// 序列化参数为 key=value&key=value 格式
const queryString = sortedKeys.map(key => `${key}=${params[key]}`).join('&')
// 计算 MD5 签名
const signString = queryString + appsec
const sign = md5(signString)
console.log('签名字符串:', signString)
console.log('签名结果:', sign)
// 添加签名
sortedParams.sign = sign
return sortedParams
}
/**
* 获取当前时间戳
*/
async function getTimestamp(): Promise<number> {
try {
const resp = await QueryBiliAPI(
'https://api.bilibili.com/x/report/click/now',
'GET',
'',
false,
)
const json = await resp.json()
if (json.code === 0 && json.data?.now) {
return json.data.now
}
}
catch (err) {
console.error('获取服务器时间戳失败,使用本地时间:', err)
}
return Math.floor(Date.now() / 1000)
}
/**
* 获取直播姬版本号
*/
export async function getLiveVersion(): Promise<LiveVersionInfo | null> {
try {
console.log('正在获取直播姬版本号')
const appkey = 'aae92bc66f3edfab'
const appsec = 'af125a0d5279fd576c1b4418a3e8276d'
const ts = await getTimestamp()
// 准备参数并签名
const params = appSign({
appkey: appkey,
system_version: 2,
ts,
}, appsec)
const query = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
query.append(key, String(value))
})
const resp = await QueryBiliAPI(
`https://api.live.bilibili.com/xlive/app-blink/v1/liveVersionInfo/getHomePageLiveVersion?${query.toString()}`,
'GET',
'',
false,
)
const json = await resp.json()
if (json.code === 0 && json.data) {
console.log('获取直播姬版本成功:', json.data.curr_version, 'build:', json.data.build)
return json.data
}
return null
}
catch (err) {
console.error('获取直播姬版本失败:', err)
return null
}
}
/**
* 直播间管理API
*/
/**
* 开始直播
* @param roomId 直播间ID
* @param areaV2 直播分区ID子分区ID
* @param platform 直播平台 pc | pc_link | android_link
* @param version 直播姬版本号(可选)
* @param build 直播姬构建号(可选)
*/
export interface StartLiveParams {
roomId: number
areaV2: number
platform?: 'pc' | 'pc_link' | 'android_link'
version?: string
build?: number
}
export interface StartLiveResponse {
code: number
msg: string
message: string
data?: {
change: number
status: string
room_type: number
rtmp: {
addr: string // RTMP推流地址
code: string // RTMP推流参数密钥
new_link: string
provider: string
}
protocols: Array<{
protocol: string
addr: string
code: string
new_link: string
provider: string
}>
try_time: string
live_key: string
sub_session_key: string
notice: any
qr?: string // 人脸认证二维码
need_face_auth: boolean
service_source: string
rtmp_backup: any
up_stream_extra: {
isp: string
}
}
}
// 开播错误码
export enum StartLiveErrorCode {
SUCCESS = 0,
NEED_FACE_AUTH = 60024, // 需要人脸认证
}
/**
* 开始直播
*/
export async function startLive(params: StartLiveParams): Promise<StartLiveResponse> {
const biliCookieStore = useBiliCookie()
const cookie = await biliCookieStore.getBiliCookie()
console.log('正在开始直播: ', params)
if (!cookie) {
throw new Error('未登录或Cookie无效')
}
// 从cookie中提取bili_jct作为csrf
const csrfMatch = cookie.match(/bili_jct=([^;]+)/)
const csrf = csrfMatch ? csrfMatch[1] : ''
if (!csrf) {
throw new Error('无法获取CSRF令牌')
}
// 准备参数
const appkey = 'aae92bc66f3edfab'
const appsec = 'af125a0d5279fd576c1b4418a3e8276d'
// 获取时间戳
const ts = await getTimestamp()
const requestParams: Record<string, any> = {
access_key: '', // 留空
appkey: appkey,
platform: params.platform || 'pc_link',
room_id: params.roomId,
area_v2: params.areaV2,
build: params.build?.toString() || '9343',
backup_stream: 0,
csrf: csrf,
csrf_token: csrf,
ts: ts.toString(),
}
// 对参数按字典序排序并签名
const signedParams = appSign(requestParams, appsec)
console.log('已对参数进行签名')
console.log('开播请求参数:', signedParams)
// 将参数作为URL查询字符串而不是POST body
const query = new URLSearchParams()
Object.entries(signedParams).forEach(([key, value]) => {
query.append(key, String(value))
})
const resp = await QueryBiliAPI(
`https://api.live.bilibili.com/room/v1/Room/startLive?${query.toString()}`,
'POST',
cookie,
true,
)
const json = await resp.json()
console.log('开播响应:', json)
return json as StartLiveResponse
}
/**
* 关闭直播
*/
export interface StopLiveParams {
roomId: number
platform?: 'pc' | 'pc_link' | 'android_link'
}
export interface StopLiveResponse {
code: number
msg: string
message: string
data?: {
change: number
status: string
}
}
/**
* 关闭直播
*/
export async function stopLive(params: StopLiveParams): Promise<StopLiveResponse> {
const biliCookieStore = useBiliCookie()
const cookie = await biliCookieStore.getBiliCookie()
if (!cookie) {
throw new Error('未登录或Cookie无效')
}
const csrfMatch = cookie.match(/bili_jct=([^;]+)/)
const csrf = csrfMatch ? csrfMatch[1] : ''
if (!csrf) {
throw new Error('无法获取CSRF令牌')
}
const formData = new URLSearchParams()
formData.append('platform', params.platform || 'pc_link')
formData.append('room_id', params.roomId.toString())
formData.append('csrf', csrf)
const resp = await QueryBiliAPI(
'https://api.live.bilibili.com/room/v1/Room/stopLive',
'POST',
cookie,
true,
formData,
)
const json = await resp.json()
return json as StopLiveResponse
}
/**
* 更新直播间信息
*/
export interface UpdateRoomParams {
roomId: number
title?: string
areaId?: number
addTag?: string
delTag?: string
}
export interface UpdateRoomResponse {
code: number
msg: string
message: string
data?: {
sub_session_key: string
audit_info: {
audit_title_reason: string
audit_title_status: number
audit_title?: string
update_title: string
}
}
}
/**
* 更新直播间信息
*/
export async function updateRoom(params: UpdateRoomParams): Promise<UpdateRoomResponse> {
const biliCookieStore = useBiliCookie()
const cookie = await biliCookieStore.getBiliCookie()
if (!cookie) {
throw new Error('未登录或Cookie无效')
}
const csrfMatch = cookie.match(/bili_jct=([^;]+)/)
const csrf = csrfMatch ? csrfMatch[1] : ''
if (!csrf) {
throw new Error('无法获取CSRF令牌')
}
const formData = new URLSearchParams()
formData.append('room_id', params.roomId.toString())
formData.append('csrf', csrf)
formData.append('csrf_token', csrf)
if (params.title !== undefined) {
formData.append('title', params.title)
}
if (params.areaId !== undefined) {
formData.append('area_id', params.areaId.toString())
}
if (params.addTag !== undefined) {
formData.append('add_tag', params.addTag)
}
if (params.delTag !== undefined) {
formData.append('del_tag', params.delTag)
}
const resp = await QueryBiliAPI(
'https://api.live.bilibili.com/room/v1/Room/update',
'POST',
cookie,
true,
formData,
)
const json = await resp.json()
return json as UpdateRoomResponse
}
/**
* 获取直播分区列表
*/
export interface LiveArea {
id: number
name: string
parent_id: number
parent_name: string
}
export async function getLiveAreas(): Promise<LiveArea[]> {
const resp = await QueryBiliAPI('https://api.live.bilibili.com/room/v1/Area/getList', 'GET', '', false)
const json = await resp.json()
if (json.code === 0 && json.data) {
const areas: LiveArea[] = []
for (const parent of json.data) {
for (const child of parent.list) {
areas.push({
id: child.id,
name: child.name,
parent_id: parent.id,
parent_name: parent.name,
})
}
}
return areas
}
throw new Error('获取直播分区失败')
}
export interface UpdateRoomNewsParams {
roomId: number
content: string
}
export interface UpdateRoomNewsResponse {
code: number
message: string
data: any
ttl?: number
}
export async function updateRoomNews(params: UpdateRoomNewsParams): Promise<UpdateRoomNewsResponse> {
const biliCookieStore = useBiliCookie()
const cookie = await biliCookieStore.getBiliCookie()
if (!cookie) {
throw new Error('未登录或Cookie无效')
}
const csrfMatch = cookie.match(/bili_jct=([^;]+)/)
const csrf = csrfMatch ? csrfMatch[1] : ''
const uidMatch = cookie.match(/DedeUserID=([^;]+)/)
const uid = uidMatch ? uidMatch[1] : ''
if (!csrf || !uid) {
throw new Error('无法获取CSRF令牌或用户ID')
}
const formData = new URLSearchParams()
formData.append('room_id', params.roomId.toString())
formData.append('uid', uid)
formData.append('content', params.content ?? '')
formData.append('csrf', csrf)
formData.append('csrf_token', csrf)
const resp = await QueryBiliAPI(
'https://api.live.bilibili.com/xlive/app-blink/v1/index/updateRoomNews',
'POST',
cookie,
true,
formData,
)
const json = await resp.json()
return json as UpdateRoomNewsResponse
}
export interface UploadCoverResult {
location: string
etag?: string
image_url?: string
}
export interface UploadCoverResponse {
code: number
message: string
data?: UploadCoverResult
}
export async function uploadCover(file: File): Promise<UploadCoverResponse> {
const biliCookieStore = useBiliCookie()
const cookie = await biliCookieStore.getBiliCookie()
if (!cookie) {
throw new Error('未登录或Cookie无效')
}
const csrfMatch = cookie.match(/bili_jct=([^;]+)/)
const csrf = csrfMatch ? csrfMatch[1] : ''
if (!csrf) {
throw new Error('无法获取CSRF令牌')
}
const apiUrl = 'https://api.bilibili.com/x/upload/web/image'
const boundary = '----WebKitFormBoundary' + Math.random().toString(16).slice(2)
const encoder = new TextEncoder()
const parts: string[] = []
parts.push(
`--${boundary}\r\n` +
'Content-Disposition: form-data; name="bucket"\r\n\r\n' +
'live\r\n',
)
parts.push(
`--${boundary}\r\n` +
'Content-Disposition: form-data; name="dir"\r\n\r\n' +
'new_room_cover\r\n',
)
parts.push(
`--${boundary}\r\n` +
'Content-Disposition: form-data; name="csrf"\r\n\r\n' +
`${csrf}\r\n`,
)
parts.push(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="${file.name || 'blob'}"\r\n` +
`Content-Type: ${file.type || 'image/jpeg'}\r\n\r\n`,
)
const headBytes = encoder.encode(parts.join(''))
const fileBytes = new Uint8Array(await file.arrayBuffer())
const tailBytes = encoder.encode(`\r\n--${boundary}--\r\n`)
const body = new Uint8Array(headBytes.length + fileBytes.length + tailBytes.length)
body.set(headBytes, 0)
body.set(fileBytes, headBytes.length)
body.set(tailBytes, headBytes.length + fileBytes.length)
const headers: Record<string, string> = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0',
'Origin': 'https://www.bilibili.com',
'Referer': 'https://live.bilibili.com/',
'Cookie': cookie,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
}
const resp = await tauriFetch(apiUrl, {
method: 'POST',
headers,
body,
})
if (!resp.ok) {
throw new Error(`上传封面失败: HTTP ${resp.status} ${resp.statusText}`)
}
const json = await resp.json()
return json as UploadCoverResponse
}
export interface UpdateCoverResponse {
code: number
message: string
data?: any
}
export async function updateCover(coverUrl: string): Promise<UpdateCoverResponse> {
const biliCookieStore = useBiliCookie()
const cookie = await biliCookieStore.getBiliCookie()
if (!cookie) {
throw new Error('未登录或Cookie无效')
}
const csrfMatch = cookie.match(/bili_jct=([^;]+)/)
const csrf = csrfMatch ? csrfMatch[1] : ''
if (!csrf) {
throw new Error('无法获取CSRF令牌')
}
const formData = new URLSearchParams()
formData.append('platform', 'web')
formData.append('mobi_app', 'web')
formData.append('build', '1')
formData.append('cover', coverUrl)
formData.append('coverVertical', '')
formData.append('liveDirectionType', '1')
formData.append('csrf', csrf)
formData.append('csrf_token', csrf)
const resp = await QueryBiliAPI(
'https://api.live.bilibili.com/xlive/app-blink/v1/preLive/UpdatePreLiveInfo',
'POST',
cookie,
true,
formData,
)
const json = await resp.json()
return json as UpdateCoverResponse
}

View File

@@ -14,7 +14,7 @@ import {
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import { relaunch } from '@tauri-apps/plugin-process' import { relaunch } from '@tauri-apps/plugin-process'
import { check } from '@tauri-apps/plugin-updater' import { check } from '@tauri-apps/plugin-updater'
import { h, ref } from 'vue' import { h, ref, watch } from 'vue'
import { isLoggedIn, useAccount } from '@/api/account' import { isLoggedIn, useAccount } from '@/api/account'
import { CN_HOST, isDev } from '@/data/constants' import { CN_HOST, isDev } from '@/data/constants'
import { useWebFetcher } from '@/store/useWebFetcher' import { useWebFetcher } from '@/store/useWebFetcher'
@@ -23,8 +23,9 @@ import { useBiliCookie } from '../store/useBiliCookie'
import { useBiliFunction } from '../store/useBiliFunction' import { useBiliFunction } from '../store/useBiliFunction'
import { useDanmakuWindow } from '../store/useDanmakuWindow' import { useDanmakuWindow } from '../store/useDanmakuWindow'
import { useSettings } from '../store/useSettings' import { useSettings } from '../store/useSettings'
import { initInfo } from './info' import { initInfo, roomInfo } from './info'
import { getBuvid, getRoomKey } from './utils' import { getBuvid, getRoomKey } from './utils'
import { useTauriStore } from '../store/useTauriStore'
const accountInfo = useAccount() const accountInfo = useAccount()
@@ -35,6 +36,14 @@ let heartbeatTimer: number | null = null
let updateCheckTimer: number | null = null let updateCheckTimer: number | null = null
let updateNotificationRef: any = null let updateNotificationRef: any = null
// interface RtmpRelayState {
// roomId: number
// targetRtmpUrl: string
// }
// const RTMP_RELAY_STATE_KEY = 'webfetcher.rtmpRelay'
// let hasTriedAutoResumeRtmp = false
async function sendHeartbeat() { async function sendHeartbeat() {
try { try {
await invoke('heartbeat', undefined, { await invoke('heartbeat', undefined, {
@@ -45,6 +54,49 @@ async function sendHeartbeat() {
} }
} }
// async function tryAutoResumeRtmpRelay() {
// if (hasTriedAutoResumeRtmp) return
//
// const store = useTauriStore()
// const saved = await store.get<RtmpRelayState | null>(RTMP_RELAY_STATE_KEY)
// if (!saved || !saved.roomId || !saved.targetRtmpUrl) {
// hasTriedAutoResumeRtmp = true
// return
// }
//
// const room = roomInfo.value
// if (!room || room.live_status !== 1) {
// return
// }
//
// if (room.room_id !== saved.roomId) {
// hasTriedAutoResumeRtmp = true
// return
// }
//
// try {
// // 如果已经在进行 RTMP 转发,则不再重复启动
// try {
// const status = await invoke<{ is_relaying: boolean }>('get_rtmp_relay_status')
// if (status?.is_relaying) {
// info('[RTMP] 已在转发中,跳过自动恢复')
// hasTriedAutoResumeRtmp = true
// return
// }
// }
// catch (error) {
// warn(`[RTMP] 获取 RTMP 转发状态失败: ${error}`)
// }
//
// await invoke('start_rtmp_relay', { targetUrl: saved.targetRtmpUrl })
// info('[RTMP] 检测到正在开播,已自动恢复 RTMP 转发')
// } catch (error) {
// warn(`[RTMP] 自动恢复 RTMP 转发失败: ${error}`)
// } finally {
// hasTriedAutoResumeRtmp = true
// }
// }
export function startHeartbeat() { export function startHeartbeat() {
// 立即发送一次,确保后端在加载后快速收到心跳 // 立即发送一次,确保后端在加载后快速收到心跳
void sendHeartbeat() void sendHeartbeat()
@@ -379,6 +431,12 @@ export async function initAll(isOnBoot: boolean) {
startUpdateCheck() startUpdateCheck()
} }
// void tryAutoResumeRtmpRelay()
// watch(roomInfo, () => {
// void tryAutoResumeRtmpRelay()
// })
clientInited.value = true clientInited.value = true
clientInitStage.value = '启动完成' clientInitStage.value = '启动完成'
} }
@@ -400,6 +458,13 @@ export async function checkUpdate() {
export const isInitedDanmakuClient = ref(false) export const isInitedDanmakuClient = ref(false)
export const isInitingDanmakuClient = ref(false) export const isInitingDanmakuClient = ref(false)
// 重置弹幕客户端初始化状态
export function resetDanmakuClientInitState() {
isInitedDanmakuClient.value = false
isInitingDanmakuClient.value = false
info('弹幕客户端初始化状态已重置')
}
export async function initDanmakuClient() { export async function initDanmakuClient() {
const biliCookie = useBiliCookie() const biliCookie = useBiliCookie()
const settings = useSettings() const settings = useSettings()
@@ -407,6 +472,13 @@ export async function initDanmakuClient() {
info('弹幕客户端已初始化, 跳过初始化') info('弹幕客户端已初始化, 跳过初始化')
return { success: true, message: '' } return { success: true, message: '' }
} }
// 检查是否启用 EventFetcher
if (!settings.settings.enableEventFetcher) {
info('EventFetcher 功能已禁用, 跳过弹幕客户端初始化')
return { success: true, message: 'EventFetcher 已禁用' }
}
isInitingDanmakuClient.value = true isInitingDanmakuClient.value = true
console.log(settings.settings) console.log(settings.settings)
let result = { success: false, message: '' } let result = { success: false, message: '' }

View File

@@ -5,7 +5,7 @@ import { OPEN_LIVE_API_URL } from '@/data/constants'
import { useBiliCookie } from '../store/useBiliCookie' import { useBiliCookie } from '../store/useBiliCookie'
import { useBiliFunction } from '../store/useBiliFunction' import { useBiliFunction } from '../store/useBiliFunction'
export async function QueryBiliAPI(url: string, method: string = 'GET', cookie: string = '', useCookie: boolean = true) { export async function QueryBiliAPI(url: string, method: string = 'GET', cookie: string = '', useCookie: boolean = true, body?: string | URLSearchParams) {
const u = new URL(url) const u = new URL(url)
console.log(`调用bilibili api: ${url}`) console.log(`调用bilibili api: ${url}`)
const userAgents = [ const userAgents = [
@@ -17,13 +17,21 @@ export async function QueryBiliAPI(url: string, method: string = 'GET', cookie:
] ]
const randomUserAgent = userAgents[Math.floor(Math.random() * userAgents.length)] const randomUserAgent = userAgents[Math.floor(Math.random() * userAgents.length)]
return fetch(url, { const headers: Record<string, string> = {
method,
headers: {
'User-Agent': randomUserAgent, 'User-Agent': randomUserAgent,
'Origin': 'https://www.bilibili.com', 'Origin': 'https://www.bilibili.com',
'Referer': 'https://live.bilibili.com/',
'Cookie': useCookie ? (cookie || (await useBiliCookie().getBiliCookie()) || '') : '', 'Cookie': useCookie ? (cookie || (await useBiliCookie().getBiliCookie()) || '') : '',
}, }
if (body) {
headers['Content-Type'] = 'application/x-www-form-urlencoded'
}
return fetch(url, {
method,
headers,
body: body instanceof URLSearchParams ? body.toString() : body,
}) })
} }

View File

@@ -0,0 +1,551 @@
import { acceptHMRUpdate, defineStore } from 'pinia'
import { ref } from 'vue'
import OBSWebSocket from 'obs-websocket-js'
import { useTauriStore } from './useTauriStore'
// OBS配置接口
export interface ObsConfigState {
address: string
password?: string
}
// 场景配置接口
export interface ObsSceneConfig {
startScene?: string // 开播场景
stopScene?: string // 下播场景
waitingScene?: string // 等待场景
autoSwitchEnabled: boolean // 是否启用自动切换
autoToggleStream: boolean // 是否在开播下播后自动切换OBS推流状态
}
// OBS统计信息接口
export interface ObsStats {
cpuUsage: number | null
memoryUsage: number | null
fps: number | null
averageRenderTimeMs: number | null
renderSkippedFrames: number | null
renderTotalFrames: number | null
outputSkippedFrames: number | null
outputTotalFrames: number | null
bitrateKbps: number | null
}
export const useOBSStore = defineStore('obs', () => {
// 基础配置
const OBS_CONFIG_KEY = 'webfetcher.obsConfig'
const OBS_SCENE_CONFIG_KEY = 'webfetcher.obsSceneConfig'
const tauriStore = useTauriStore()
// 连接状态
const obsAddress = ref('ws://127.0.0.1:4455')
const obsPassword = ref('')
const obsConnected = ref(false)
const obsConnecting = ref(false)
const obsError = ref('')
const obsAutoReconnect = ref(false)
// 推流状态
const obsStreamActive = ref(false)
const obsStreamReconnecting = ref(false)
const isTogglingObsStream = ref(false)
// 统计信息
const obsStats = ref<ObsStats>({
cpuUsage: null,
memoryUsage: null,
fps: null,
averageRenderTimeMs: null,
renderSkippedFrames: null,
renderTotalFrames: null,
outputSkippedFrames: null,
outputTotalFrames: null,
bitrateKbps: null,
})
// 场景控制
const obsScenes = ref<string[]>([])
const currentObsScene = ref('')
const isSwitchingScene = ref(false)
const obsSceneError = ref('')
const obsSceneConfig = ref<ObsSceneConfig>({
autoSwitchEnabled: false,
autoToggleStream: true // 默认开启
})
// OBS实例和定时器
let obs: OBSWebSocket | null = null
let obsStatsTimer: number | null = null
let obsReconnectTimer: number | null = null
let lastObsBytes = 0
let lastObsBytesTimestamp = 0
// 初始化OBS实例
function ensureObsInstance() {
if (!obs) {
obs = new OBSWebSocket()
obs.on('ConnectionClosed', () => {
obsConnected.value = false
obsStreamActive.value = false
stopObsStatsLoop()
})
}
}
// 更新OBS统计信息
async function updateObsStats() {
if (!obs || !obsConnected.value) return
try {
const stats: any = await obs.call('GetStats')
obsStats.value.cpuUsage = typeof stats.cpuUsage === 'number' ? stats.cpuUsage : null
obsStats.value.memoryUsage = typeof stats.memoryUsage === 'number' ? stats.memoryUsage : null
obsStats.value.fps = typeof stats.activeFps === 'number' ? stats.activeFps : null
obsStats.value.averageRenderTimeMs = typeof stats.averageFrameRenderTime === 'number' ? stats.averageFrameRenderTime : null
obsStats.value.renderSkippedFrames = typeof stats.renderSkippedFrames === 'number' ? stats.renderSkippedFrames : null
obsStats.value.renderTotalFrames = typeof stats.renderTotalFrames === 'number' ? stats.renderTotalFrames : null
obsStats.value.outputSkippedFrames = typeof stats.outputSkippedFrames === 'number' ? stats.outputSkippedFrames : null
obsStats.value.outputTotalFrames = typeof stats.outputTotalFrames === 'number' ? stats.outputTotalFrames : null
const streamStatus: any = await obs.call('GetStreamStatus')
obsStreamActive.value = !!streamStatus.outputActive
obsStreamReconnecting.value = !!streamStatus.outputReconnecting
// 计算码率
const now = Date.now()
const bytes = typeof streamStatus.outputBytes === 'number' ? streamStatus.outputBytes : 0
if (lastObsBytesTimestamp && now > lastObsBytesTimestamp && bytes >= lastObsBytes) {
const deltaBytes = bytes - lastObsBytes
const deltaSeconds = (now - lastObsBytesTimestamp) / 1000
if (deltaSeconds > 0) {
const kbps = (deltaBytes * 8) / 1000 / deltaSeconds
obsStats.value.bitrateKbps = Number.isFinite(kbps) ? kbps : null
}
}
lastObsBytes = bytes
lastObsBytesTimestamp = now
// 获取当前场景
await updateCurrentScene()
}
catch (err) {
console.error('获取 OBS 统计失败:', err)
}
}
// 启动统计循环
function startObsStatsLoop() {
if (obsStatsTimer !== null) return
obsStatsTimer = window.setInterval(() => {
void updateObsStats()
}, 1000)
}
// 停止统计循环
function stopObsStatsLoop() {
if (obsStatsTimer !== null) {
clearInterval(obsStatsTimer)
obsStatsTimer = null
}
}
// 启动自动重连循环
function startObsAutoReconnectLoop() {
if (obsReconnectTimer !== null) return
// 如果当前条件满足,立即尝试连接一次
if (obsAutoReconnect.value &&
obsAddress.value &&
obsPassword.value &&
!obsConnected.value &&
!obsConnecting.value) {
void handleObsConnect()
}
// 启动定时重连循环
obsReconnectTimer = window.setInterval(() => {
if (!obsAutoReconnect.value) return
if (!obsAddress.value) return
if (obsConnected.value || obsConnecting.value) return
// 确保地址和密码都已设置才尝试连接
if (!obsPassword.value) return
void handleObsConnect()
}, 10000)
}
// 停止自动重连循环
function stopObsAutoReconnectLoop() {
if (obsReconnectTimer !== null) {
clearInterval(obsReconnectTimer)
obsReconnectTimer = null
}
}
// 连接OBS
async function handleObsConnect() {
console.log('handleObsConnect called')
console.log('obsConnected:', obsConnected.value, 'obsConnecting:', obsConnecting.value)
if (obsConnected.value || obsConnecting.value) {
console.log('Early return: already connected or connecting')
return
}
console.log('Starting OBS connection process...')
obsError.value = ''
obsConnecting.value = true
try {
ensureObsInstance()
if (!obs) {
throw new Error('OBS 实例未初始化')
}
const address = obsAddress.value || 'ws://127.0.0.1:4455'
const password = obsPassword.value || undefined
await obs.connect(address, password, {
rpcVersion: 1,
})
obsConnected.value = true
obsConnecting.value = false
obsAutoReconnect.value = true
startObsAutoReconnectLoop()
// 保存配置
try {
await tauriStore.set(OBS_CONFIG_KEY, {
address,
password: obsPassword.value || undefined,
} as ObsConfigState)
}
catch (err) {
console.error('保存 OBS 配置失败:', err)
}
startObsStatsLoop()
void updateObsStats()
// 连接成功后获取场景列表
void fetchObsScenes()
}
catch (err: any) {
console.error('连接 OBS 失败:', err)
obsError.value = err?.message || String(err)
obsConnected.value = false
obsConnecting.value = false
}
}
// 断开OBS连接
async function handleObsDisconnect() {
obsError.value = ''
obsAutoReconnect.value = false
stopObsStatsLoop()
stopObsAutoReconnectLoop()
try {
if (obs) {
await obs.disconnect()
}
}
catch (err) {
console.error('断开 OBS 失败:', err)
}
finally {
obsConnected.value = false
obsStreamActive.value = false
}
}
// 切换推流状态
async function handleObsToggleStream() {
if (!obs || !obsConnected.value) {
window.$message.error('请先连接 OBS')
return
}
try {
isTogglingObsStream.value = true
const result: any = await obs.call('ToggleStream')
if (typeof result?.outputActive === 'boolean') {
obsStreamActive.value = result.outputActive
}
window.$message.success(obsStreamActive.value ? '已开始 OBS 推流' : '已停止 OBS 推流')
void updateObsStats()
}
catch (err: any) {
console.error('切换 OBS 推流状态失败:', err)
window.$message.error(`切换 OBS 推流状态失败: ${err?.message || err}`)
}
finally {
isTogglingObsStream.value = false
}
}
// 开始推流
async function startObsStream() {
if (!obs || !obsConnected.value) {
console.warn('OBS 未连接,无法开始推流')
return false
}
if (obsStreamActive.value) {
console.log('OBS 已在推流中')
return true
}
try {
isTogglingObsStream.value = true
await obs.call('StartStream')
obsStreamActive.value = true
window.$message.success('已开始 OBS 推流')
void updateObsStats()
return true
}
catch (err: any) {
console.error('开始 OBS 推流失败:', err)
window.$message.error(`开始 OBS 推流失败: ${err?.message || err}`)
return false
}
finally {
isTogglingObsStream.value = false
}
}
// 停止推流
async function stopObsStream() {
if (!obs || !obsConnected.value) {
console.warn('OBS 未连接,无法停止推流')
return false
}
if (!obsStreamActive.value) {
console.log('OBS 未在推流中')
return true
}
try {
isTogglingObsStream.value = true
await obs.call('StopStream')
obsStreamActive.value = false
window.$message.success('已停止 OBS 推流')
void updateObsStats()
return true
}
catch (err: any) {
console.error('停止 OBS 推流失败:', err)
window.$message.error(`停止 OBS 推流失败: ${err?.message || err}`)
return false
}
finally {
isTogglingObsStream.value = false
}
}
// 同步推流码到 OBS
async function syncStreamKeyToObs(server: string, key: string) {
if (!obs || !obsConnected.value) {
window.$message.error('请先连接 OBS')
return false
}
try {
// 获取当前的流设置
const streamSettings: any = await obs.call('GetStreamServiceSettings')
// 更新服务器和推流码
await obs.call('SetStreamServiceSettings', {
streamServiceType: streamSettings.streamServiceType || 'rtmp_custom',
streamServiceSettings: {
...streamSettings.streamServiceSettings,
server: server,
key: key
}
})
window.$message.success('推流码已同步到 OBS')
return true
}
catch (err: any) {
console.error('同步推流码到 OBS 失败:', err)
window.$message.error(`同步推流码失败: ${err?.message || err}`)
return false
}
}
// 获取OBS场景列表
async function fetchObsScenes() {
if (!obs || !obsConnected.value) return
try {
const sceneList: any = await obs.call('GetSceneList')
obsScenes.value = sceneList.scenes.map((scene: any) => scene.sceneName as string)
console.log('获取到OBS场景列表:', obsScenes.value)
}
catch (err: any) {
console.error('获取OBS场景列表失败:', err)
obsSceneError.value = err?.message || '获取场景列表失败'
}
}
// 更新当前场景
async function updateCurrentScene() {
if (!obs || !obsConnected.value) return
try {
const currentScene: any = await obs.call('GetCurrentProgramScene')
currentObsScene.value = currentScene.currentProgramSceneName || ''
}
catch (err: any) {
console.error('获取当前场景失败:', err)
}
}
// 切换到指定场景
async function switchToScene(sceneName: string): Promise<boolean> {
if (!obs || !obsConnected.value) {
window.$message.error('OBS未连接')
return false
}
if (!sceneName || !obsScenes.value.includes(sceneName)) {
window.$message.error('无效的场景名称')
return false
}
// 防止重复切换到相同场景
if (currentObsScene.value === sceneName) {
console.log(`已在场景: ${sceneName},无需切换`)
return true
}
try {
isSwitchingScene.value = true
obsSceneError.value = ''
await obs.call('SetCurrentProgramScene', {
sceneName: sceneName
})
currentObsScene.value = sceneName
console.log(`已切换到场景: ${sceneName}`)
window.$message.success(`已切换到场景: ${sceneName}`)
return true
}
catch (err: any) {
console.error('切换场景失败:', err)
obsSceneError.value = err?.message || '切换场景失败'
window.$message.error(`切换场景失败: ${err?.message || err}`)
return false
}
finally {
isSwitchingScene.value = false
}
}
// 保存场景配置
async function saveSceneConfig() {
try {
await tauriStore.set(OBS_SCENE_CONFIG_KEY, obsSceneConfig.value)
console.log('场景配置已保存')
}
catch (err) {
console.error('保存场景配置失败:', err)
}
}
// 加载场景配置
async function loadSceneConfig() {
try {
const saved = await tauriStore.get<ObsSceneConfig | null>(OBS_SCENE_CONFIG_KEY)
if (saved) {
obsSceneConfig.value = saved
console.log('已加载场景配置:', saved)
}
}
catch (err) {
console.error('加载场景配置失败:', err)
}
}
// 加载OBS配置
async function loadObsConfig() {
try {
const saved = await tauriStore.get<ObsConfigState | null>(OBS_CONFIG_KEY)
if (saved?.address) {
obsAddress.value = saved.address
// 只有在设置了地址和密码时才启用自动重连
if (saved?.password !== undefined) {
obsPassword.value = saved.password || ''
obsAutoReconnect.value = true
}
}
}
catch (err) {
console.error('加载OBS配置失败:', err)
}
}
// 初始化
async function init() {
await loadObsConfig()
await loadSceneConfig()
// 只有在设置了地址和密码后才启动自动重连
if (obsAutoReconnect.value && obsAddress.value) {
startObsAutoReconnectLoop()
}
}
// 清理资源
function cleanup() {
stopObsAutoReconnectLoop()
stopObsStatsLoop()
if (obs) {
void obs.disconnect().catch(() => {})
obs = null
}
}
return {
// 状态
obsAddress,
obsPassword,
obsConnected,
obsConnecting,
obsError,
obsAutoReconnect,
obsStreamActive,
obsStreamReconnecting,
isTogglingObsStream,
obsStats,
obsScenes,
currentObsScene,
isSwitchingScene,
obsSceneError,
obsSceneConfig,
// 方法
handleObsConnect,
handleObsDisconnect,
handleObsToggleStream,
startObsStream,
stopObsStream,
syncStreamKeyToObs,
fetchObsScenes,
updateCurrentScene,
switchToScene,
saveSceneConfig,
loadSceneConfig,
loadObsConfig,
init,
cleanup,
}
})
// 热模块替换支持
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useOBSStore, import.meta.hot))
}

View File

@@ -19,6 +19,9 @@ export interface VTsuruClientSettings {
danmakuInterval: number danmakuInterval: number
pmInterval: number pmInterval: number
// EventFetcher 功能开关
enableEventFetcher: boolean
dev_disableDanmakuClient: boolean dev_disableDanmakuClient: boolean
} }
@@ -40,6 +43,9 @@ export const useSettings = defineStore('settings', () => {
danmakuInterval: 2000, danmakuInterval: 2000,
pmInterval: 2000, pmInterval: 2000,
// 默认启用 EventFetcher
enableEventFetcher: true,
dev_disableDanmakuClient: false, dev_disableDanmakuClient: false,
} }
const settings = ref<VTsuruClientSettings>(Object.assign({}, defaultSettings)) const settings = ref<VTsuruClientSettings>(Object.assign({}, defaultSettings))
@@ -51,6 +57,8 @@ export const useSettings = defineStore('settings', () => {
// 初始化消息队列间隔设置 // 初始化消息队列间隔设置
settings.value.danmakuInterval ??= defaultSettings.danmakuInterval settings.value.danmakuInterval ??= defaultSettings.danmakuInterval
settings.value.pmInterval ??= defaultSettings.pmInterval settings.value.pmInterval ??= defaultSettings.pmInterval
// 初始化 EventFetcher 开关
settings.value.enableEventFetcher ??= defaultSettings.enableEventFetcher
} }
async function save() { async function save() {
await store.set(settings.value) await store.set(settings.value)

94
src/components.d.ts vendored
View File

@@ -1,8 +1,12 @@
/* eslint-disable */ /* eslint-disable */
// @ts-nocheck // @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components // Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399 // Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable import { GlobalComponents } from 'vue'
export {} export {}
/* prettier-ignore */ /* prettier-ignore */
@@ -19,11 +23,35 @@ declare module 'vue' {
LabelItem: typeof import('./components/LabelItem.vue')['default'] LabelItem: typeof import('./components/LabelItem.vue')['default']
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default'] LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default'] MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar']
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NCascader: typeof import('naive-ui')['NCascader']
NDivider: typeof import('naive-ui')['NDivider']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NFlex: typeof import('naive-ui')['NFlex'] NFlex: typeof import('naive-ui')['NFlex']
NFormItemGi: typeof import('naive-ui')['NFormItemGi'] NFormItemGi: typeof import('naive-ui')['NFormItemGi']
NGi: typeof import('naive-ui')['NGi']
NGrid: typeof import('naive-ui')['NGrid']
NGridItem: typeof import('naive-ui')['NGridItem'] NGridItem: typeof import('naive-ui')['NGridItem']
NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']
NInput: typeof import('naive-ui')['NInput']
NInputGroup: typeof import('naive-ui')['NInputGroup']
NModal: typeof import('naive-ui')['NModal']
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NScrollbar: typeof import('naive-ui')['NScrollbar'] NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSpace: typeof import('naive-ui')['NSpace']
NStatistic: typeof import('naive-ui')['NStatistic']
NSwitch: typeof import('naive-ui')['NSwitch']
NTag: typeof import('naive-ui')['NTag'] NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
NTime: typeof import('naive-ui')['NTime']
NTooltip: typeof import('naive-ui')['NTooltip']
NUpload: typeof import('naive-ui')['NUpload']
PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default'] PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default']
PointHistoryCard: typeof import('./components/manage/PointHistoryCard.vue')['default'] PointHistoryCard: typeof import('./components/manage/PointHistoryCard.vue')['default']
PointOrderCard: typeof import('./components/manage/PointOrderCard.vue')['default'] PointOrderCard: typeof import('./components/manage/PointOrderCard.vue')['default']
@@ -46,3 +74,67 @@ declare module 'vue' {
VideoCollectInfoCard: typeof import('./components/VideoCollectInfoCard.vue')['default'] VideoCollectInfoCard: typeof import('./components/VideoCollectInfoCard.vue')['default']
} }
} }
// For TSX support
declare global {
const AddressDisplay: typeof import('./components/manage/AddressDisplay.vue')['default']
const BiliUserSelector: typeof import('./components/common/BiliUserSelector.vue')['default']
const DanmakuContainer: typeof import('./components/DanmakuContainer.vue')['default']
const DanmakuItem: typeof import('./components/DanmakuItem.vue')['default']
const DynamicForm: typeof import('./components/DynamicForm.vue')['default']
const EventFetcherAlert: typeof import('./components/EventFetcherAlert.vue')['default']
const EventFetcherStatusCard: typeof import('./components/EventFetcherStatusCard.vue')['default']
const FeedbackItem: typeof import('./components/FeedbackItem.vue')['default']
const LabelItem: typeof import('./components/LabelItem.vue')['default']
const LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
const MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
const NAlert: typeof import('naive-ui')['NAlert']
const NAvatar: typeof import('naive-ui')['NAvatar']
const NButton: typeof import('naive-ui')['NButton']
const NCard: typeof import('naive-ui')['NCard']
const NCascader: typeof import('naive-ui')['NCascader']
const NDivider: typeof import('naive-ui')['NDivider']
const NEllipsis: typeof import('naive-ui')['NEllipsis']
const NEmpty: typeof import('naive-ui')['NEmpty']
const NFlex: typeof import('naive-ui')['NFlex']
const NFormItemGi: typeof import('naive-ui')['NFormItemGi']
const NGi: typeof import('naive-ui')['NGi']
const NGrid: typeof import('naive-ui')['NGrid']
const NGridItem: typeof import('naive-ui')['NGridItem']
const NIcon: typeof import('naive-ui')['NIcon']
const NImage: typeof import('naive-ui')['NImage']
const NInput: typeof import('naive-ui')['NInput']
const NInputGroup: typeof import('naive-ui')['NInputGroup']
const NModal: typeof import('naive-ui')['NModal']
const NPopconfirm: typeof import('naive-ui')['NPopconfirm']
const NScrollbar: typeof import('naive-ui')['NScrollbar']
const NSelect: typeof import('naive-ui')['NSelect']
const NSpace: typeof import('naive-ui')['NSpace']
const NStatistic: typeof import('naive-ui')['NStatistic']
const NSwitch: typeof import('naive-ui')['NSwitch']
const NTag: typeof import('naive-ui')['NTag']
const NText: typeof import('naive-ui')['NText']
const NTime: typeof import('naive-ui')['NTime']
const NTooltip: typeof import('naive-ui')['NTooltip']
const NUpload: typeof import('naive-ui')['NUpload']
const PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default']
const PointHistoryCard: typeof import('./components/manage/PointHistoryCard.vue')['default']
const PointOrderCard: typeof import('./components/manage/PointOrderCard.vue')['default']
const QuestionItem: typeof import('./components/QuestionItem.vue')['default']
const QuestionItems: typeof import('./components/QuestionItems.vue')['default']
const RegisterAndLogin: typeof import('./components/RegisterAndLogin.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView']
const SaveCompoent: typeof import('./components/SaveCompoent.vue')['default']
const ScheduleList: typeof import('./components/ScheduleList.vue')['default']
const SimpleVideoCard: typeof import('./components/SimpleVideoCard.vue')['default']
const SimpleVirtualList: typeof import('./components/SimpleVirtualList.vue')['default']
const SongList: typeof import('./components/SongList.vue')['default']
const SongPlayer: typeof import('./components/SongPlayer.vue')['default']
const TempComponent: typeof import('./components/TempComponent.vue')['default']
const TurnstileVerify: typeof import('./components/TurnstileVerify.vue')['default']
const UpdateNoteContainer: typeof import('./components/UpdateNoteContainer.vue')['default']
const UserBasicInfoCard: typeof import('./components/UserBasicInfoCard.vue')['default']
const VEditor: typeof import('./components/VEditor.vue')['default']
const VideoCollectInfoCard: typeof import('./components/VideoCollectInfoCard.vue')['default']
}

View File

@@ -3,6 +3,29 @@ import { NButton, NImage } from 'naive-ui'
import UpdateNoteContainer from '@/components/UpdateNoteContainer.vue' import UpdateNoteContainer from '@/components/UpdateNoteContainer.vue'
export const updateNotes: updateNoteType[] = [ export const updateNotes: updateNoteType[] = [
{
ver: 9,
date: '2025.11.17',
items: [
{
type: 'new',
title: 'VTsuru Client 新增直播管理功能',
content: [
[
() => h(NButton, {
text: true,
tag: 'a',
href: 'https://www.wolai.com/carN6qvUm3FErze9Xo53ii',
target: '_blank',
type: 'info',
}, () => 'VTsuru Client '),
' 新增直播管理功能, 允许直接开播下播并使用OBS推流, 不再依赖直播姬\r\n',
() => h(NImage, { src: 'https://files.vtsuru.suki.club/updatelog/QQ20251117-182002.png', width: 300 }),
],
],
},
],
},
{ {
ver: 8, ver: 8,
date: '2025.10.16', date: '2025.10.16',

View File

@@ -1,4 +1,4 @@
import type { Component, DefineComponent } from 'vue' import { defineAsyncComponent, type Component, DefineComponent } from 'vue'
/** /**
* OBS 组件定义接口 * OBS 组件定义接口

View File

@@ -59,6 +59,15 @@ export default {
forceReload: true, forceReload: true,
}, },
}, },
{
path: 'live-manage',
name: 'client-live-manage',
component: async () => import('@/client/ClientLiveManage.vue'),
meta: {
title: '直播管理',
forceReload: true,
},
},
{ {
path: 'danmaku-window', path: 'danmaku-window',
name: 'client-danmaku-window-redirect', name: 'client-danmaku-window-redirect',

View File

@@ -655,6 +655,10 @@ onMounted(() => {
canResendEmail.value = true canResendEmail.value = true
} }
} }
if (selectedAPIKey.value != 'main') {
message.warning('你当前使用的是备用API节点, 可能会速度比较慢')
}
}) })
onUnmounted(() => { onUnmounted(() => {