Compare commits

...

57 Commits

Author SHA1 Message Date
John Smith
e93f6b2383 兼容B站弹幕升级 2020-01-12 22:46:02 +08:00
John Smith
4c2e216191 添加帮助 2019-12-16 23:40:01 +08:00
John Smith
2a35541dc5 捕获write_message异常 2019-12-08 17:55:58 +08:00
John Smith
a927282e77 修复弹幕太快时消息平滑失效的问题 2019-12-01 16:36:50 +08:00
John Smith
a7faac5425 修复有时消息不能正确合并的问题 2019-11-30 23:57:52 +08:00
John Smith
a22496c355 修复固定消息的顺序 2019-11-30 23:24:06 +08:00
John Smith
20ec58b965 Merge pull request #13 from simon300000/https-face
auto http/https avatarUrl
2019-11-30 14:26:20 +08:00
simon3000
7453d7e890 (python) http/https avatarUrl 2019-11-30 07:20:35 +01:00
simon3000
1991e33b9d Revert "auto http/https avatarUrl"
This reverts commit 4b98c56965.
2019-11-30 07:16:38 +01:00
John Smith
f429a6a03d Merge pull request #12 from simon300000/generate-https
roomUrl auto https support
2019-11-30 14:16:28 +08:00
simon3000
4b98c56965 auto http/https avatarUrl 2019-11-30 07:08:17 +01:00
simon3000
eb43a37ae5 roomUrl auto https support 2019-11-30 06:56:05 +01:00
John Smith
1cf6d88460 各种优化 2019-11-29 23:48:43 +08:00
John Smith
9ddbebbd7e 更新版本号 2019-11-28 22:25:50 +08:00
John Smith
ca7f27d536 添加平滑滚动弹幕 2019-11-28 22:18:27 +08:00
John Smith
98184e6732 添加平滑消息队列 2019-11-28 00:52:16 +08:00
John Smith
3b2616c278 封装addMessage到ChatRenderer
暂时移除了messagesBuffer
2019-11-24 18:48:10 +08:00
John Smith
9565c52e23 更新版本号 2019-10-13 21:19:33 +08:00
John Smith
bb1acaecd6 配置允许储存任意JSON 2019-10-13 21:04:03 +08:00
John Smith
dcbb90a7bd 兼容B站弹幕升级 2019-10-13 20:27:52 +08:00
John Smith
c46542bee0 兼容HTTPS 2019-10-13 17:02:54 +08:00
John Smith
709ec0585e 更新README、版本号 2019-10-06 16:56:40 +08:00
John Smith
a9c6709080 添加BiliSC链接 2019-10-06 16:41:47 +08:00
John Smith
938f758920 添加掉线重连 2019-10-06 16:20:48 +08:00
John Smith
ce4974be94 添加删除SC功能 2019-10-06 12:23:47 +08:00
John Smith
040bad0eba 修复空SC下面不是圆角边框的问题 2019-10-06 10:55:01 +08:00
John Smith
60e6b5ec99 隐藏空SC内容、更新版本号 2019-09-23 22:28:11 +08:00
John Smith
695a111c9a SC使用官方功能 2019-09-23 22:22:27 +08:00
John Smith
0160549dbe 重复标记用HSL、代码优化 2019-09-22 17:18:34 +08:00
John Smith
3fd0d9ab28 更新JS包 2019-09-17 23:53:26 +08:00
John Smith
b1061ae103 更新blivedm 2019-09-17 23:08:03 +08:00
John Smith
6e737ce33f 样式生成器添加梗 2019-09-16 22:21:44 +08:00
John Smith
10db6b9ae2 添加最大弹幕数 2019-09-16 22:15:32 +08:00
John Smith
1514bfed5b 自动检查更新 2019-09-16 20:48:03 +08:00
John Smith
dc264d40f0 SC配置按美元汇率 2019-09-15 23:31:23 +08:00
John Smith
104e647f61 样式生成器添加默认字体 2019-07-18 09:15:27 +08:00
John Smith
d5d7fdde6d 日志改用英语 2019-07-18 09:08:03 +08:00
John Smith
23aa88d564 时间补齐2位 2019-07-18 08:31:58 +08:00
John Smith
ac1bda0392 修改一堆文案 2019-07-15 21:30:52 +08:00
John Smith
5cdb786966 去掉了点击预览会消失的CSS 2019-07-15 19:50:51 +08:00
John Smith
2b5ca2e219 Merge pull request #8 from simon300000/honest
「尝试」优化了样式生成器的CSS,加入了一个只有好孩子才能看到的小按钮
2019-07-15 19:30:39 +08:00
John Smith
fc77b246c2 我傻了 2019-07-15 18:01:38 +08:00
John Smith
083832cb3e Merge pull request #7 from simon300000/patch-css
增加element字体CSS
2019-07-15 17:58:42 +08:00
John Smith
804db2fec1 全局应用字体 2019-07-15 17:56:03 +08:00
simon3000
769964d5db 增加element字体CSS 2019-07-15 17:56:03 +08:00
John Smith
aba0efca34 Merge pull request #9 from xfgryujk/dependabot/npm_and_yarn/frontend/lodash-4.17.13
Bump lodash from 4.17.11 to 4.17.13 in /frontend
2019-07-15 17:35:06 +08:00
dependabot[bot]
9ff18fe8b5 Bump lodash from 4.17.11 to 4.17.13 in /frontend
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.11 to 4.17.13.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.11...4.17.13)

Signed-off-by: dependabot[bot] <support@github.com>
2019-07-15 09:33:24 +00:00
simon3000
ee78cbf90e 改用refs直接执行function 2019-07-13 01:51:58 +02:00
simon3000
ffc3c9d8a0 将监听的event名称改为exampleResize 2019-07-13 01:16:57 +02:00
simon3000
a69b6b6406 给ChatRenderer加上demo prop, 为true时自动resize 2019-07-13 01:14:55 +02:00
simon3000
1252402e5e Merge branch 'patch-scroll-on-resize' into honest 2019-07-13 01:03:45 +02:00
simon3000
53be587189 自动resize 2019-07-11 23:14:11 +02:00
simon3000
d3cc8b0c77 👿 2019-07-11 22:52:06 +02:00
simon3000
4f4d117ce0 使用calc优化CSS生成器的浏览部分 2019-07-11 22:51:41 +02:00
simon3000
0d1e38346e 增加element字体CSS 2019-07-11 22:15:46 +02:00
simon3000
273dc0f43f 窗口缩放时滚动 2019-07-11 21:50:28 +02:00
John Smith
26f19b6630 Update README.md 2019-07-11 11:35:17 +08:00
36 changed files with 19417 additions and 6420 deletions

View File

@@ -1,5 +1,5 @@
# blivechat
用于OBS的仿YouTube风格的bilibili直播聊天层
用于OBS的仿YouTube风格的bilibili直播评论栏
最近喜欢看VTuber想为此写些程序于是有了这个东西。~~写到一半发现有类似项目了:[bilibili-live-chat](https://github.com/Tsuk1ko/bilibili-live-chat)、[BiliChat](https://github.com/3Shain/BiliChat)。~~ 本项目就当做练手吧而且对YouTube的模仿程度更高
@@ -8,23 +8,29 @@
![样式生成器截图](https://github.com/xfgryujk/blivechat/blob/master/screenshots/stylegen.png)
## 特性
* 兼容YouTube直播chat的样式
* 金瓜子礼物模仿Super Chat显示
* 兼容YouTube直播评论栏的样式
* 金瓜子礼物模仿醒目留言显示
* 高亮舰队、房管、主播的用户名
* 支持屏蔽弹幕、限制最大速度等设置
* 支持屏蔽弹幕、合并相似弹幕等设置
* 自带样式生成器
## 使用方法
### 发布版
### 本地使用
1. 下载[发布版](https://github.com/xfgryujk/blivechat/releases)仅提供x64 Windows版
2. 双击`blivechat.exe`运行服务器或者用命令行可以指定host和端口号
```bat
blivechat.exe --host 127.0.0.1 --port 12450
```
3. 用浏览器打开[http://localhost](http://localhost)输入房间ID保存配置进入房间,复制房间URL
4. (可选)用样式生成器生成样式复制CSS
3. 用浏览器打开[http://localhost:12450](http://localhost:12450)输入房间ID保存配置复制房间URL
4. 用样式生成器生成样式复制CSS
5. 在OBS中添加浏览器源输入URL和自定义CSS或者可以在首页的样式设置里输入CSS
### 公共服务器
请优先在本地使用,使用公共服务器会有更大的弹幕延迟,而且服务器故障时可能出现直播事故
* [第三方公共服务器](http://chat.bilisc.com/)
* [仅样式生成器](https://style.vtbs.moe/)
### 源代码版
1. 编译前端需要安装Node.js和npm
```sh
@@ -41,12 +47,10 @@
```sh
python3 main.py --host 127.0.0.1 --port 12450
```
3. 用浏览器打开[http://localhost](http://localhost)输入房间ID保存配置进入房间复制房间URL
4. 可选用样式生成器生成样式复制CSS
5. 在OBS中添加浏览器源输入URL和自定义CSS或者可以在首页的样式设置里输入CSS
3. 用浏览器打开[http://localhost:12450](http://localhost:12450),以下略
### Docker
1. ```sh
docker run -d -p 12450:12450 xfgryujk/blivechat:latest
```
2. 以下略
2. 用浏览器打开[http://localhost:12450](http://localhost:12450)以下略

Submodule blivedm updated: 9eb953720c...15669a2084

24499
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
"core-js": "^2.6.5",
"downloadjs": "^1.4.7",
"element-ui": "^2.9.1",
"lodash": "^4.17.11",
"lodash": "^4.17.13",
"vue": "^2.6.10",
"vue-i18n": "^8.11.2",
"vue-router": "^3.0.6"

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -1,14 +1,13 @@
import axios from 'axios'
import {mergeConfig} from '@/utils'
import stylegen from '@/views/StyleGenerator/stylegen'
export const DEFAULT_CONFIG = {
minGiftPrice: 6.911, // $1
minGiftPrice: 7, // $1
mergeSimilarDanmaku: true,
showDanmaku: true,
showGift: true,
maxSpeed: 4,
maxNumber: 60,
blockGiftDanmaku: true,
blockLevel: 0,
@@ -18,7 +17,7 @@ export const DEFAULT_CONFIG = {
blockUsers: '',
blockMedalLevel: 0,
css: stylegen.getStyle(stylegen.DEFAULT_CONFIG)
css: ''
}
export function setLocalConfig (config) {

View File

@@ -10,7 +10,7 @@
<div id="event-text" class="style-scope yt-live-chat-legacy-paid-message-renderer">{{title}}</div>
<div id="detail-text" class="style-scope yt-live-chat-legacy-paid-message-renderer">{{content}}</div>
</div>
<div id="timestamp" class="style-scope yt-live-chat-legacy-paid-message-renderer">{{time}}</div>
<div id="timestamp" class="style-scope yt-live-chat-legacy-paid-message-renderer">{{timeText}}</div>
</div>
</div>
<div id="inline-action-button-container" class="style-scope yt-live-chat-legacy-paid-message-renderer" aria-hidden="true">
@@ -21,6 +21,7 @@
<script>
import ImgShadow from './ImgShadow.vue'
import utils from '@/utils'
export default {
name: 'LegacyPaidMessage',
@@ -32,7 +33,12 @@ export default {
authorName: String,
title: String,
content: String,
time: String
time: Date
},
computed: {
timeText() {
return utils.getTimeTextMinSec(this.time)
}
}
}
</script>

View File

@@ -1,5 +1,6 @@
<template>
<yt-live-chat-paid-message-renderer class="style-scope yt-live-chat-item-list-renderer" allow-animations :style="{
<yt-live-chat-paid-message-renderer class="style-scope yt-live-chat-item-list-renderer" allow-animations
:show-only-header="!content" :style="{
'--yt-live-chat-paid-message-primary-color': color.contentBg,
'--yt-live-chat-paid-message-secondary-color': color.headerBg,
'--yt-live-chat-paid-message-header-color': color.header,
@@ -16,9 +17,9 @@
<div id="header-content" class="style-scope yt-live-chat-paid-message-renderer">
<div id="header-content-primary-column" class="style-scope yt-live-chat-paid-message-renderer">
<div id="author-name" class="style-scope yt-live-chat-paid-message-renderer">{{authorName}}</div>
<div id="purchase-amount" class="style-scope yt-live-chat-paid-message-renderer">CN¥{{price}}</div>
<div id="purchase-amount" class="style-scope yt-live-chat-paid-message-renderer">{{priceText}}</div>
</div>
<span id="timestamp" class="style-scope yt-live-chat-paid-message-renderer">{{time}}</span>
<span id="timestamp" class="style-scope yt-live-chat-paid-message-renderer">{{timeText}}</span>
</div>
</div>
<div id="content" class="style-scope yt-live-chat-paid-message-renderer">
@@ -33,6 +34,7 @@
<script>
import ImgShadow from './ImgShadow.vue'
import * as constants from './constants'
import utils from '@/utils'
export default {
name: 'PaidMessage',
@@ -43,12 +45,18 @@ export default {
avatarUrl: String,
authorName: String,
price: Number, // 价格,人民币
time: String,
time: Date,
content: String
},
computed: {
color() {
return constants.getPriceConfig(this.price).colors
},
priceText() {
return 'CN¥' + utils.formatCurrency(this.price)
},
timeText() {
return utils.getTimeTextMinSec(this.time)
}
}
}

View File

@@ -4,7 +4,7 @@
:imgUrl="avatarUrl"
></img-shadow>
<div id="content" class="style-scope yt-live-chat-text-message-renderer">
<span id="timestamp" class="style-scope yt-live-chat-text-message-renderer">{{time}}</span>
<span id="timestamp" class="style-scope yt-live-chat-text-message-renderer">{{timeText}}</span>
<yt-live-chat-author-chip class="style-scope yt-live-chat-text-message-renderer">
<span id="author-name" dir="auto" class="style-scope yt-live-chat-author-chip" :type="authorTypeText">
{{authorName}}
@@ -29,9 +29,11 @@
import ImgShadow from './ImgShadow.vue'
import AuthorBadge from './AuthorBadge.vue'
import * as constants from './constants'
import utils from '@/utils'
const REPEATED_MARK_COLOR_START = [64, 158, 255]
const REPEATED_MARK_COLOR_END = [245, 108, 108]
// HSL
const REPEATED_MARK_COLOR_START = [210, 100.0, 62.5]
const REPEATED_MARK_COLOR_END = [360, 87.3, 69.2]
export default {
name: 'TextMessage',
@@ -41,7 +43,7 @@ export default {
},
props: {
avatarUrl: String,
time: String,
time: Date,
authorName: String,
authorType: Number,
content: String,
@@ -49,6 +51,9 @@ export default {
repeated: Number
},
computed: {
timeText() {
return utils.getTimeTextMinSec(this.time)
},
authorTypeText() {
return constants.AUTHOR_TYPE_TO_TEXT[this.authorType]
},
@@ -65,7 +70,7 @@ export default {
color[i] = REPEATED_MARK_COLOR_START[i] + (REPEATED_MARK_COLOR_END[i] - REPEATED_MARK_COLOR_START[i]) * t
}
}
return `rgb(${color.join(', ')})`
return `hsl(${color[0]}, ${color[1]}%, ${color[2]}%)`
}
}
}

View File

@@ -45,6 +45,7 @@ import ImgShadow from './ImgShadow.vue'
import LegacyPaidMessage from './LegacyPaidMessage.vue'
import PaidMessage from './PaidMessage.vue'
import * as constants from './constants'
import {formatCurrency} from '@/utils'
export default {
name: 'Ticker',
@@ -106,9 +107,9 @@ export default {
},
getText(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
return '会员'
return 'Member'
}
return `CN¥${message.price}`
return 'CN¥' + formatCurrency(message.price)
},
updateProgress() {
let curTime = new Date()

View File

@@ -19,11 +19,14 @@ export const GUARD_LEVEL_TO_TEXT = [
export const MESSAGE_TYPE_TEXT = 0
export const MESSAGE_TYPE_MEMBER = 1
export const MESSAGE_TYPE_GIFT = 2
export const MESSAGE_TYPE_SUPER_CHAT = 2
export const MESSAGE_TYPE_DEL = 3
// 美元 -> 人民币 汇率
const EXCHANGE_RATE = 7
export const PRICE_CONFIGS = [
{ // $100红
price: 1245, // >= 1245小电视飞船
price: 100 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(230,33,23,1)',
headerBg: 'rgba(208,0,0,1)',
@@ -35,7 +38,7 @@ export const PRICE_CONFIGS = [
pinTime: 60
},
{ // $50品红
price: 450, // 450~1245摩天大楼
price: 50 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(233,30,99,1)',
headerBg: 'rgba(194,24,91,1)',
@@ -47,7 +50,7 @@ export const PRICE_CONFIGS = [
pinTime: 30
},
{ // $20橙
price: 100, // 100~450节奏风暴、天空之翼
price: 20 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(245,124,0,1)',
headerBg: 'rgba(230,81,0,1)',
@@ -59,7 +62,7 @@ export const PRICE_CONFIGS = [
pinTime: 10
},
{ // $10黄
price: 52, // 52~100疯狂打call
price: 10 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(255,202,40,1)',
headerBg: 'rgba(255,179,0,1)',
@@ -71,7 +74,7 @@ export const PRICE_CONFIGS = [
pinTime: 5
},
{ // $5绿
price: 28, // 28~52礼花
price: 5 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(29,233,182,1)',
headerBg: 'rgba(0,191,165,1)',
@@ -83,7 +86,7 @@ export const PRICE_CONFIGS = [
pinTime: 2
},
{ // $2浅蓝
price: 9.9, // 9.9~28B坷垃
price: 2 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(0,229,255,1)',
headerBg: 'rgba(0,184,212,1)',
@@ -95,7 +98,7 @@ export const PRICE_CONFIGS = [
pinTime: 0
},
{ // $1蓝
price: 0, // 0~9.9,丢人
price: 1 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(30,136,229,1)',
headerBg: 'rgba(21,101,192,1)',

View File

@@ -3,8 +3,10 @@
<ticker class="style-scope yt-live-chat-renderer" :messages="paidMessages" :hidden="paidMessages.length === 0"></ticker>
<yt-live-chat-item-list-renderer class="style-scope yt-live-chat-renderer" allow-scroll>
<div id="item-scroller" ref="scroller" class="style-scope yt-live-chat-item-list-renderer animated" @scroll="onScroll">
<div id="item-offset" class="style-scope yt-live-chat-item-list-renderer" style="height: 1800px;">
<div id="items" class="style-scope yt-live-chat-item-list-renderer" style="overflow: hidden; transform: translateY(0px);">
<div ref="itemOffset" id="item-offset" class="style-scope yt-live-chat-item-list-renderer" style="height: 0px;">
<div ref="items" id="items" class="style-scope yt-live-chat-item-list-renderer" style="overflow: hidden"
:style="{transform: `translateY(${Math.floor(scrollPixelsRemaining)}px)`}"
>
<template v-for="message in messages">
<text-message :key="message.id" v-if="message.type === MESSAGE_TYPE_TEXT"
class="style-scope yt-live-chat-item-list-renderer"
@@ -17,7 +19,7 @@
:avatarUrl="message.avatarUrl" :title="message.title" :content="message.content"
:time="message.time"
></legacy-paid-message>
<paid-message :key="message.id" v-else
<paid-message :key="message.id" v-else-if="message.type === MESSAGE_TYPE_SUPER_CHAT"
class="style-scope yt-live-chat-item-list-renderer"
:price="message.price" :avatarUrl="message.avatarUrl" :authorName="message.authorName"
:time="message.time" :content="message.content"
@@ -31,12 +33,16 @@
</template>
<script>
import config from '@/api/config'
import Ticker from './Ticker.vue'
import TextMessage from './TextMessage.vue'
import LegacyPaidMessage from './LegacyPaidMessage.vue'
import PaidMessage from './PaidMessage.vue'
import * as constants from './constants'
const CHAT_SMOOTH_ANIMATION_TIME_MS = 84
const SCROLLED_TO_BOTTOM_EPSILON = 15
export default {
name: 'ChatRenderer',
components: {
@@ -46,9 +52,11 @@ export default {
PaidMessage
},
props: {
paidMessages: Array,
messages: Array,
css: String
css: String,
maxNumber: {
type: Number,
default: config.DEFAULT_CONFIG.maxNumber
}
},
data() {
let styleElement = document.createElement('style')
@@ -56,9 +64,34 @@ export default {
return {
MESSAGE_TYPE_TEXT: constants.MESSAGE_TYPE_TEXT,
MESSAGE_TYPE_MEMBER: constants.MESSAGE_TYPE_MEMBER,
MESSAGE_TYPE_SUPER_CHAT: constants.MESSAGE_TYPE_SUPER_CHAT,
styleElement,
canAutoScroll: true
messages: [], // 显示的消息
paidMessages: [], // 固定在上方的消息
smoothedMessageQueue: [], // 平滑消息队列由外部调用addMessages等方法添加
emitSmoothedMessageTimerId: null, // 消费平滑消息队列的定时器ID
enqueueIntervals: [], // 最近进队列的时间间隔,用来估计下次进队列的时间
lastEnqueueTime: null, // 上次进队列的时间
estimatedEnqueueInterval: null, // 估计的下次进队列时间间隔
messagesBuffer: [], // 暂时未显示的消息,当不能自动滚动时会积压在这
preinsertHeight: 0, // 插入新消息之前items的高度
isSmoothed: true, // 是否平滑滚动,当消息太快时不平滑滚动
chatRateMs: 1000, // 用来计算消息速度
scrollPixelsRemaining: 0, // 平滑滚动剩余像素
scrollTimeRemainingMs: 0, // 平滑滚动剩余时间
lastSmoothChatMessageAddMs: null, // 上次showNewMessages时间
smoothScrollRafHandle: null, // 平滑滚动requestAnimationFrame句柄
lastSmoothScrollUpdate: null, // 平滑滚动上一帧时间
atBottom: true // 滚动到底部,用来判断能否自动滚动
}
},
computed: {
canScrollToBottom() {
return this.atBottom/* || this.allowScroll*/
}
},
mounted() {
@@ -67,11 +100,11 @@ export default {
},
beforeDestroy() {
document.head.removeChild(this.styleElement)
},
updated() {
if (this.canAutoScroll) {
this.scrollToBottom()
if (this.emitSmoothedMessageTimerId) {
window.clearTimeout(this.emitSmoothedMessageTimerId)
this.emitSmoothedMessageTimerId = null
}
this.clearMessages()
},
watch: {
css(val) {
@@ -79,12 +112,340 @@ export default {
}
},
methods: {
addMessage(message) {
this.addMessages([message])
},
addMessages(messages) {
this.enqueueMessages(messages)
},
mergeSimilarText(content) {
let res = false
this.forEachRecentMessage(5, message => {
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
return true
}
let longer, shorter
if (message.content.length > content.length) {
longer = message.content
shorter = content
} else {
longer = content
shorter = message.content
}
if (longer.indexOf(shorter) !== -1 // 长的包含短的
&& longer.length - shorter.length < shorter.length // 长度差较小
) {
message.repeated++
res = true
return false
}
return true
})
return res
},
mergeSimilarGift(authorName, price) {
let res = false
this.forEachRecentMessage(5, message => {
if (message.type === constants.MESSAGE_TYPE_SUPER_CHAT
&& message.content === ''
&& message.authorName === authorName
) {
message.price += price
res = true
return false
}
return true
})
return res
},
forEachRecentMessage(num, callback) {
// 从新到老遍历num条消息
for (let i = this.smoothedMessageQueue.length - 1; i >= 0 && num > 0; i--) {
let messageGroup = this.smoothedMessageQueue[i]
for (let j = messageGroup.length - 1; j >= 0 && num-- > 0; j--) {
if (!callback(messageGroup[j])) {
return
}
}
}
for (let arr of [this.messagesBuffer, this.messages]) {
for (let i = arr.length - 1; i >= 0 && num-- > 0; i--) {
if (!callback(arr[i])) {
return
}
}
}
},
delMessage(id) {
this.delMessages([id])
},
delMessages(ids) {
this.enqueueMessages(ids.map(id => {
return {
id: id,
type: constants.MESSAGE_TYPE_DEL
}
}))
},
clearMessages() {
this.messages = []
this.paidMessages = []
this.smoothedMessageQueue = []
this.messagesBuffer = []
this.isSmoothed = true
this.lastSmoothChatMessageAddMs = null
this.chatRateMs = 1000
this.lastSmoothScrollUpdate = null
this.scrollTimeRemainingMs = this.scrollPixelsRemaining = 0
this.smoothScrollRafHandle = null
this.preinsertHeight = 0
this.maybeResizeScrollContainer()
if (!this.atBottom) {
this.scrollToBottom()
}
},
enqueueMessages(messages) {
if (this.lastEnqueueTime) {
let interval = new Date() - this.lastEnqueueTime
// 理论上B站发包间隔1S如果不过滤间隔太短的会导致消息平滑失效
if (interval > 100) {
this.enqueueIntervals.push(interval)
if (this.enqueueIntervals.length > 5) {
this.enqueueIntervals.splice(0, this.enqueueIntervals.length - 5)
}
this.estimatedEnqueueInterval = Math.max(...this.enqueueIntervals)
}
}
this.lastEnqueueTime = new Date()
// 只有要显示的消息需要平滑
let messageGroup = []
for (let message of messages) {
messageGroup.push(message)
if (message.type !== constants.MESSAGE_TYPE_DEL) {
this.smoothedMessageQueue.push(messageGroup)
messageGroup = []
}
}
if (messageGroup.length > 0) {
this.smoothedMessageQueue.push(messageGroup)
}
if (!this.emitSmoothedMessageTimerId) {
this.emitSmoothedMessageTimerId = window.setTimeout(this.emitSmoothedMessages)
}
},
emitSmoothedMessages() {
this.emitSmoothedMessageTimerId = null
if (this.smoothedMessageQueue.length <= 0) {
return
}
// 估计的下次进队列剩余时间
let estimatedNextEnqueueRemainTime = 10 * 1000
if (this.estimatedEnqueueInterval) {
estimatedNextEnqueueRemainTime = Math.max(this.lastEnqueueTime - new Date() + this.estimatedEnqueueInterval, 1)
}
// 最快80ms/条,计算发送的消息数,保证在下次进队列之前消费完队列
let groupNumToEmit
if (this.smoothedMessageQueue.length < estimatedNextEnqueueRemainTime / 80) {
// 队列中消息数很少每次发1条也能发完
groupNumToEmit = 1
} else {
// 每次发1条以上保证按最快速度能发完
groupNumToEmit = Math.ceil(this.smoothedMessageQueue.length / (estimatedNextEnqueueRemainTime / 80))
}
let messageGroups = this.smoothedMessageQueue.splice(0, groupNumToEmit)
let mergedGroup = []
for (let messageGroup of messageGroups) {
for (let message of messageGroup) {
mergedGroup.push(message)
}
}
this.handleMessageGroup(mergedGroup)
if (this.smoothedMessageQueue.length <= 0) {
return
}
let sleepTime
if (groupNumToEmit == 1) {
// 队列中消息数很少随便定个80-1000ms的时间
sleepTime = estimatedNextEnqueueRemainTime / this.smoothedMessageQueue.length
sleepTime *= 0.5 + Math.random()
if (sleepTime > 1000) {
sleepTime = 1000
} else if (sleepTime < 80) {
sleepTime = 80
}
} else {
// 按最快速度发
sleepTime = 80
}
this.emitSmoothedMessageTimerId = window.setTimeout(this.emitSmoothedMessages, sleepTime)
},
handleMessageGroup(messageGroup) {
if (messageGroup.length <= 0) {
return
}
for (let message of messageGroup) {
switch (message.type) {
case constants.MESSAGE_TYPE_TEXT:
case constants.MESSAGE_TYPE_MEMBER:
case constants.MESSAGE_TYPE_SUPER_CHAT:
this.handleAddMessage(message)
break
case constants.MESSAGE_TYPE_DEL:
this.handleDelMessage(message.id)
break
}
}
this.maybeResizeScrollContainer(),
this.flushMessagesBuffer()
this.$nextTick(this.maybeScrollToBottom)
},
handleAddMessage(message) {
message = {
...message,
addTime: new Date() // 添加一个本地时间给Ticker用防止本地时间和服务器时间相差很大的情况
}
this.messagesBuffer.push(message)
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
this.paidMessages.unshift(message)
}
},
handleDelMessage(message) {
let id = message.id
for (let arr of [this.messages, this.paidMessages, this.messagesBuffer]) {
for (let i = 0; i < arr.length; i++) {
if (arr[i].id === id) {
arr.splice(i, 1)
break
}
}
}
},
async flushMessagesBuffer() {
if (this.messagesBuffer.length <= 0) {
return
}
if (!this.canScrollToBottom) {
if (this.messagesBuffer.length > this.maxNumber) {
// 未显示消息数 > 最大可显示数,丢弃
this.messagesBuffer.splice(0, this.messagesBuffer.length - this.maxNumber)
}
return
}
let removeNum = Math.max(this.messages.length + this.messagesBuffer.length - this.maxNumber, 0)
if (removeNum > 0) {
this.messages.splice(0, removeNum)
// 防止同时添加和删除项目时所有的项目重新渲染 https://github.com/vuejs/vue/issues/6857
await this.$nextTick()
}
this.preinsertHeight = this.$refs.items.clientHeight
for (let message of this.messagesBuffer) {
this.messages.push(message)
}
this.messagesBuffer = []
// 等items高度变化
this.$nextTick(this.showNewMessages)
},
showNewMessages() {
let hasScrollBar = this.$refs.items.clientHeight > this.$refs.scroller.clientHeight
this.$refs.itemOffset.style.height = `${this.$refs.items.clientHeight}px`
if (!this.canScrollToBottom || !hasScrollBar) {
return
}
// 计算剩余像素
this.scrollPixelsRemaining += this.$refs.items.clientHeight - this.preinsertHeight
this.scrollToBottom()
// 计算是否平滑滚动、剩余时间
if (!this.lastSmoothChatMessageAddMs) {
this.lastSmoothChatMessageAddMs = performance.now()
}
let interval = performance.now() - this.lastSmoothChatMessageAddMs
this.chatRateMs = 0.9 * this.chatRateMs + 0.1 * interval
if (this.isSmoothed) {
if (this.chatRateMs < 400) {
this.isSmoothed = false
}
} else {
if (this.chatRateMs > 450) {
this.isSmoothed = true
}
}
this.scrollTimeRemainingMs += this.isSmoothed ? CHAT_SMOOTH_ANIMATION_TIME_MS : 0
if (!this.smoothScrollRafHandle) {
this.smoothScrollRafHandle = window.requestAnimationFrame(this.smoothScroll)
}
this.lastSmoothChatMessageAddMs = performance.now()
},
smoothScroll(time) {
if (!this.lastSmoothScrollUpdate) {
// 第一帧
this.lastSmoothScrollUpdate = time
this.smoothScrollRafHandle = window.requestAnimationFrame(this.smoothScroll)
return
}
let interval = time - this.lastSmoothScrollUpdate
if (
this.scrollPixelsRemaining <= 0 || this.scrollPixelsRemaining >= 400 // 已经滚动到底部或者离底部太远则结束
|| interval >= 1000 // 离上一帧时间太久,可能用户切换到其他网页
|| this.scrollTimeRemainingMs <= 0 // 时间已结束
) {
this.resetSmoothScroll()
return
}
let pixelsToScroll = interval / this.scrollTimeRemainingMs * this.scrollPixelsRemaining
this.scrollPixelsRemaining -= pixelsToScroll
if (this.scrollPixelsRemaining < 0) {
this.scrollPixelsRemaining = 0
}
this.scrollTimeRemainingMs -= interval
if (this.scrollTimeRemainingMs < 0) {
this.scrollTimeRemainingMs = 0
}
this.lastSmoothScrollUpdate = time
this.smoothScrollRafHandle = window.requestAnimationFrame(this.smoothScroll)
},
resetSmoothScroll() {
this.scrollTimeRemainingMs = this.scrollPixelsRemaining = 0
this.lastSmoothScrollUpdate = null
if (this.smoothScrollRafHandle) {
window.cancelAnimationFrame(this.smoothScrollRafHandle)
this.smoothScrollRafHandle = null
}
},
maybeResizeScrollContainer() {
this.$refs.itemOffset.style.height = `${this.$refs.items.clientHeight}px`
this.maybeScrollToBottom()
},
maybeScrollToBottom() {
if (this.canScrollToBottom) {
this.scrollToBottom()
}
},
scrollToBottom() {
this.$refs.scroller.scrollTop = this.$refs.scroller.scrollHeight
this.$refs.scroller.scrollTop = Math.pow(2, 24)
this.atBottom = true
},
onScroll() {
this.canAutoScroll = Math.abs(this.$refs.scroller.scrollHeight - this.$refs.scroller.scrollTop
- this.$refs.scroller.clientHeight) < 2
let scroller = this.$refs.scroller
this.atBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < SCROLLED_TO_BOTTOM_EPSILON
this.flushMessagesBuffer()
}
}
}

View File

@@ -2,7 +2,9 @@ export default {
sidebar: {
home: 'Home',
stylegen: 'Style generator',
projectAddress: 'Project address'
help: 'Help',
projectAddress: 'Project address',
giftRecord: 'Super Chat record'
},
home: {
roomIdEmpty: "Room ID can't be empty",
@@ -14,17 +16,17 @@ export default {
showGift: 'Show Super Chats',
mergeSimilarDanmaku: 'Merge similar messages',
minGiftPrice: 'Min price of Super Chats to show (CNY)',
maxSpeed: 'Max speed of messages (/second, 0 for unlimited)',
maxNumber: 'Max number of messages',
block: 'Block',
giftDanmaku: 'System messages (gift effect)',
blockLevel: 'User level lower than',
informalUser: 'Informal users',
unverifiedUser: 'Unverified users',
giftDanmaku: 'Block system messages (gift effect)',
blockLevel: 'Block user level lower than',
informalUser: 'Block informal users',
unverifiedUser: 'Block unverified users',
blockKeywords: 'Block keywords',
onePerLine: 'One per line',
blockUsers: 'Block users',
blockMedalLevel: 'Medal level lower than',
blockMedalLevel: 'Block medal level lower than',
style: 'Style',
@@ -105,5 +107,13 @@ export default {
result: 'Result',
copy: 'Copy',
resetConfig: 'Reset config'
},
help: {
help: 'Help',
p1: '1. Copy the room ID from the Bilibili live room webpage',
p2: '2. Enter the room ID into the room ID on the home page. Copy the room URL after saving the configuration',
p3: '3. Generate styles with the style generator. Copy the CSS',
p4: '4. Add browser source in OBS',
p5: '5. Enter the previously copied room URL at URL, and enter the previously copied CSS at custom CSS'
}
}

View File

@@ -1,8 +1,10 @@
export default {
export default {
sidebar: {
home: 'トップページ',
stylegen: 'スタイルジェネレータ',
projectAddress: 'プロジェクトアドレス'
help: 'ヘルプ',
projectAddress: 'プロジェクトアドレス',
giftRecord: 'スーパーチャット記録'
},
home: {
roomIdEmpty: 'ルームのIDを空白にすることはできません',
@@ -14,17 +16,17 @@
showGift: 'スーパーチャットと新メンバーを表示する',
mergeSimilarDanmaku: '同じコメントを合併する',
minGiftPrice: '最低表示スーパーチャット価格CNY',
maxSpeed: 'コメントの最大速度(バー/秒、0は無制限',
maxNumber: '最大コメント',
block: 'ブロック',
giftDanmaku: 'システムコメント(プレゼント効果)',
blockLevel: 'ユーザーレベルがx未満',
informalUser: '非公式ユーザー',
unverifiedUser: '認証されていないユーザー',
blockKeywords: 'キーワード',
giftDanmaku: 'ブロックシステムコメント(プレゼント効果)',
blockLevel: 'ブロックユーザーレベルがx未満',
informalUser: 'ブロック非公式ユーザー',
unverifiedUser: 'ブロック認証されていないユーザー',
blockKeywords: 'ブロックキーワード',
onePerLine: '1行に1つずつ',
blockUsers: 'ユーザー',
blockMedalLevel: '勲章等級がx未満',
blockUsers: 'ブロックユーザー',
blockMedalLevel: 'ブロック勲章等級がx未満',
style: 'スタイル',
@@ -105,5 +107,13 @@
result: '結果',
copy: 'コピー',
resetConfig: 'デフォルトに戻す'
},
help: {
help: 'ヘルプ',
p1: '1. ビリビリの生放送ウェブから生放送ルームIDをこぴーする',
p2: '2. ホームページでコピーしたIDを入力し、配置を保存すると、ルームのURLをこぴーする',
p3: '3. スタイルジェネレータでお好みのコメント様子を選び、出力したCSSをコピーする',
p4: '4. OBSでブラウザを新規作成する',
p5: '5. プロパティでこぴーしたURLを入力し、カスタムCSSでスタイルジェネレータのCSSを入力する'
}
}

View File

@@ -2,7 +2,9 @@ export default {
sidebar: {
home: '首页',
stylegen: '样式生成器',
projectAddress: '项目地址'
help: '帮助',
projectAddress: '项目地址',
giftRecord: '打赏记录'
},
home: {
roomIdEmpty: '房间ID不能为空',
@@ -11,20 +13,20 @@ export default {
general: '常规',
roomId: '房间ID',
showDanmaku: '显示弹幕',
showGift: '显示礼物和新舰长',
showGift: '显示打赏和新舰长',
mergeSimilarDanmaku: '合并相似弹幕',
minGiftPrice: '最低显示礼物价格(元)',
maxSpeed: '弹幕最大速度(条/秒0为无限制',
minGiftPrice: '最低显示打赏价格(元)',
maxNumber: '最大弹幕数',
block: '屏蔽',
giftDanmaku: '礼物弹幕',
blockLevel: '用户等级低于',
informalUser: '非正式会员',
unverifiedUser: '未绑定手机用户',
giftDanmaku: '屏蔽礼物弹幕',
blockLevel: '屏蔽用户等级低于',
informalUser: '屏蔽非正式会员',
unverifiedUser: '屏蔽未绑定手机用户',
blockKeywords: '屏蔽关键词',
onePerLine: '一行一个',
blockUsers: '屏蔽用户',
blockMedalLevel: '当前直播间勋章等级低于',
blockMedalLevel: '屏蔽当前直播间勋章等级低于',
style: '样式',
@@ -75,7 +77,7 @@ export default {
moderatorMessageBgColor: '房管消息背景色',
memberMessageBgColor: '舰长消息背景色',
scAndNewMember: '礼物、舰长',
scAndNewMember: '打赏、舰长',
firstLineFont: '第一行字体',
firstLineFontSize: '第一行字体尺寸',
firstLineLineHeight: '第一行行高0为默认',
@@ -105,5 +107,13 @@ export default {
result: '结果',
copy: '复制',
resetConfig: '恢复默认设置'
},
help: {
help: '帮助',
p1: '1. 从B站直播间网页复制房间ID',
p2: '2. 把房间ID输入到首页的房间ID保存配置后复制房间URL',
p3: '3. 使用样式生成器生成样式复制CSS',
p4: '4. 在OBS中添加浏览器源',
p5: '5. URL处输入之前复制的房间URL自定义CSS处输入之前复制的CSS'
}
}

View File

@@ -13,11 +13,19 @@
<el-menu-item :index="$router.resolve({name: 'stylegen'}).href">
<i class="el-icon-brush"></i>{{$t('sidebar.stylegen')}}
</el-menu-item>
<el-menu-item :index="$router.resolve({name: 'help'}).href">
<i class="el-icon-question"></i>{{$t('sidebar.help')}}
</el-menu-item>
<a href="https://github.com/xfgryujk/blivechat" target="_blank">
<el-menu-item>
<i class="el-icon-share"></i>{{$t('sidebar.projectAddress')}}
</el-menu-item>
</a>
<a href="https://bilisc.com/" target="_blank">
<el-menu-item>
<i class="el-icon-share"></i>{{$t('sidebar.giftRecord')}}
</el-menu-item>
</a>
<el-submenu index="null">
<template slot="title">
<i class="el-icon-chat-line-square"></i>Language

View File

@@ -8,6 +8,9 @@
<h1 class="sidebar-title">blivechat</h1>
</router-link>
</div>
<div class="version">
v1.2.3
</div>
<sidebar></sidebar>
</el-aside>
<el-main>
@@ -47,6 +50,10 @@ export default {
</script>
<style>
html {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif;
}
html, body, #app, .app-wrapper, .sidebar-container {
height: 100%;
}
@@ -114,6 +121,17 @@ a, a:focus, a:hover {
vertical-align: middle;
}
.sidebar-container .version {
height: 30px;
background: #2b2f3a;
color: #aaa;
font-weight: 600;
line-height: 30px;
font-size: 14px;
vertical-align: middle;
text-align: center;
}
.sidebar-container .is-horizontal {
display: none;
}

View File

@@ -9,6 +9,7 @@ import App from './App.vue'
import Layout from './layout'
import Home from './views/Home.vue'
import StyleGenerator from './views/StyleGenerator'
import Help from './views/Help'
import Room from './views/Room.vue'
import NotFound from './views/NotFound.vue'
@@ -37,7 +38,8 @@ const router = new VueRouter({
component: Layout,
children: [
{path: '', component: Home},
{path: 'stylegen', name: 'stylegen', component: StyleGenerator}
{path: 'stylegen', name: 'stylegen', component: StyleGenerator},
{path: 'help', name: 'help', component: Help}
]
},
{path: '/room/:roomId', name: 'room', component: Room},

View File

@@ -6,6 +6,20 @@ export function mergeConfig (config, defaultConfig) {
return res
}
export default {
mergeConfig
export function formatCurrency (price) {
return new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: price < 100 ? 2 : 0
}).format(price)
}
export function getTimeTextMinSec (date) {
let min = ('00' + date.getMinutes()).slice(-2)
let sec = ('00' + date.getSeconds()).slice(-2)
return `${min}:${sec}`
}
export default {
mergeConfig,
formatCurrency,
getTimeTextMinSec
}

View File

@@ -0,0 +1,21 @@
<template>
<div>
<h1>{{$t('help.help')}}</h1>
<p>{{$t('help.p1')}}</p>
<p><el-image src="/static/img/tutorial/tutorial-1.png"></el-image></p>
<p>{{$t('help.p2')}}</p>
<p><el-image src="/static/img/tutorial/tutorial-2.png"></el-image></p>
<p>{{$t('help.p3')}}</p>
<p><el-image src="/static/img/tutorial/tutorial-3.png"></el-image></p>
<p>{{$t('help.p4')}}</p>
<p><el-image src="/static/img/tutorial/tutorial-4.png"></el-image></p>
<p>{{$t('help.p5')}}</p>
<p><el-image src="/static/img/tutorial/tutorial-5.png"></el-image></p>
</div>
</template>
<script>
export default {
name: 'Help'
}
</script>

View File

@@ -22,8 +22,8 @@
<el-form-item :label="$t('home.minGiftPrice')">
<el-input v-model.number="form.minGiftPrice" type="number" min="0"></el-input>
</el-form-item>
<el-form-item :label="$t('home.maxSpeed')">
<el-input v-model.number="form.maxSpeed" type="number" min="0"></el-input>
<el-form-item :label="$t('home.maxNumber')">
<el-input v-model.number="form.maxNumber" type="number" min="1"></el-input>
</el-form-item>
</el-tab-pane>
@@ -116,7 +116,7 @@ export default {
let resolved = this.$router.resolve({name: 'room', params: {roomId: this.form.roomId},
query: {config_id: window.localStorage.configId}})
this.roomUrl = `http://${window.location.host}${resolved.href}`
this.roomUrl = `${window.location.protocol}//${window.location.host}${resolved.href}`
})
},
enterRoom() {

View File

@@ -1,5 +1,5 @@
<template>
<chat-renderer :paidMessages="paidMessages" :messages="messages" :css="config.css"></chat-renderer>
<chat-renderer ref="renderer" :css="config.css" :maxNumber="config.maxNumber"></chat-renderer>
</template>
<script>
@@ -7,10 +7,13 @@ import config from '@/api/config'
import ChatRenderer from '@/components/ChatRenderer'
import * as constants from '@/components/ChatRenderer/constants'
const COMMAND_JOIN_ROOM = 0
const COMMAND_ADD_TEXT = 1
const COMMAND_ADD_GIFT = 2
const COMMAND_ADD_MEMBER = 3
const COMMAND_HEARTBEAT = 0
const COMMAND_JOIN_ROOM = 1
const COMMAND_ADD_TEXT = 2
const COMMAND_ADD_GIFT = 3
const COMMAND_ADD_MEMBER = 4
const COMMAND_ADD_SUPER_CHAT = 5
const COMMAND_DEL_SUPER_CHAT = 6
export default {
name: 'Room',
@@ -18,57 +21,61 @@ export default {
ChatRenderer
},
data() {
let cfg = {...config.DEFAULT_CONFIG}
cfg.blockKeywords = cfg.blockKeywords.split('\n').filter(val => val)
cfg.blockUsers = cfg.blockUsers.split('\n').filter(val => val)
cfg.maxSpeed = 0
return {
config: cfg,
config: {...config.DEFAULT_CONFIG},
websocket: null,
messagesBufferTimerId: null,
retryCount: 0,
isDestroying: false,
heartbeatTimerId: null,
nextId: 0,
messagesBuffer: [], // 暂时不显示的消息,可能会丢弃
messages: [], // 正在显示的消息
paidMessages: []
}
},
async created() {
// 开发时使用localhost:12450
const url = process.env.NODE_ENV === 'development' ? 'ws://localhost:12450/chat' : `ws://${window.location.host}/chat`
this.websocket = new WebSocket(url)
this.websocket.onopen = this.onWsOpen.bind(this)
this.websocket.onmessage = this.onWsMessage.bind(this)
computed: {
blockKeywords() {
return this.config.blockKeywords.split('\n').filter(val => val)
},
blockUsers() {
return this.config.blockUsers.split('\n').filter(val => val)
}
},
created() {
this.wsConnect()
if (this.$route.query.config_id) {
try {
let cfg = await config.getRemoteConfig(this.$route.query.config_id)
cfg.blockKeywords = cfg.blockKeywords.split('\n').filter(val => val)
cfg.blockUsers = cfg.blockUsers.split('\n').filter(val => val)
this.config = cfg
} catch (e) {
this.$message.error('获取配置失败:' + e)
}
this.updateConfig(this.$route.query.config_id)
}
},
beforeDestroy() {
if (this.messagesBufferTimerId) {
window.clearInterval(this.messagesBufferTimerId)
}
this.isDestroying = true
this.websocket.close()
},
watch: {
config(val) {
if (this.messagesBufferTimerId) {
window.clearInterval(this.messagesBufferTimerId)
this.messagesBufferTimerId = null
}
if (val.maxSpeed > 0) {
this.messagesBufferTimerId = window.setInterval(this.handleMessagesBuffer.bind(this), 1000 / val.maxSpeed)
}
}
},
methods: {
async updateConfig(configId) {
try {
this.config = await config.getRemoteConfig(configId)
} catch (e) {
this.$message.error('获取配置失败:' + e)
}
},
wsConnect() {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
// 开发时使用localhost:12450
const host = process.env.NODE_ENV === 'development' ? 'localhost:12450' : window.location.host
const url = `${protocol}://${host}/chat`
this.websocket = new WebSocket(url)
this.websocket.onopen = this.onWsOpen
this.websocket.onclose = this.onWsClose
this.websocket.onmessage = this.onWsMessage
this.heartbeatTimerId = window.setInterval(this.sendHeartbeat, 10 * 1000)
},
sendHeartbeat() {
this.websocket.send(JSON.stringify({
cmd: COMMAND_HEARTBEAT
}))
},
onWsOpen() {
this.retryCount = 0
this.websocket.send(JSON.stringify({
cmd: COMMAND_JOIN_ROOM,
data: {
@@ -76,19 +83,31 @@ export default {
}
}))
},
onWsClose() {
if (this.heartbeatTimerId) {
window.clearInterval(this.heartbeatTimerId)
this.heartbeatTimerId = null
}
if (this.isDestroying) {
return
}
window.console.log(`掉线重连中${++this.retryCount}`)
this.wsConnect()
},
onWsMessage(event) {
let {cmd, data} = JSON.parse(event.data)
let message = null
let time = data.timestamp ? new Date(data.timestamp * 1000) : new Date()
switch(cmd) {
switch (cmd) {
case COMMAND_ADD_TEXT:
if (!this.config.showDanmaku || !this.filterTextMessage(data) || this.mergeSimilar(data.content)) {
if (!this.config.showDanmaku || !this.filterTextMessage(data) || this.mergeSimilarText(data.content)) {
break
}
message = {
id: `text_${this.nextId++}`,
type: constants.MESSAGE_TYPE_TEXT,
avatarUrl: data.avatarUrl,
time: `${time.getMinutes()}:${time.getSeconds()}`,
time: time,
authorName: data.authorName,
authorType: data.authorType,
content: data.content,
@@ -101,34 +120,63 @@ export default {
break
}
let price = data.totalCoin / 1000
if (price < this.config.minGiftPrice) // 丢人
if (price < this.config.minGiftPrice) { // 丢人
break
}
if (this.mergeSimilarGift(data.authorName, price)) {
break
}
message = {
type: constants.MESSAGE_TYPE_GIFT,
id: `gift_${this.nextId++}`,
type: constants.MESSAGE_TYPE_SUPER_CHAT,
avatarUrl: data.avatarUrl,
authorName: data.authorName,
price: price,
time: `${time.getMinutes()}:${time.getSeconds()}`,
content: `Sent ${data.giftName}x${data.giftNum}`
time: time,
content: '' // 有了SC礼物不需要内容了
}
break
}
case COMMAND_ADD_MEMBER:
if (!this.config.showGift) {
if (!this.config.showGift || !this.filterSuperChatMessage(data)) {
break
}
message = {
id: `member_${this.nextId++}`,
type: constants.MESSAGE_TYPE_MEMBER,
avatarUrl: data.avatarUrl,
time: `${time.getMinutes()}:${time.getSeconds()}`,
time: time,
authorName: data.authorName,
title: 'NEW MEMBER!',
content: `Welcome ${data.authorName}`
content: `Welcome ${data.authorName}!`
}
break
case COMMAND_ADD_SUPER_CHAT:
if (!this.config.showGift) {
break
}
if (data.price < this.config.minGiftPrice) { // 丢人
break
}
message = {
id: `sc_${data.id}`,
type: constants.MESSAGE_TYPE_SUPER_CHAT,
avatarUrl: data.avatarUrl,
authorName: data.authorName,
price: data.price,
time: time,
content: data.content.trim()
}
break
case COMMAND_DEL_SUPER_CHAT:
for (let id of data.ids) {
id = `sc_${id}`
this.$refs.renderer.delMessage(id)
}
break
}
if (message) {
this.addMessageBuffer(message)
this.$refs.renderer.addMessage(message)
}
},
filterTextMessage(data) {
@@ -143,77 +191,32 @@ export default {
} else if (this.config.blockMedalLevel > 0 && data.medalLevel < this.config.blockMedalLevel) {
return false
}
for (let keyword of this.config.blockKeywords) {
return this.filterSuperChatMessage(data)
},
filterSuperChatMessage(data) {
for (let keyword of this.blockKeywords) {
if (data.content.indexOf(keyword) !== -1) {
return false
}
}
for (let user of this.config.blockUsers) {
for (let user of this.blockUsers) {
if (data.authorName === user) {
return false
}
}
return true
},
mergeSimilar(content) {
mergeSimilarText(content) {
if (!this.config.mergeSimilarDanmaku) {
return false
}
for (let i = this.messages.length - 1; i >= 0 && i >= this.messages.length - 5; i--) {
let message = this.messages[i]
if (
(message.content.indexOf(content) !== -1 || content.indexOf(message.content) !== -1) // 包含对方
&& Math.abs(message.content.length - content.length) < Math.min(message.content.length, content.length) // 长度差比两者长度都小
) {
message.repeated++
return true
}
}
return false
return this.$refs.renderer.mergeSimilarText(content)
},
addMessageBuffer(message) {
if (this.config.maxSpeed > 0 && message.type === 0) {
message.addTime = new Date()
this.messagesBuffer.push(message)
} else {
// 无速度限制或者是礼物
this.addMessageShow(message)
mergeSimilarGift(authorName, price) {
if (!this.config.mergeSimilarDanmaku) {
return false
}
},
addMessageShow(message) {
message.id = this.nextId++
message.addTime = new Date()
this.messages.push(message)
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
this.paidMessages.unshift(message)
}
// 防止同时添加和删除项目时所有的项目重新渲染 https://github.com/vuejs/vue/issues/6857
this.$nextTick(() => {
if (this.messages.length > 50) {
this.messages.splice(0, this.messages.length - 50)
}
})
},
handleMessagesBuffer() {
// 3秒内未发出则丢弃
let i
let curTime = new Date()
for (i = 0; i < this.messagesBuffer.length; i++) {
if (curTime - this.messagesBuffer[i].addTime <= 3 * 1000) {
break
}
}
this.messagesBuffer.splice(0, i)
if (this.messagesBuffer.length <= 0) {
return
}
let message = this.messagesBuffer.shift()
// 防止短时间发送多条时不能合并
if (this.mergeSimilar(message.content)) {
this.handleMessagesBuffer()
return
}
this.addMessageShow(message)
return this.$refs.renderer.mergeSimilarGift(authorName, price)
}
}
}

View File

@@ -188,9 +188,9 @@
</el-form>
</el-col>
<el-col :span="12">
<div id="example-container">
<div ref="exampleContainer" id="example-container">
<div id="fakebody">
<chat-renderer :paidMessages="examplePaidMessages" :messages="messages" :css="exampleCss"></chat-renderer>
<chat-renderer ref="renderer" :css="exampleCss"></chat-renderer>
</div>
</div>
</el-col>
@@ -211,7 +211,7 @@ let textMessageTemplate = {
addTime: time,
type: constants.MESSAGE_TYPE_TEXT,
avatarUrl: 'https://static.hdslb.com/images/member/noface.gif',
time: `${time.getMinutes()}:${time.getSeconds()}`,
time: time,
authorName: '',
authorType: constants.AUTHRO_TYPE_NORMAL,
content: '',
@@ -223,7 +223,7 @@ let legacyPaidMessageTemplate = {
addTime: time,
type: constants.MESSAGE_TYPE_MEMBER,
avatarUrl: 'https://static.hdslb.com/images/member/noface.gif',
time: `${time.getMinutes()}:${time.getSeconds()}`,
time: time,
authorName: '',
title: 'NEW MEMBER!',
content: ''
@@ -231,11 +231,11 @@ let legacyPaidMessageTemplate = {
let paidMessageTemplate = {
id: 0,
addTime: time,
type: constants.MESSAGE_TYPE_GIFT,
type: constants.MESSAGE_TYPE_SUPER_CHAT,
avatarUrl: 'https://static.hdslb.com/images/member/noface.gif',
authorName: '',
price: 0,
time: `${time.getMinutes()}:${time.getSeconds()}`,
time: time,
content: ''
}
let nextId = 0
@@ -246,7 +246,8 @@ const EXAMPLE_MESSAGES = [
authorName: 'mob路人',
content: '8888888888',
repeated: 12
}, {
},
{
...textMessageTemplate,
id: nextId++,
authorName: 'member舰长',
@@ -254,43 +255,42 @@ const EXAMPLE_MESSAGES = [
content: '草',
privilegeType: 3,
repeated: 3
}, {
},
{
...textMessageTemplate,
id: nextId++,
authorName: 'admin房管',
authorType: constants.AUTHRO_TYPE_ADMIN,
content: 'kksk'
}, {
},
{
...legacyPaidMessageTemplate,
id: nextId++,
authorName: 'Paryi',
content: 'Welcome Paryi'
}, {
authorName: '吾乐KANA',
content: 'Welcome 吾乐KANA!'
},
{
...paidMessageTemplate,
id: nextId++,
authorName: '石油佬',
price: 1245,
content: 'Sent 小电视飞船x1'
}, {
authorName: '无火的残渣',
price: 66600,
content: 'Sent 小电视飞船x100'
},
{
...textMessageTemplate,
id: nextId++,
authorName: 'streamer主播',
authorType: constants.AUTHRO_TYPE_OWNER,
content: '感谢石油佬送的小电视'
}, {
},
{
...paidMessageTemplate,
id: nextId++,
authorName: '臭DD',
authorName: '夏色祭保護協会会長',
price: 28,
content: 'Sent 礼花x1'
}
]
let examplePaidMessages = []
for (let message of EXAMPLE_MESSAGES) {
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
examplePaidMessages.push(message)
}
}
export default {
name: 'StyleGenerator',
@@ -298,15 +298,14 @@ export default {
ChatRenderer
},
data() {
let config = stylegen.getLocalConfig()
let result = stylegen.getStyle(config)
let stylegenConfig = stylegen.getLocalConfig()
let result = stylegen.getStyle(stylegenConfig)
return {
FONTS: [...fonts.LOCAL_FONTS, ...fonts.NETWORK_FONTS],
form: {...config},
form: {...stylegenConfig},
result,
exampleCss: result.replace(/^body\b/gm, '#fakebody'),
messages: EXAMPLE_MESSAGES,
examplePaidMessages
}
},
computed: {
@@ -314,6 +313,21 @@ export default {
return stylegen.getStyle(this.form)
}
},
watch: {
computedResult: _.debounce(function(val) {
this.result = val
stylegen.setLocalConfig(this.form)
}, 500),
result(val) {
this.exampleCss = val.replace(/^body\b/gm, '#fakebody')
}
},
mounted() {
this.$refs.renderer.addMessages(EXAMPLE_MESSAGES)
let observer = new MutationObserver(() => this.$refs.renderer.scrollToBottom())
observer.observe(this.$refs.exampleContainer, {attributes: true})
},
methods: {
getFontSuggestions(query, callback) {
let res = this.FONTS.map(font => {return {value: font}})
@@ -326,8 +340,8 @@ export default {
callback(res)
},
playAnimation() {
this.messages = []
this.$nextTick(() => this.messages = EXAMPLE_MESSAGES)
this.$refs.renderer.clearMessages()
this.$nextTick(() => this.$refs.renderer.addMessages(EXAMPLE_MESSAGES))
},
copyResult() {
this.$refs.result.select()
@@ -336,15 +350,6 @@ export default {
resetConfig() {
this.form = {...stylegen.DEFAULT_CONFIG}
}
},
watch: {
computedResult: _.debounce(function(val) {
this.result = val
stylegen.setLocalConfig(this.form)
}, 500),
result(val) {
this.exampleCss = val.replace(/^body\b/gm, '#fakebody')
}
}
}
</script>
@@ -357,8 +362,8 @@ export default {
#example-container {
position: fixed;
top: 30px;
right: 30px;
width: 400px;
left: calc(210px + 40px + (100vw - 210px - 40px) / 2);
width: calc((100vw - 210px - 40px) / 2 - 40px - 30px);
height: calc(100vh - 110px);
background-color: #444;
@@ -380,6 +385,9 @@ export default {
background-position:0 0, 16px 0, 16px -16px, 0px 16px;
padding: 25px;
resize: both;
overflow: hidden;
}
.app-wrapper.mobile #example-container {

View File

@@ -63,6 +63,8 @@ export const DEFAULT_CONFIG = {
reverseSlide: false
}
const FALLBACK_FONTS = ', "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "\\5FAE\\8F6F\\96C5\\9ED1", SimHei, Arial, sans-serif'
export function setLocalConfig (config) {
config = mergeConfig(config, DEFAULT_CONFIG)
window.localStorage.stylegenConfig = JSON.stringify(config)
@@ -105,7 +107,7 @@ yt-live-chat-author-chip #author-name {
/* Outlines */
yt-live-chat-renderer * {
${getShowOutlinesStyle(config)}
font-family: "${config.messageFont}", sans-serif;
font-family: "${config.messageFont}"${FALLBACK_FONTS};
font-size: ${config.messageFontSize}px !important;
line-height: ${config.messageLineHeight}px !important;
}
@@ -116,11 +118,11 @@ yt-live-chat-legacy-paid-message-renderer #content {
}
/* Hide scrollbar. */
yt-live-chat-item-list-renderer #items{
yt-live-chat-item-list-renderer #items {
overflow: hidden !important;
}
yt-live-chat-item-list-renderer #item-scroller{
yt-live-chat-item-list-renderer #item-scroller {
overflow: hidden !important;
}
@@ -164,7 +166,7 @@ yt-live-chat-text-message-renderer #chat-badges {
yt-live-chat-text-message-renderer #timestamp {
display: ${config.showTime ? 'inline' : 'none'} !important;
${config.timeColor ? `color: ${config.timeColor} !important;` : ''}
font-family: "${config.timeFont}", sans-serif;
font-family: "${config.timeFont}"${FALLBACK_FONTS};
font-size: ${config.timeFontSize}px !important;
line-height: ${config.timeLineHeight || config.timeFontSize}px !important;
}
@@ -188,7 +190,7 @@ yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="memb
/* Channel names. */
yt-live-chat-text-message-renderer #author-name {
${config.userNameColor ? `color: ${config.userNameColor} !important;` : ''}
font-family: "${config.userNameFont}", sans-serif;
font-family: "${config.userNameFont}"${FALLBACK_FONTS};
font-size: ${config.userNameFontSize}px !important;
line-height: ${config.userNameLineHeight || config.userNameFontSize}px !important;
}
@@ -199,7 +201,7 @@ ${getShowColonStyle(config)}
yt-live-chat-text-message-renderer #message,
yt-live-chat-text-message-renderer #message * {
${config.messageColor ? `color: ${config.messageColor} !important;` : ''}
font-family: "${config.messageFont}", sans-serif;
font-family: "${config.messageFont}"${FALLBACK_FONTS};
font-size: ${config.messageFontSize}px !important;
line-height: ${config.messageLineHeight || config.messageFontSize}px !important;
}
@@ -214,7 +216,7 @@ yt-live-chat-paid-message-renderer #author-name *,
yt-live-chat-legacy-paid-message-renderer #event-text,
yt-live-chat-legacy-paid-message-renderer #event-text * {
${config.firstLineColor ? `color: ${config.firstLineColor} !important;` : ''}
font-family: "${config.firstLineFont}", sans-serif;
font-family: "${config.firstLineFont}"${FALLBACK_FONTS};
font-size: ${config.firstLineFontSize}px !important;
line-height: ${config.firstLineLineHeight || config.firstLineFontSize}px !important;
}
@@ -224,7 +226,7 @@ yt-live-chat-paid-message-renderer #purchase-amount *,
yt-live-chat-legacy-paid-message-renderer #detail-text,
yt-live-chat-legacy-paid-message-renderer #detail-text * {
${config.secondLineColor ? `color: ${config.secondLineColor} !important;` : ''}
font-family: "${config.secondLineFont}", sans-serif;
font-family: "${config.secondLineFont}"${FALLBACK_FONTS};
font-size: ${config.secondLineFontSize}px !important;
line-height: ${config.secondLineLineHeight || config.secondLineFontSize}px !important;
}
@@ -232,7 +234,7 @@ yt-live-chat-legacy-paid-message-renderer #detail-text * {
yt-live-chat-paid-message-renderer #content,
yt-live-chat-paid-message-renderer #content * {
${config.scContentColor ? `color: ${config.scContentColor} !important;` : ''}
font-family: "${config.scContentFont}", sans-serif;
font-family: "${config.scContentFont}"${FALLBACK_FONTS};
font-size: ${config.scContentFontSize}px !important;
line-height: ${config.scContentLineHeight || config.scContentFontSize}px !important;
}
@@ -273,7 +275,7 @@ yt-live-chat-ticker-paid-message-item-renderer *,
yt-live-chat-ticker-sponsor-item-renderer,
yt-live-chat-ticker-sponsor-item-renderer * {
${config.secondLineColor ? `color: ${config.secondLineColor} !important;` : ''}
font-family: "${config.secondLineFont}", sans-serif;
font-family: "${config.secondLineFont}"${FALLBACK_FONTS};
}
yt-live-chat-mode-change-message-renderer,
@@ -382,11 +384,11 @@ function getAnimationStyle (config) {
: ` transform: translateX(${config.reverseSlide ? 16 : -16}px);`
} }`)
curTime += config.fadeInTime
keyframes.push(` ${(curTime / totalTime) * 100}% { opacity: 1; transform: none;}`)
keyframes.push(` ${(curTime / totalTime) * 100}% { opacity: 1; transform: none; }`)
}
if (config.animateOut) {
curTime += config.animateOutWaitTime * 1000
keyframes.push(` ${(curTime / totalTime) * 100}% { opacity: 1; transform: none;}`)
keyframes.push(` ${(curTime / totalTime) * 100}% { opacity: 1; transform: none; }`)
curTime += config.fadeOutTime
keyframes.push(` ${(curTime / totalTime) * 100}% { opacity: 0;${!config.slide ? ''
: ` transform: translateX(${config.reverseSlide ? -16 : 16}px);`

19
main.py
View File

@@ -1,13 +1,15 @@
# -*- coding: utf-8 -*-
import argparse
import webbrowser
import asyncio
import logging
import os
import webbrowser
import tornado.ioloop
import tornado.web
import update
import views.chat
import views.config
import views.main
@@ -31,6 +33,8 @@ def main():
level=logging.INFO if not args.debug else logging.DEBUG
)
asyncio.ensure_future(update.check_update())
app = tornado.web.Application(
[
(r'/chat', views.chat.ChatHandler),
@@ -45,10 +49,15 @@ def main():
debug=args.debug,
autoreload=False
)
app.listen(args.port, args.host)
logger.info('服务器启动:%s:%d', args.host, args.port)
url = 'http://localhost' if args.port == 80 else f'http://localhost:{args.port}'
webbrowser.open(url)
try:
app.listen(args.port, args.host)
except OSError:
logger.warning('Address is used %s:%d', args.host, args.port)
return
finally:
url = 'http://localhost' if args.port == 80 else f'http://localhost:{args.port}'
webbrowser.open(url)
logger.info('Server started: %s:%d', args.host, args.port)
tornado.ioloop.IOLoop.current().start()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 154 KiB

18
update.py Normal file
View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
import aiohttp
VERSION = 'v1.2.3'
async def check_update():
try:
async with aiohttp.ClientSession() as session:
async with session.get('https://api.github.com/repos/xfgryujk/blivechat/releases/latest') as r:
data = await r.json()
if data['name'] != VERSION:
print('New version available:', data['name'])
print(data['body'])
print('Download:', data['html_url'])
except aiohttp.ClientConnectionError:
print('Failed to check update: connection failed')

View File

@@ -19,7 +19,10 @@ class ApiHandler(tornado.web.RequestHandler):
def prepare(self):
if self.request.headers.get('Content-Type', '').startswith('application/json'):
self.json_args = json.loads(self.request.body)
try:
self.json_args = json.loads(self.request.body)
except json.JSONDecodeError:
self.json_args = None
else:
self.json_args = None

View File

@@ -17,13 +17,16 @@ logger = logging.getLogger(__name__)
class Command(enum.IntEnum):
JOIN_ROOM = 0
ADD_TEXT = 1
ADD_GIFT = 2
ADD_MEMBER = 3
HEARTBEAT = 0
JOIN_ROOM = 1
ADD_TEXT = 2
ADD_GIFT = 3
ADD_MEMBER = 4
ADD_SUPER_CHAT = 5
DEL_SUPER_CHAT = 6
DEFAULT_AVATAR_URL = 'https://static.hdslb.com/images/member/noface.gif'
DEFAULT_AVATAR_URL = '//static.hdslb.com/images/member/noface.gif'
_http_session = aiohttp.ClientSession()
_avatar_url_cache: Dict[int, str] = {}
@@ -48,8 +51,8 @@ async def get_avatar_url(user_id):
return DEFAULT_AVATAR_URL
if _last_avatar_failed_time is not None:
if (cur_time - _last_avatar_failed_time).total_seconds() < 5 * 60 + 3:
# 5分钟以内被ban解封大约要15分钟
if (cur_time - _last_avatar_failed_time).total_seconds() < 3 * 60 + 3:
# 3分钟以内被ban解封大约要15分钟
return DEFAULT_AVATAR_URL
else:
_last_avatar_failed_time = None
@@ -59,13 +62,13 @@ async def get_avatar_url(user_id):
async with _http_session.get('https://api.bilibili.com/x/space/acc/info',
params={'mid': user_id}) as r:
if r.status != 200: # 可能会被B站ban
logger.warning('获取头像失败:status=%d %s uid=%d', r.status, r.reason, user_id)
logger.warning('Failed to fetch avatar: status=%d %s uid=%d', r.status, r.reason, user_id)
_last_avatar_failed_time = cur_time
return DEFAULT_AVATAR_URL
data = await r.json()
except aiohttp.ClientConnectionError:
return DEFAULT_AVATAR_URL
url = data['data']['face']
url = data['data']['face'].replace('http:', '').replace('https:', '')
if not url.endswith('noface.gif'):
url += '@48w_48h'
_avatar_url_cache[user_id] = url
@@ -93,9 +96,66 @@ async def _fetch_avatar_loop():
asyncio.ensure_future(_fetch_avatar_loop())
# 重新定义parse_XXX是为了减少对字段名的依赖防止B站改字段名
def _parse_danmaku(client: blivedm.BLiveClient, command):
info = command['info']
if info[3]:
room_id = info[3][3]
medal_level = info[3][0]
else:
room_id = medal_level = 0
return client._on_receive_danmaku(blivedm.DanmakuMessage(
None, None, None, info[0][4], None, None, info[0][9], None,
info[1],
info[2][0], info[2][1], info[2][2], None, None, info[2][5], info[2][6], None,
medal_level, None, None, room_id, None, None,
info[4][0], None, None,
None, None,
info[7]
))
def _parse_gift(client: blivedm.BLiveClient, command):
data = command['data']
return client._on_receive_gift(blivedm.GiftMessage(
data['giftName'], data['num'], data['uname'], data['face'], None,
None, data['timestamp'], None, None,
None, None, None, data['coin_type'], data['total_coin']
))
def _parse_buy_guard(client: blivedm.BLiveClient, command):
data = command['data']
return client._on_buy_guard(blivedm.GuardBuyMessage(
data['uid'], data['username'], None, None, None,
None, None, data['start_time'], None
))
def _parse_super_chat(client: blivedm.BLiveClient, command):
data = command['data']
return client._on_super_chat(blivedm.SuperChatMessage(
data['price'], data['message'], None, data['start_time'],
None, None, data['id'], None,
None, None, data['user_info']['uname'],
data['user_info']['face'], None,
None, None,
None, None, None,
None
))
class Room(blivedm.BLiveClient):
_COMMAND_HANDLERS = {
**blivedm.BLiveClient._COMMAND_HANDLERS,
'DANMU_MSG': _parse_danmaku,
'SEND_GIFT': _parse_gift,
'GUARD_BUY': _parse_buy_guard,
'SUPER_CHAT_MESSAGE': _parse_super_chat
}
def __init__(self, room_id):
super().__init__(room_id, session=_http_session)
super().__init__(room_id, session=_http_session, heartbeat_interval=10)
self.clients: List['ChatHandler'] = []
def stop_and_close(self):
@@ -108,7 +168,10 @@ class Room(blivedm.BLiveClient):
def send_message(self, cmd, data):
body = json.dumps({'cmd': cmd, 'data': data})
for client in self.clients:
client.write_message(body)
try:
client.write_message(body)
except tornado.websocket.WebSocketClosedError:
pass
async def _on_receive_danmaku(self, danmaku: blivedm.DanmakuMessage):
asyncio.ensure_future(self.__on_receive_danmaku(danmaku))
@@ -140,7 +203,7 @@ class Room(blivedm.BLiveClient):
if gift.coin_type != 'gold': # 丢人
return
self.send_message(Command.ADD_GIFT, {
'avatarUrl': gift.face,
'avatarUrl': gift.face.replace('http:', '').replace('https:', ''),
'timestamp': gift.timestamp,
'authorName': gift.uname,
'giftName': gift.gift_name,
@@ -158,75 +221,69 @@ class Room(blivedm.BLiveClient):
'authorName': message.username
})
async def _on_super_chat(self, message: blivedm.SuperChatMessage):
self.send_message(Command.ADD_SUPER_CHAT, {
'avatarUrl': message.face.replace('http:', '').replace('https:', ''),
'timestamp': message.start_time,
'authorName': message.uname,
'price': message.price,
'content': message.message,
'id': message.id
})
async def _on_super_chat_delete(self, message: blivedm.SuperChatDeleteMessage):
self.send_message(Command.ADD_SUPER_CHAT, {
'ids': message.ids
})
class RoomManager:
def __init__(self):
self._rooms: Dict[int, Room] = {}
def add_client(self, room_id, client: 'ChatHandler'):
if room_id in self._rooms:
room = self._rooms[room_id]
else:
logger.info('创建房间%d', room_id)
room = Room(room_id)
self._rooms[room_id] = room
room.start()
async def add_client(self, room_id, client: 'ChatHandler'):
if room_id not in self._rooms:
if not await self._add_room(room_id):
client.close()
return
room = self._rooms[room_id]
room.clients.append(client)
logger.info('%d clients in room %s', len(room.clients), room_id)
if client.application.settings['debug']:
self.__send_test_message(room)
client.send_test_message()
def del_client(self, room_id, client: 'ChatHandler'):
if room_id not in self._rooms:
return
room = self._rooms[room_id]
room.clients.remove(client)
logger.info('%d clients in room %s', len(room.clients), room_id)
if not room.clients:
logger.info('移除房间%d', room_id)
room.stop_and_close()
del self._rooms[room_id]
self._del_room(room_id)
# 测试用
@staticmethod
def __send_test_message(room):
base_data = {
'avatarUrl': 'https://i0.hdslb.com/bfs/face/29b6be8aa611e70a3d3ac219cdaf5e72b604f2de.jpg@48w_48h',
'timestamp': time.time(),
'authorName': 'xfgryujk',
}
text_data = {
**base_data,
'authorType': 0,
'content': '我能吞下玻璃而不伤身体',
'privilegeType': 0,
'isGiftDanmaku': False,
'authorLevel': 20,
'isNewbie': False,
'isMobileVerified': True
}
vip_data = base_data
gift_data = {
**base_data,
'giftName': '礼花',
'giftNum': 1,
'totalCoin': 28000
}
room.send_message(Command.ADD_TEXT, text_data)
text_data['authorName'] = '主播'
text_data['authorType'] = 3
text_data['content'] = "I can eat glass, it doesn't hurt me."
room.send_message(Command.ADD_TEXT, text_data)
room.send_message(Command.ADD_MEMBER, vip_data)
room.send_message(Command.ADD_GIFT, gift_data)
gift_data['giftName'] = '节奏风暴'
gift_data['totalCoin'] = 100000
room.send_message(Command.ADD_GIFT, gift_data)
gift_data['giftName'] = '摩天大楼'
gift_data['totalCoin'] = 450000
room.send_message(Command.ADD_GIFT, gift_data)
gift_data['giftName'] = '小电视飞船'
gift_data['totalCoin'] = 1245000
room.send_message(Command.ADD_GIFT, gift_data)
async def _add_room(self, room_id):
if room_id in self._rooms:
return True
logger.info('Creating room %d', room_id)
room = Room(room_id)
self._rooms[room_id] = room
if await room.init_room():
room.start()
return True
else:
self._del_room(room_id)
return False
def _del_room(self, room_id):
if room_id not in self._rooms:
return
logger.info('Removing room %d', room_id)
room = self._rooms[room_id]
for client in room.clients:
client.close()
room.stop_and_close()
del self._rooms[room_id]
room_manager = RoomManager()
@@ -239,21 +296,24 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
self.room_id = None
def open(self):
logger.info('Websocket连接 %s', self.request.remote_ip)
logger.info('Websocket connected %s', self.request.remote_ip)
def on_message(self, message):
if self.room_id is not None:
return
body = json.loads(message)
if body['cmd'] == Command.JOIN_ROOM:
cmd = body['cmd']
if cmd == Command.HEARTBEAT:
pass
elif cmd == Command.JOIN_ROOM:
if self.room_id is not None:
return
self.room_id = int(body['data']['roomId'])
logger.info('客户端%s加入房间%d', self.request.remote_ip, self.room_id)
room_manager.add_client(self.room_id, self)
logger.info('Client %s is joining room %d', self.request.remote_ip, self.room_id)
asyncio.ensure_future(room_manager.add_client(self.room_id, self))
else:
logger.warning('未知的命令: %s data: %s', body['cmd'], body['data'])
logger.warning('Unknown cmd: %s body: %s', cmd, body)
def on_close(self):
logger.info('Websocket断开 %s room: %s', self.request.remote_ip, self.room_id)
logger.info('Websocket disconnected %s room: %s', self.request.remote_ip, str(self.room_id))
if self.room_id is not None:
room_manager.del_client(self.room_id, self)
@@ -262,3 +322,57 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
if self.application.settings['debug']:
return True
return super().check_origin(origin)
# 测试用
def send_test_message(self):
base_data = {
'avatarUrl': '//i0.hdslb.com/bfs/face/29b6be8aa611e70a3d3ac219cdaf5e72b604f2de.jpg@48w_48h',
'timestamp': time.time(),
'authorName': 'xfgryujk',
}
text_data = {
**base_data,
'authorType': 0,
'content': '我能吞下玻璃而不伤身体',
'privilegeType': 0,
'isGiftDanmaku': False,
'authorLevel': 20,
'isNewbie': False,
'isMobileVerified': True
}
member_data = base_data
gift_data = {
**base_data,
'giftName': '摩天大楼',
'giftNum': 1,
'totalCoin': 450000
}
sc_data = {
**base_data,
'price': 30,
'content': 'The quick brown fox jumps over the lazy dog',
'id': 1
}
self.send_message(Command.ADD_TEXT, text_data)
text_data['authorName'] = '主播'
text_data['authorType'] = 3
text_data['content'] = "I can eat glass, it doesn't hurt me."
self.send_message(Command.ADD_TEXT, text_data)
self.send_message(Command.ADD_MEMBER, member_data)
self.send_message(Command.ADD_SUPER_CHAT, sc_data)
sc_data['price'] = 100
sc_data['content'] = '敏捷的棕色狐狸跳过了懒狗'
sc_data['id'] = 2
self.send_message(Command.ADD_SUPER_CHAT, sc_data)
# self.send_message(Command.DEL_SUPER_CHAT, {'ids': [1, 2]})
self.send_message(Command.ADD_GIFT, gift_data)
gift_data['giftName'] = '小电视飞船'
gift_data['totalCoin'] = 1245000
self.send_message(Command.ADD_GIFT, gift_data)
def send_message(self, cmd, data):
body = json.dumps({'cmd': cmd, 'data': data})
try:
self.write_message(body)
except tornado.websocket.WebSocketClosedError:
pass

View File

@@ -1,30 +1,35 @@
# -*- coding: utf-8 -*-
import json
import uuid
import views.base
from typing import *
configs: Dict[str, dict] = {}
MAX_CONFIG_SIZE = 100 * 1024
ALLOWED_FIELDS = (
'showDanmaku', 'showGift', 'mergeSimilarDanmaku', 'minGiftPrice', 'maxSpeed',
'blockGiftDanmaku', 'blockLevel', 'blockNewbie', 'blockNotMobileVerified',
'blockKeywords', 'blockUsers', 'blockMedalLevel', 'css'
)
configs: Dict[str, dict] = {}
# noinspection PyAbstractClass
class ConfigsHandler(views.base.ApiHandler):
async def post(self):
if not isinstance(self.json_args, dict):
self.set_status(400)
return
config = self.json_args
config_id = str(uuid.uuid4())
config = {
name: self.json_args[name] for name in ALLOWED_FIELDS
}
config['id'] = config_id
config_str = json.dumps(config)
if len(config_str) > MAX_CONFIG_SIZE:
self.set_status(413)
return
configs[config_id] = config
self.write(config_str)
self.set_status(201)
self.write(config)
self.set_header('Content-Type', 'application/json; charset=UTF-8')
if len(configs) > 10000:
for _, key in zip(range(100), configs):
@@ -34,13 +39,23 @@ class ConfigsHandler(views.base.ApiHandler):
# noinspection PyAbstractClass
class ConfigHandler(views.base.ApiHandler):
async def put(self, config_id):
config = configs.get(config_id, None)
if config is None:
if config_id not in configs:
self.set_status(404)
return
for name in ALLOWED_FIELDS:
config[name] = self.json_args[name]
self.write(config)
if not isinstance(self.json_args, dict):
self.set_status(400)
return
config = self.json_args
config['id'] = config_id
config_str = json.dumps(config)
if len(config_str) > MAX_CONFIG_SIZE:
self.set_status(413)
return
configs[config_id] = config
self.write(config_str)
self.set_header('Content-Type', 'application/json; charset=UTF-8')
async def get(self, config_id):
config = configs.get(config_id, None)