├── .gitattributes
├── .github
├── FUNDING.yml
└── workflows
│ └── pages-deploy.yml
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── babel.config.js
├── jsconfig.json
├── package.json
├── public
├── index.html
└── live.html
├── src
├── components
│ ├── DanmakuItem.vue
│ ├── DanmakuList.vue
│ ├── InputGroup.vue
│ └── Live.vue
├── pages
│ ├── index
│ │ ├── App.vue
│ │ └── main.js
│ └── live
│ │ ├── App.vue
│ │ └── main.js
└── utils
│ ├── biliOpen.js
│ ├── bufferPolyfill.js
│ ├── face.js
│ ├── loadImg.js
│ ├── props.js
│ ├── protobuf.js
│ ├── qrLogin.js
│ ├── request.js
│ └── storage.js
├── vercel.json
├── vue.config.js
└── yarn.lock
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | ko_fi: jindaikirin
2 | custom: ['https://afdian.net/@jindaikirin']
3 |
--------------------------------------------------------------------------------
/.github/workflows/pages-deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to Pages
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | jobs:
7 | build-and-deploy:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Checkout
11 | uses: actions/checkout@v2
12 | with:
13 | ref: master
14 | persist-credentials: false
15 | - name: Install dependencies
16 | run: yarn install --frozen-lockfile
17 | - name: Build
18 | run: yarn run build
19 | - name: Deploy
20 | uses: JamesIves/github-pages-deploy-action@3.7.1
21 | with:
22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
23 | BRANCH: dist
24 | FOLDER: dist
25 | CLEAN: true
26 | SINGLE_COMMIT: true
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # TypeScript v1 declaration files
45 | typings/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Microbundle cache
57 | .rpt2_cache/
58 | .rts2_cache_cjs/
59 | .rts2_cache_es/
60 | .rts2_cache_umd/
61 |
62 | # Optional REPL history
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 | *.tgz
67 |
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
71 | # dotenv environment variables file
72 | .env
73 | .env.test
74 |
75 | # parcel-bundler cache (https://parceljs.org/)
76 | .cache
77 |
78 | # Next.js build output
79 | .next
80 |
81 | # Nuxt.js build / generate output
82 | .nuxt
83 | dist
84 |
85 | # Gatsby files
86 | .cache/
87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
88 | # https://nextjs.org/blog/next-9-1#public-directory-support
89 | # public
90 |
91 | # vuepress build output
92 | .vuepress/dist
93 |
94 | # Serverless directories
95 | .serverless/
96 |
97 | # FuseBox cache
98 | .fusebox/
99 |
100 | # DynamoDB Local files
101 | .dynamodb/
102 |
103 | # TernJS port file
104 | .tern-port
105 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "singleQuote": true,
4 | "trailingComma": "es5",
5 | "arrowParens": "avoid",
6 | "htmlWhitespaceSensitivity": "css",
7 | "useTabs": false,
8 | "tabWidth": 2,
9 | "semi": true
10 | }
11 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 神代綺凜
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Bilibili Live Chat
2 |
3 | 
4 |
5 | 这是一个无后端的,仿 YouTube Live Chat 的,箱都不用开就能食用的 Bilibili 直播弹幕姬
6 |
7 | 主要用于 OBS,为的是在低功能需求的情况下,不依靠任何第三方本地软件实现弹幕和礼物的展示
8 |
9 | 老版本在 `v1` 分支,新版本是使用 Vue 3 重构的版本,并增加了一些新特性,成品直接部署在 Github Pages
10 |
11 | ## 食用步骤
12 |
13 | 1. 打开 [blc.lolicon.app](https://blc.lolicon.app/)
14 | 2. 输入房间号,填写设置项,点击“Go!”,然后复制新页面的地址
15 | 3. 在 OBS 中添加“浏览器”来源,将地址粘贴到“URL”处,根据自己需要调整宽高和缩放
16 | 4. Enjoy~
17 |
18 | ## 其他说明
19 |
20 | ### 连接模式
21 |
22 | B站在2023年7月左右开始对游客模式下的直播弹幕进行用户名打码、限流等操作,如果需要正常使用有两种方法
23 |
24 | 1. 在“普通模式”下额外提供 [live.bilibili.com](https://live.bilibili.com/) 的 cookie,**可以连接任意直播间**
25 | 2. 【推荐】使用“开放平台”模式,需要注册 Bilibili 开放平台个人开发者并提供一些参数,**只能连接自己的直播间**
26 |
27 | #### 普通模式
28 |
29 | 该模式若未提供 cookie 则为游客身份连接,会出现收到的弹幕用户名被打码且随机限流(部分弹幕收不到)的情况
30 |
31 | 若提供 [live.bilibili.com](https://live.bilibili.com/) 的 cookie,则会使用该 cookie 调用B站 API 获取直播弹幕连接 token
32 |
33 | 支持手机 APP 扫码登录(仅限本项目官方站点)([隐私声明](#隐私声明))
34 |
35 | > [!NOTE]
36 | > 由于需要发送 cookie,因此无论是否开启跨域模式,调用该 API 都需要依赖反代服务(详见[跨域模式](#跨域模式))
37 |
38 | #### 开放平台
39 |
40 | 该模式只能连接自己的直播间,但为 Bilibili 官方开放的连接方式,因此更推荐使用
41 |
42 | 1. 前往开放平台注册个人开发者([注册地址](https://open-live.bilibili.com/open-register-form/personal)),提交注册后需要等待审核通过
43 | 2. 前往[创作者服务中心](https://open-live.bilibili.com/open-manage)-我的项目,随意创建一个项目,点进项目拿到**项目ID**
44 | 3. 前往[创作者服务中心](https://open-live.bilibili.com/open-manage)-个人资料,拿到 **access_key_id** 和 **access_key_secret**
45 | 4. 获取**身份码**,两种方法任选其一
46 | - [我的直播间](https://link.bilibili.com/p/center/index/#/my-room/start-live)-开始直播-身份码
47 | - [互动应用中心](https://play-live.bilibili.com/)-右下角菜单-身份码
48 |
49 | ### 跨域模式
50 |
51 | B站 API 无法被跨域调用,若不开启跨域模式,则会使用反代服务([隐私声明](#隐私声明))
52 |
53 | 若在 OBS 使用,则推荐开启跨域模式,方法如下:
54 |
55 | 任何基于 Chromium 的浏览器(例如 OBS Browser 和 Chrome)都可以通过添加 `--disable-web-security` 启动参数来禁用网页安全机制,此时可以开启“跨域模式”选项,几乎所有B站 API 将被直接跨域调用(需要 cookie 的除外),这样就不需要依赖反代服务
56 |
57 | 示例:
58 |
59 | - OBS:直接在启动的快捷方式后追加该参数,然后通过快捷方式启动即可
60 | 
61 | - Chrome:和 OBS 同理,不过必须额外添加一个 `--user-data-dir` 参数来指定用户目录,随意新建一个空文件夹来指定即可
62 | 该操作看上去十分麻烦,实则是 Chrome 的一个安全措施,因为**禁用网页安全机制是危险行为,日常使用时千万别这么做**
63 | 
64 |
65 | 其他内核的浏览器可以自行搜索相应参数来禁用网页安全机制
66 |
67 | ### 显示头像
68 |
69 | > 已支持从弹幕信息中获取头像,不再需要调用 API
70 | > 不过普通模式下可能没有头像,不知道为什么B站又不提供 `dm_v2` 了
71 |
72 | 头像加载机制:
73 |
74 | - 获取到头像后,图片会被预加载,加载完毕或超时(5 秒)后弹幕才会被插入弹幕列表
75 | - 非 GIF 头像会优先加载小头像(48x48)以节省流量,若首包到达时间超过 2 秒(B站 COS 图片压缩处理卡了,偶尔可能发生),则会回退为加载完整大小的头像图片
76 |
77 | ## 隐私声明
78 |
79 | 本项目官方站点 [blc.lolicon.app](https://blc.lolicon.app/) 会额外使用到以下两个本人开源并部署在公共平台上的服务:
80 |
81 | 1. B站API反向代理服务 [Tsuk1ko/blc-proxy](https://github.com/Tsuk1ko/blc-proxy) 部署于 HuggingFace
82 | 2. B站扫码登录服务 [Tsuk1ko/bilibili-qr-login](https://github.com/Tsuk1ko/bilibili-qr-login) 部署于 HuggingFace
83 |
84 | 本站及上述服务不会收集任何信息,若不信任请勿在【关闭跨域模式】或【在普通连接模式下提供 cookie】的情况下使用本项目及【扫码登录】功能
85 |
86 | ## Project setup
87 |
88 | ```bash
89 | yarn install
90 | ```
91 |
92 | ### Compiles and hot-reloads for development
93 |
94 | ```bash
95 | yarn serve
96 | ```
97 |
98 | ### Compiles and minifies for production
99 |
100 | ```bash
101 | yarn build
102 | ```
103 |
104 | ### Lints and fixes files
105 |
106 | ```bash
107 | yarn lint
108 | ```
109 |
110 | ### Customize configuration
111 |
112 | See [Configuration Reference](https://cli.vuejs.org/config/).
113 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: ['lodash', '@babel/plugin-proposal-nullish-coalescing-operator', '@babel/plugin-proposal-optional-chaining'],
3 | presets: ['@vue/cli-plugin-babel/preset'],
4 | };
5 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["./src/**/*"],
3 | "compilerOptions": {
4 | "target": "ESNext",
5 | "baseUrl": ".",
6 | "moduleResolution": "node",
7 | "resolveJsonModule": true,
8 | "paths": {
9 | "@/*": ["src/*"]
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bilibili-live-chat",
3 | "version": "2.8.1",
4 | "private": true,
5 | "scripts": {
6 | "serve": "vue-cli-service serve",
7 | "build": "vue-cli-service build",
8 | "lint": "vue-cli-service lint",
9 | "postversion": "tpv"
10 | },
11 | "dependencies": {
12 | "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
13 | "@babel/plugin-proposal-optional-chaining": "^7.21.0",
14 | "bilibili-live-ws": "^6.3.1",
15 | "buffer": "^6.0.3",
16 | "core-js": "^3.32.2",
17 | "lodash": "^4.17.21",
18 | "lru-cache": "^10.0.1",
19 | "md5": "^2.3.0",
20 | "pako": "^2.1.0",
21 | "protobufjs": "^7.2.5",
22 | "query-string": "^7.1.3",
23 | "uuid": "^9.0.1",
24 | "vue": "^3.3.4"
25 | },
26 | "devDependencies": {
27 | "@tsuk1ko/postversion": "^1.0.2",
28 | "@vue/cli-plugin-babel": "^5.0.8",
29 | "@vue/cli-plugin-eslint": "^5.0.8",
30 | "@vue/cli-service": "^5.0.8",
31 | "@vue/compiler-sfc": "^3.3.4",
32 | "babel-eslint": "^10.1.0",
33 | "babel-plugin-lodash": "^3.3.4",
34 | "eslint": "^7.32.0",
35 | "eslint-plugin-vue": "^9.17.0",
36 | "lint-staged": "^13.3.0",
37 | "prettier": "^3.0.3",
38 | "sass": "^1.68.0",
39 | "sass-loader": "^13.3.2"
40 | },
41 | "eslintConfig": {
42 | "root": true,
43 | "env": {
44 | "node": true
45 | },
46 | "extends": [
47 | "plugin:vue/vue3-essential",
48 | "eslint:recommended"
49 | ],
50 | "parserOptions": {
51 | "parser": "babel-eslint"
52 | },
53 | "rules": {
54 | "no-empty": "off",
55 | "no-unused-vars": "warn",
56 | "vue/multi-word-component-names": "off"
57 | }
58 | },
59 | "browserslist": [
60 | "> 1%",
61 | "last 2 versions",
62 | "not dead"
63 | ],
64 | "gitHooks": {
65 | "pre-commit": "lint-staged"
66 | },
67 | "lint-staged": {
68 | "*.{js,vue}": [
69 | "vue-cli-service lint",
70 | "prettier --write",
71 | "git add"
72 | ]
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
11 |
12 |
13 | Bilibili Live Chat
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/public/live.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
11 |
12 |
13 | Bilibili Live Chat
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/src/components/DanmakuItem.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
![]()
4 |
5 | {{ uname }}
6 | {{ message }}
7 |
8 |
9 | 感谢
10 | {{ uname }}
11 | 赠送的
12 | {{ giftName }}
13 |
14 | ×
15 | {{ num }}
16 |
17 |
18 |
19 | 感谢
20 | {{ uname }}
21 | 的SC:{{ message }}
22 |
23 |
24 | {{ message }}
25 |
26 |
27 |
28 |
29 |
67 |
68 |
174 |
--------------------------------------------------------------------------------
/src/components/DanmakuList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
13 |
14 |
15 |
22 |
23 |
24 |
34 |
35 |
36 |
145 |
146 |
173 |
--------------------------------------------------------------------------------
/src/components/InputGroup.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ header }}
4 |
5 | {{ footer }}
6 |
7 |
8 |
9 |
10 |
18 |
--------------------------------------------------------------------------------
/src/components/Live.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
12 |
13 |
14 |
239 |
240 |
251 |
--------------------------------------------------------------------------------
/src/pages/index/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
Bilibili Live Chat
5 |
13 |
14 |
17 |
18 |
176 |
177 |
178 |
179 |
305 |
306 |
366 |
--------------------------------------------------------------------------------
/src/pages/index/main.js:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue';
2 | import App from './App.vue';
3 |
4 | createApp(App).mount('#app');
5 |
--------------------------------------------------------------------------------
/src/pages/live/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
134 |
135 |
146 |
--------------------------------------------------------------------------------
/src/pages/live/main.js:
--------------------------------------------------------------------------------
1 | import '@/utils/bufferPolyfill';
2 | import { createApp } from 'vue';
3 | import App from './App.vue';
4 |
5 | createApp(App).mount('#app');
6 |
--------------------------------------------------------------------------------
/src/utils/biliOpen.js:
--------------------------------------------------------------------------------
1 | import md5 from 'md5';
2 | import { autoGet } from './request';
3 |
4 | /**
5 | * 鉴权加密
6 | * @param {*} params
7 | * @param {string} akId
8 | * @param {string} akSecret
9 | * @returns
10 | */
11 | async function getEncodeHeader(body, akId, akSecret) {
12 | const timestamp = parseInt(Date.now() / 1000 + '');
13 | const nonce = parseInt(Math.random() * 100000 + '') + timestamp;
14 | const header = {
15 | 'x-bili-accesskeyid': akId,
16 | 'x-bili-content-md5': md5(body),
17 | 'x-bili-signature-method': 'HMAC-SHA256',
18 | 'x-bili-signature-nonce': nonce + '',
19 | 'x-bili-signature-version': '1.0',
20 | 'x-bili-timestamp': timestamp,
21 | };
22 | const data = [];
23 | for (const key in header) {
24 | data.push(`${key}:${header[key]}`);
25 | }
26 |
27 | const signature = await getHmacSha256(akSecret, data.join('\n'));
28 | return {
29 | Accept: 'application/json',
30 | 'Content-Type': 'application/json',
31 | ...header,
32 | Authorization: signature,
33 | };
34 | }
35 |
36 | /**
37 | * HMAC-SHA256加密
38 | * @param {string} key
39 | * @param {string} message
40 | * @returns
41 | */
42 | async function getHmacSha256(key, message) {
43 | const encoder = new TextEncoder();
44 | const cryptoKey = await window.crypto.subtle.importKey(
45 | 'raw',
46 | encoder.encode(key),
47 | { name: 'HMAC', hash: { name: 'SHA-256' } },
48 | false,
49 | ['sign']
50 | );
51 | const signature = await window.crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(message));
52 | return Array.from(new Uint8Array(signature))
53 | .map(b => b.toString(16).padStart(2, '0'))
54 | .join('');
55 | }
56 |
57 | const START_URL = 'https://live-open.biliapi.com/v2/app/start';
58 | const HEARTBEAT_URL = 'https://live-open.biliapi.com/v2/app/heartbeat';
59 | // const END_URL = 'https://live-open.biliapi.com/v2/app/end';
60 |
61 | async function callApi(url, data, akId, akSecret) {
62 | const body = JSON.stringify(data);
63 | const headers = await getEncodeHeader(body, akId, akSecret);
64 | return autoGet(url, { method: 'POST', headers, body });
65 | }
66 |
67 | /**
68 | * @param {string} akId
69 | * @param {string} akSecret
70 | * @param {number} appId
71 | * @param {string} authCode
72 | */
73 | export async function getOpenData(akId, akSecret, appId, authCode) {
74 | const startRes = await callApi(START_URL, { code: authCode, app_id: appId }, akId, akSecret);
75 | console.log('open start', startRes);
76 | const { code, message, data } = startRes;
77 | if (code !== 0) throw new Error(message);
78 | const gameId = data.game_info.game_id;
79 | if (!gameId) throw new Error('no game id');
80 | setInterval(async () => {
81 | const heartbeatRet = await callApi(HEARTBEAT_URL, { game_id: gameId }, akId, akSecret);
82 | if (heartbeatRet.code === 0) console.log('open heartbeat success');
83 | else console.error('open heartbeat error', heartbeatRet);
84 | }, 20e3);
85 | return data;
86 | }
87 |
--------------------------------------------------------------------------------
/src/utils/bufferPolyfill.js:
--------------------------------------------------------------------------------
1 | import { Buffer } from 'buffer';
2 | window.Buffer = Buffer;
3 |
--------------------------------------------------------------------------------
/src/utils/face.js:
--------------------------------------------------------------------------------
1 | import { LRUCache } from 'lru-cache';
2 | import loadImg from './loadImg';
3 | import { last } from 'lodash';
4 |
5 | // const NO_FACE = 'https://i0.hdslb.com/bfs/face/member/noface.jpg';
6 |
7 | // 不用缓存了
8 | window.localStorage.removeItem('blc-face');
9 |
10 | /** @type {LRUCache>} */
11 | const cache = new LRUCache({ max: 500, ttl: 600 * 1000, updateAgeOnGet: true });
12 |
13 | const getFaceLoads = face => {
14 | const smallFace = getSmallFace(face);
15 | if (smallFace) {
16 | return [
17 | [smallFace, 2000],
18 | [face, 5000],
19 | ];
20 | }
21 | return [[face, 0]];
22 | };
23 |
24 | const getSmallFace = url => {
25 | if (url.endsWith('.gif') || url.includes('noface')) return;
26 | return url.replace(/(\.[^./]+$)/, '$1_48x48$1');
27 | };
28 |
29 | export const loadFace = async (uid, url) => {
30 | const key = uid || last(url.split('/'));
31 | if (cache.has(key)) return cache.get(key);
32 |
33 | const loads = getFaceLoads(url.replace(/^http:/, 'https:'));
34 | const loadPromise = loadImg(loads);
35 | cache.set(key, loadPromise);
36 | const finalUrl = await loadPromise;
37 | cache.set(key, finalUrl);
38 |
39 | return finalUrl;
40 | };
41 |
--------------------------------------------------------------------------------
/src/utils/loadImg.js:
--------------------------------------------------------------------------------
1 | const loadImg = (url, timeout) =>
2 | new Promise((resolve, reject) => {
3 | let progress = 0;
4 | const xhr = new XMLHttpRequest();
5 | const loadTimeout = setTimeout(() => {
6 | xhr.abort();
7 | }, 10000);
8 | const progressTimeout = timeout
9 | ? setTimeout(() => {
10 | if (progress === 0) xhr.abort();
11 | }, timeout)
12 | : null;
13 | xhr.open('GET', url, true);
14 | xhr.onprogress = e => {
15 | if (e.lengthComputable) progress = e.loaded / e.total;
16 | };
17 | xhr.onload = () => {
18 | clearTimeout(progressTimeout);
19 | clearTimeout(loadTimeout);
20 | resolve(url);
21 | };
22 | xhr.onerror = reject;
23 | xhr.onabort = reject;
24 | xhr.send();
25 | });
26 |
27 | /** @returns {Promise} */
28 | export default async loads => {
29 | for (const [url, timeout] of loads) {
30 | const loaded = await loadImg(url, timeout).catch(() => {
31 | console.warn('Timeout', url);
32 | });
33 | if (loaded) return loaded;
34 | }
35 | return loads[0][0];
36 | };
37 |
--------------------------------------------------------------------------------
/src/utils/props.js:
--------------------------------------------------------------------------------
1 | import { parse as qsp } from 'query-string';
2 | import { mapValues, pick } from 'lodash';
3 |
4 | export const defaultProps = {
5 | auth: 'normal',
6 | akId: '',
7 | akSecret: '',
8 | appId: '',
9 | code: '',
10 | room: '',
11 | cookie: '',
12 | cors: 'false',
13 | face: 'true',
14 | display: 'bottom',
15 | stay: '',
16 | limit: '',
17 | giftComb: '',
18 | giftPin: '',
19 | delay: '',
20 | blockUID: '',
21 | debug: '',
22 | customCss: '',
23 | };
24 | Object.freeze(defaultProps);
25 |
26 | export const intProps = ['room', 'stay', 'giftComb', 'limit', 'giftPin', 'delay', 'appId'];
27 | Object.freeze(intProps);
28 |
29 | export const intPropsSet = new Set(intProps);
30 | Object.freeze(intPropsSet);
31 |
32 | export const isIntProp = name => intPropsSet.has(name);
33 |
34 | const intPropsDefault = {};
35 | Object.freeze(intPropsDefault);
36 |
37 | export const propsType = mapValues(defaultProps, (v, k) => (intPropsSet.has(k) ? Number : String));
38 | Object.freeze(propsType);
39 |
40 | export const selectOptions = {
41 | auth: [
42 | {
43 | value: 'normal',
44 | text: '普通模式',
45 | },
46 | {
47 | value: 'open',
48 | text: '开放平台',
49 | },
50 | ],
51 | cors: [
52 | {
53 | value: 'false',
54 | text: '关闭(所有跨域请求将依赖反代服务)',
55 | },
56 | {
57 | value: 'true',
58 | text: '开启(请阅读右侧说明)',
59 | },
60 | ],
61 | face: [
62 | {
63 | value: 'true',
64 | text: '显示',
65 | },
66 | {
67 | value: 'false',
68 | text: '不显示',
69 | },
70 | ],
71 | display: [
72 | {
73 | value: 'bottom',
74 | text: '自底部',
75 | },
76 | {
77 | value: 'top',
78 | text: '从顶部',
79 | },
80 | ],
81 | };
82 | Object.freeze(selectOptions);
83 |
84 | export const parseProps = qs =>
85 | mapValues(
86 | pick(
87 | {
88 | ...defaultProps,
89 | ...qsp(qs),
90 | },
91 | Object.keys(defaultProps)
92 | ),
93 | (v, k) => {
94 | if (isIntProp(k)) return (v && parseInt(v)) || intPropsDefault[k] || 0;
95 | if (k in selectOptions) return selectOptions[k].some(({ value }) => value === v) ? v : defaultProps[k];
96 | return v || defaultProps[k];
97 | }
98 | );
99 |
--------------------------------------------------------------------------------
/src/utils/protobuf.js:
--------------------------------------------------------------------------------
1 | import { length as b64len, decode as b64dec } from '@protobufjs/base64';
2 | import { Type, Field } from 'protobufjs';
3 |
4 | const decodeB64 = str => {
5 | const length = b64len(str);
6 | const buffer = new Uint8Array(length);
7 | b64dec(str, buffer, 0);
8 | return buffer;
9 | };
10 |
11 | const UserInfo = new Type('UserInfo').add(new Field('face', 4, 'string'));
12 | const DanmakuMessageV2 = new Type('DanmakuMessageV2').add(UserInfo).add(new Field('user', 20, 'UserInfo'));
13 |
14 | /**
15 | * @param {string} str
16 | * @returns {{ user: { face: string } }}
17 | */
18 | export const decodeDmV2 = str => {
19 | const buffer = decodeB64(str);
20 | return DanmakuMessageV2.decode(buffer);
21 | };
22 |
--------------------------------------------------------------------------------
/src/utils/qrLogin.js:
--------------------------------------------------------------------------------
1 | const qrLoginService = 'https://mashir0-bilibili-qr-login.hf.space';
2 |
3 | const getCenterPosition = (width, height) => {
4 | const screenLeft = window.screenLeft !== undefined ? window.screenLeft : window.screen.availLeft;
5 | const screenTop = window.screenTop !== undefined ? window.screenTop : window.screen.availTop;
6 |
7 | const screenWidth = window.screen.width || window.outerWidth || document.documentElement.clientWidth;
8 | const screenHeight = window.screen.height || window.outerWidth || document.documentElement.clientHeight;
9 |
10 | return {
11 | left: Math.round((screenWidth - width) / 2 + screenLeft),
12 | top: Math.round((screenHeight - height) / 2 + screenTop),
13 | };
14 | };
15 |
16 | const getFeaturesStr = features =>
17 | Object.entries(features)
18 | .map(([k, v]) => `${k}=${v}`)
19 | .join(',');
20 |
21 | export const openQrLoginWindow = () => {
22 | const width = 380;
23 | const height = 340;
24 | const features = getFeaturesStr({
25 | width,
26 | height,
27 | location: false,
28 | menubar: false,
29 | resizable: false,
30 | scrollbars: false,
31 | status: false,
32 | toolbar: false,
33 | ...getCenterPosition(width, height),
34 | });
35 | return window.open(`${qrLoginService}/?mode=window`, '_blank', features);
36 | };
37 |
38 | let handleLogin;
39 |
40 | const handleMessage = e => {
41 | if (e.origin !== qrLoginService) return;
42 | const { type, data } = e.data;
43 | if (type === 'success') {
44 | if (data && typeof data === 'string') handleLogin?.(data);
45 | e.source?.close();
46 | }
47 | };
48 |
49 | export const bindQrLogin = fn => {
50 | handleLogin = fn;
51 | window.addEventListener('message', handleMessage);
52 | };
53 |
54 | export const unbindQrLogin = () => {
55 | handleLogin = null;
56 | window.removeEventListener('message', handleMessage);
57 | };
58 |
--------------------------------------------------------------------------------
/src/utils/request.js:
--------------------------------------------------------------------------------
1 | import { pick } from 'lodash';
2 |
3 | let canCORS = true;
4 |
5 | export const setCors = bool => (canCORS = bool);
6 |
7 | export const getResp = (url, options = {}) => fetch(url, { referrer: '', referrerPolicy: 'no-referrer', ...options });
8 |
9 | export const get = (url, options) => getResp(url, options).then(r => r.json());
10 |
11 | export const corsGetResp = (url, options) =>
12 | fetch('https://mashir0-blcp.hf.space/', {
13 | method: 'POST',
14 | headers: { 'Content-Type': 'application/json' },
15 | body: JSON.stringify({ url, ...pick(options, ['method', 'headers', 'body']) }),
16 | referrerPolicy: 'origin',
17 | });
18 |
19 | export const corsGet = (url, options) => corsGetResp(url, options).then(r => r.json());
20 |
21 | export const autoGet = (url, options) => (canCORS ? get(url, options) : corsGet(url, options));
22 |
23 | export const autoGetResp = (url, options) => (canCORS ? getResp(url, options) : corsGetResp(url, options));
24 |
--------------------------------------------------------------------------------
/src/utils/storage.js:
--------------------------------------------------------------------------------
1 | const storage = window.localStorage;
2 |
3 | export const sget = (key, defaultValue = null) => {
4 | const text = storage.getItem(`blc-${key}`);
5 | try {
6 | return text ? JSON.parse(text) : defaultValue;
7 | } catch (error) {
8 | return defaultValue;
9 | }
10 | };
11 | export const sset = (key, value) => storage.setItem(`blc-${key}`, JSON.stringify(value));
12 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "github": {
3 | "silent": true
4 | },
5 | "headers": [
6 | {
7 | "source": "/css/(.*)",
8 | "headers": [
9 | {
10 | "key": "Cache-Control",
11 | "value": "public, max-age=31536000"
12 | }
13 | ]
14 | },
15 | {
16 | "source": "/js/(.*)",
17 | "headers": [
18 | {
19 | "key": "Cache-Control",
20 | "value": "public, max-age=31536000"
21 | }
22 | ]
23 | }
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/vue.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | publicPath: '',
3 | productionSourceMap: false,
4 | pages: {
5 | index: {
6 | entry: 'src/pages/index/main.js',
7 | chunks: ['chunk-common', 'chunk-index-vendors', 'index'],
8 | },
9 | live: {
10 | entry: 'src/pages/live/main.js',
11 | chunks: ['chunk-common', 'chunk-live-vendors', 'live'],
12 | },
13 | },
14 | chainWebpack: config => {
15 | const pageKeys = Object.keys(module.exports.pages);
16 | config.optimization.splitChunks({
17 | cacheGroups: {
18 | ...pageKeys.map(key => ({
19 | name: `chunk-${key}-vendors`,
20 | priority: -10,
21 | chunks: chunk => chunk.name === key,
22 | test: /[\\/]node_modules[\\/]/,
23 | enforce: true,
24 | })),
25 | common: {
26 | name: 'chunk-common',
27 | priority: 0,
28 | chunks: 'initial',
29 | minChunks: 2,
30 | reuseExistingChunk: true,
31 | enforce: true,
32 | },
33 | },
34 | });
35 | },
36 | };
37 |
--------------------------------------------------------------------------------