Compare commits

..

9 Commits

Author SHA1 Message Date
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
16 changed files with 230 additions and 117 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,14 +8,14 @@
![样式生成器截图](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
@@ -25,6 +25,12 @@
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:12450](http://localhost:12450)输入房间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: 1613238420...efc7f3b3f8

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,
@@ -21,7 +22,7 @@
<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" v-show="content">
<div id="content" class="style-scope yt-live-chat-paid-message-renderer">
<div id="message" dir="auto" class="style-scope yt-live-chat-paid-message-renderer">
{{content}}
</div>

View File

@@ -37,6 +37,9 @@ 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: {
@@ -83,8 +86,8 @@ export default {
this.$refs.scroller.scrollTop = this.$refs.scroller.scrollHeight
},
onScroll() {
this.canAutoScroll = Math.abs(this.$refs.scroller.scrollHeight - this.$refs.scroller.scrollTop
- this.$refs.scroller.clientHeight) < 2
let scroller = this.$refs.scroller
this.canAutoScroll = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < SCROLLED_TO_BOTTOM_EPSILON
}
}
}

View File

@@ -2,7 +2,8 @@ export default {
sidebar: {
home: 'Home',
stylegen: 'Style generator',
projectAddress: 'Project address'
projectAddress: 'Project address',
giftRecord: 'Super Chat record'
},
home: {
roomIdEmpty: "Room ID can't be empty",

View File

@@ -2,7 +2,8 @@
sidebar: {
home: 'トップページ',
stylegen: 'スタイルジェネレータ',
projectAddress: 'プロジェクトアドレス'
projectAddress: 'プロジェクトアドレス',
giftRecord: 'スーパーチャット記録'
},
home: {
roomIdEmpty: 'ルームのIDを空白にすることはできません',

View File

@@ -2,7 +2,8 @@ export default {
sidebar: {
home: '首页',
stylegen: '样式生成器',
projectAddress: '项目地址'
projectAddress: '项目地址',
giftRecord: '打赏记录'
},
home: {
roomIdEmpty: '房间ID不能为空',
@@ -11,9 +12,9 @@ export default {
general: '常规',
roomId: '房间ID',
showDanmaku: '显示弹幕',
showGift: '显示礼物和新舰长',
showGift: '显示打赏和新舰长',
mergeSimilarDanmaku: '合并相似弹幕',
minGiftPrice: '最低显示礼物价格(元)',
minGiftPrice: '最低显示打赏价格(元)',
maxSpeed: '弹幕最大速度(条/秒0为无限制',
maxNumber: '最大弹幕数',
@@ -76,7 +77,7 @@ export default {
moderatorMessageBgColor: '房管消息背景色',
memberMessageBgColor: '舰长消息背景色',
scAndNewMember: '礼物、舰长',
scAndNewMember: '打赏、舰长',
firstLineFont: '第一行字体',
firstLineFontSize: '第一行字体尺寸',
firstLineLineHeight: '第一行行高0为默认',

View File

@@ -18,6 +18,11 @@
<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

@@ -7,7 +7,7 @@ import config from '@/api/config'
import ChatRenderer from '@/components/ChatRenderer'
import * as constants from '@/components/ChatRenderer/constants'
// const COMMAND_HEARTBEAT = 0
const COMMAND_HEARTBEAT = 0
const COMMAND_JOIN_ROOM = 1
const COMMAND_ADD_TEXT = 2
const COMMAND_ADD_GIFT = 3
@@ -27,7 +27,12 @@ export default {
cfg.maxSpeed = 0
return {
config: cfg,
websocket: null,
retryCount: 0,
isDestroying: false,
heartbeatTimerId: null,
messagesBufferTimerId: null,
nextId: 0,
messagesBuffer: [], // 暂时不显示的消息,可能会丢弃
@@ -35,28 +40,17 @@ export default {
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
this.websocket.onmessage = this.onWsMessage
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: {
@@ -66,12 +60,39 @@ export default {
this.messagesBufferTimerId = null
}
if (val.maxSpeed > 0) {
this.messagesBufferTimerId = window.setInterval(this.handleMessagesBuffer.bind(this), 1000 / val.maxSpeed)
this.messagesBufferTimerId = window.setInterval(this.handleMessagesBuffer, 1000 / val.maxSpeed)
}
}
},
methods: {
async updateConfig(configId) {
try {
let cfg = await config.getRemoteConfig(configId)
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)
}
},
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: {
@@ -79,16 +100,28 @@ 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)) {
break
}
message = {
id: `text_${this.nextId++}`,
type: constants.MESSAGE_TYPE_TEXT,
avatarUrl: data.avatarUrl,
time: time,
@@ -108,6 +141,7 @@ export default {
break
}
message = {
id: `gift_${this.nextId++}`,
type: constants.MESSAGE_TYPE_SUPER_CHAT,
avatarUrl: data.avatarUrl,
authorName: data.authorName,
@@ -122,6 +156,7 @@ export default {
break
}
message = {
id: `member_${this.nextId++}`,
type: constants.MESSAGE_TYPE_MEMBER,
avatarUrl: data.avatarUrl,
time: time,
@@ -138,6 +173,7 @@ export default {
break
}
message = {
id: `sc_${data.id}`,
type: constants.MESSAGE_TYPE_SUPER_CHAT,
avatarUrl: data.avatarUrl,
authorName: data.authorName,
@@ -146,10 +182,21 @@ export default {
content: data.content
}
break
case COMMAND_DEL_SUPER_CHAT:
// TODO 删除SC
case COMMAND_DEL_SUPER_CHAT: {
let arrays = [this.messages, this.messagesBuffer, this.paidMessages]
for (let id of data.ids) {
id = `sc_${id}`
for (let arr of arrays) {
for (let i = 0; i < arr.length; i++) {
if (arr[i].id === id) {
arr.splice(i, 1)
}
}
}
}
break
}
}
if (message) {
this.addMessageBuffer(message)
}
@@ -208,7 +255,7 @@ export default {
return false
},
addMessageBuffer(message) {
if (this.config.maxSpeed > 0 && message.type === 0) {
if (this.config.maxSpeed > 0 && message.type === constants.MESSAGE_TYPE_TEXT) {
message.addTime = new Date()
this.messagesBuffer.push(message)
} else {
@@ -217,7 +264,6 @@ export default {
}
},
addMessageShow(message) {
message.id = this.nextId++
message.addTime = new Date()
this.messages.push(message)
if (message.type !== constants.MESSAGE_TYPE_TEXT) {

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

View File

@@ -2,7 +2,7 @@
import aiohttp
VERSION = 'v1.2.0'
VERSION = 'v1.2.2'
async def check_update():

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

@@ -181,70 +181,49 @@ 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('Creating room %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('Removing room %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()
@@ -260,18 +239,21 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
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('Client %s is joining room %d', self.request.remote_ip, self.room_id)
room_manager.add_client(self.room_id, self)
asyncio.ensure_future(room_manager.add_client(self.room_id, self))
else:
logger.warning('Unknown cmd: %s data: %s', body['cmd'], body['data'])
logger.warning('Unknown cmd: %s body: %s', cmd, body)
def on_close(self):
logger.info('Websocket disconnected %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)
@@ -280,3 +262,54 @@ 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': '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
}
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})
self.write_message(body)

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',
'maxNumber', '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)