Compare commits

..

63 Commits

Author SHA1 Message Date
John Smith
c8e4278c39 修复被B站ban后不会恢复获取头像的问题 2020-02-03 19:29:29 +08:00
John Smith
07b47a26ca 更新版本号v1.3.0
close #15
2020-02-03 16:23:55 +08:00
John Smith
8d55331e6c 头像缓存持久化 2020-02-03 16:18:21 +08:00
John Smith
cae06858fc 前端配置放在URL参数 2020-02-01 21:27:50 +08:00
John Smith
8d4e8e6f35 一些前端优化 2020-02-01 18:17:23 +08:00
John Smith
8d40f9f9e5 更新版本号 2020-01-12 23:02:15 +08:00
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
45 changed files with 19900 additions and 6776 deletions

1
.gitignore vendored
View File

@@ -105,3 +105,4 @@ venv.bak/
.idea/
data/database.db

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,22 +8,28 @@
![样式生成器截图](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
5. 在OBS中添加浏览器源输入URL和自定义CSS或者可以在首页的样式设置里输入CSS
3. 用浏览器打开[http://localhost:12450](http://localhost:12450)输入房间ID保存配置复制房间URL
4. 用样式生成器生成样式复制CSS
5. 在OBS中添加浏览器源输入URL和自定义CSS
### 公共服务器
请优先在本地使用,使用公共服务器会有更大的弹幕延迟,而且服务器故障时可能出现直播事故
* [第三方公共服务器](http://chat.bilisc.com/)
* [仅样式生成器](https://style.vtbs.moe/)
### 源代码版
1. 编译前端需要安装Node.js和npm
@@ -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)以下略

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

315
api/chat.py Normal file
View File

@@ -0,0 +1,315 @@
# -*- coding: utf-8 -*-
import asyncio
import enum
import json
import logging
import time
from typing import *
import aiohttp
import tornado.websocket
import blivedm.blivedm as blivedm
import models.avatar
logger = logging.getLogger(__name__)
class Command(enum.IntEnum):
HEARTBEAT = 0
JOIN_ROOM = 1
ADD_TEXT = 2
ADD_GIFT = 3
ADD_MEMBER = 4
ADD_SUPER_CHAT = 5
DEL_SUPER_CHAT = 6
_http_session = aiohttp.ClientSession()
room_manager: Optional['RoomManager'] = None
def init():
global room_manager
room_manager = RoomManager()
class Room(blivedm.BLiveClient):
# 重新定义parse_XXX是为了减少对字段名的依赖防止B站改字段名
def __parse_danmaku(self, command):
info = command['info']
if info[3]:
room_id = info[3][3]
medal_level = info[3][0]
else:
room_id = medal_level = 0
return self._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(self, command):
data = command['data']
return self._on_receive_gift(blivedm.GiftMessage(
data['giftName'], data['num'], data['uname'], data['face'], None,
data['uid'], data['timestamp'], None, None,
None, None, None, data['coin_type'], data['total_coin']
))
def __parse_buy_guard(self, command):
data = command['data']
return self._on_buy_guard(blivedm.GuardBuyMessage(
data['uid'], data['username'], None, None, None,
None, None, data['start_time'], None
))
def __parse_super_chat(self, command):
data = command['data']
return self._on_super_chat(blivedm.SuperChatMessage(
data['price'], data['message'], None, data['start_time'],
None, None, data['id'], None,
None, data['uid'], data['user_info']['uname'],
data['user_info']['face'], None,
None, None,
None, None, None,
None
))
_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, heartbeat_interval=10)
self.clients: List['ChatHandler'] = []
def stop_and_close(self):
if self.is_running:
future = self.stop()
future.add_done_callback(lambda _future: asyncio.ensure_future(self.close()))
else:
asyncio.ensure_future(self.close())
def send_message(self, cmd, data):
body = json.dumps({'cmd': cmd, 'data': data})
for client in self.clients:
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))
async def __on_receive_danmaku(self, danmaku: blivedm.DanmakuMessage):
if danmaku.uid == self.room_owner_uid:
author_type = 3 # 主播
elif danmaku.admin:
author_type = 2 # 房管
elif danmaku.privilege_type != 0: # 1总督2提督3舰长
author_type = 1 # 舰队
else:
author_type = 0
self.send_message(Command.ADD_TEXT, {
'avatarUrl': await models.avatar.get_avatar_url(danmaku.uid),
'timestamp': danmaku.timestamp,
'authorName': danmaku.uname,
'authorType': author_type,
'content': danmaku.msg,
'privilegeType': danmaku.privilege_type,
'isGiftDanmaku': bool(danmaku.msg_type),
'authorLevel': danmaku.user_level,
'isNewbie': danmaku.urank < 10000,
'isMobileVerified': bool(danmaku.mobile_verify),
'medalLevel': 0 if danmaku.room_id != self.room_id else danmaku.medal_level
})
async def _on_receive_gift(self, gift: blivedm.GiftMessage):
avatar_url = gift.face.replace('http:', '').replace('https:', '')
models.avatar.update_avatar_cache(gift.uid, avatar_url)
if gift.coin_type != 'gold': # 丢人
return
self.send_message(Command.ADD_GIFT, {
'avatarUrl': avatar_url,
'timestamp': gift.timestamp,
'authorName': gift.uname,
'giftName': gift.gift_name,
'giftNum': gift.num,
'totalCoin': gift.total_coin
})
async def _on_buy_guard(self, message: blivedm.GuardBuyMessage):
asyncio.ensure_future(self.__on_buy_guard(message))
async def __on_buy_guard(self, message: blivedm.GuardBuyMessage):
self.send_message(Command.ADD_MEMBER, {
'avatarUrl': await models.avatar.get_avatar_url(message.uid),
'timestamp': message.start_time,
'authorName': message.username
})
async def _on_super_chat(self, message: blivedm.SuperChatMessage):
avatar_url = message.face.replace('http:', '').replace('https:', '')
models.avatar.update_avatar_cache(message.uid, avatar_url)
self.send_message(Command.ADD_SUPER_CHAT, {
'avatarUrl': avatar_url,
'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] = {}
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']:
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:
self._del_room(room_id)
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]
# noinspection PyAbstractClass
class ChatHandler(tornado.websocket.WebSocketHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.room_id = None
def open(self):
logger.info('Websocket connected %s', self.request.remote_ip)
def on_message(self, message):
body = json.loads(message)
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)
asyncio.ensure_future(room_manager.add_client(self.room_id, self))
else:
logger.warning('Unknown cmd: %s body: %s', cmd, body)
def on_close(self):
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)
# 跨域测试用
def check_origin(self, origin):
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

Submodule blivedm updated: 9eb953720c...15669a2084

43
config.py Normal file
View File

@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
import configparser
import logging
import os
from typing import *
logger = logging.getLogger(__name__)
CONFIG_PATH = os.path.join('data', 'config.ini')
_config: Optional['AppConfig'] = None
def init():
reload()
def reload():
config = AppConfig()
if config.load(CONFIG_PATH):
global _config
_config = config
def get_config():
return _config
class AppConfig:
def __init__(self):
self.database_url = 'sqlite:///data/database.db'
def load(self, path):
config = configparser.ConfigParser()
config.read(path)
try:
app_section = config['app']
self.database_url = app_section['database_url']
except (KeyError, ValueError):
logger.exception('Failed to load config:')
return False
return True

8
data/config.ini Normal file
View File

@@ -0,0 +1,8 @@
[app]
# See https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls
database_url = sqlite:///data/database.db
# DON'T modify this section
[DEFAULT]
database_url = sqlite:///data/database.db

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,11 @@
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,
@@ -16,9 +13,7 @@ export const DEFAULT_CONFIG = {
blockNotMobileVerified: true,
blockKeywords: '',
blockUsers: '',
blockMedalLevel: 0,
css: stylegen.getStyle(stylegen.DEFAULT_CONFIG)
blockMedalLevel: 0
}
export function setLocalConfig (config) {
@@ -32,27 +27,3 @@ export function getLocalConfig () {
}
return mergeConfig(JSON.parse(window.localStorage.config), DEFAULT_CONFIG)
}
export async function createRemoteConfig (config) {
config = mergeConfig(config, DEFAULT_CONFIG)
return (await axios.post('/config', config)).data
}
export async function setRemoteConfig (id, config) {
config = mergeConfig(config, DEFAULT_CONFIG)
return (await axios.put(`/config/${id}`, config)).data
}
export async function getRemoteConfig (id) {
let config = (await axios.get(`/config/${id}`)).data
return mergeConfig(config, DEFAULT_CONFIG)
}
export default {
DEFAULT_CONFIG,
setLocalConfig,
getLocalConfig,
createRemoteConfig,
setRemoteConfig,
getRemoteConfig
}

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 * as 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 * as 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}}
@@ -17,10 +17,12 @@
></author-badge>
</span>
</yt-live-chat-author-chip>
<span id="message" class="style-scope yt-live-chat-text-message-renderer">{{content}}</span>
<el-badge :value="repeated" :max="99" v-show="repeated > 1" class="style-scope yt-live-chat-text-message-renderer"
:style="{'--repeated-mark-color': repeatedMarkColor}"
></el-badge>
<span id="message" class="style-scope yt-live-chat-text-message-renderer">
{{content}}
<el-badge :value="repeated" :max="99" v-show="repeated > 1" class="style-scope yt-live-chat-text-message-renderer"
:style="{'--repeated-mark-color': repeatedMarkColor}"
></el-badge>
</span>
</div>
</yt-live-chat-text-message-renderer>
</template>
@@ -29,9 +31,11 @@
import ImgShadow from './ImgShadow.vue'
import AuthorBadge from './AuthorBadge.vue'
import * as constants from './constants'
import * as 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 +45,7 @@ export default {
},
props: {
avatarUrl: String,
time: String,
time: Date,
authorName: String,
authorType: Number,
content: String,
@@ -49,6 +53,9 @@ export default {
repeated: Number
},
computed: {
timeText() {
return utils.getTimeTextMinSec(this.time)
},
authorTypeText() {
return constants.AUTHOR_TYPE_TO_TEXT[this.authorType]
},
@@ -65,18 +72,18 @@ 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]}%)`
}
}
}
</script>
<style>
yt-live-chat-text-message-renderer>#content>.el-badge {
margin-left: 10px;
yt-live-chat-text-message-renderer>#content>#message>.el-badge {
margin-left: 5px;
}
yt-live-chat-text-message-renderer>#content>.el-badge .el-badge__content {
yt-live-chat-text-message-renderer>#content>#message>.el-badge .el-badge__content {
font-size: 12px !important;
line-height: 18px !important;
text-shadow: none !important;

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 * as 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,342 @@ export default {
}
},
methods: {
addMessage(message) {
this.addMessages([message])
},
addMessages(messages) {
this.enqueueMessages(messages)
},
mergeSimilarText(content) {
content = content.trim().toLowerCase()
let res = false
this.forEachRecentMessage(5, message => {
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
return true
}
let messageContent = message.content.trim().toLowerCase()
let longer, shorter
if (messageContent.length > content.length) {
longer = messageContent
shorter = content
} else {
longer = content
shorter = messageContent
}
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,29 +16,24 @@ 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',
style: 'Style',
blockMedalLevel: 'Block medal level lower than',
roomUrl: 'Room URL',
copy: 'Copy',
saveConfig: 'Save config',
enterRoom: 'Enter room',
exportConfig: 'Export config',
importConfig: 'Import config',
failedToSave: 'Failed to save: ',
successfullySaved: 'Successfully saved',
failedToParseConfig: 'Failed to parse config: '
},
stylegen: {
@@ -105,5 +102,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,29 +16,24 @@
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未満',
style: 'スタイル',
blockUsers: 'ブロックユーザー',
blockMedalLevel: 'ブロック勲章等級がx未満',
roomUrl: 'ルームのURL',
copy: 'コピー',
saveConfig: 'コンフィグを保存する',
enterRoom: 'ルームに入る',
exportConfig: 'コンフィグの導出',
importConfig: 'コンフィグの導入',
failedToSave: '保存に失敗しました:',
successfullySaved: '保存に成功しました',
failedToParseConfig: 'コンフィグ解析に失敗しました'
},
stylegen: {
@@ -105,5 +102,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,32 +13,27 @@ 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: '当前直播间勋章等级低于',
style: '样式',
blockMedalLevel: '屏蔽当前直播间勋章等级低于',
roomUrl: '房间URL',
copy: '复制',
saveConfig: '保存配置',
enterRoom: '进入房间',
exportConfig: '导出配置',
importConfig: '导入配置',
failedToSave: '保存失败:',
successfullySaved: '保存成功',
failedToParseConfig: '配置解析失败:'
},
stylegen: {
@@ -75,7 +72,7 @@ export default {
moderatorMessageBgColor: '房管消息背景色',
memberMessageBgColor: '舰长消息背景色',
scAndNewMember: '礼物、舰长',
scAndNewMember: '打赏、舰长',
firstLineFont: '第一行字体',
firstLineFontSize: '第一行字体尺寸',
firstLineLineHeight: '第一行行高0为默认',
@@ -89,8 +86,8 @@ export default {
scContentLineLineHeight: 'Super Chat内容行高0为默认',
scContentLineColor: 'Super Chat内容颜色',
showNewMemberBg: '显示新舰长背景',
showScTicker: '显示Super Chat贴纸',
showOtherThings: '显示Super Chat贴纸之外的内容',
showScTicker: '显示Super Chat固定栏',
showOtherThings: '显示Super Chat固定栏之外的内容',
animation: '动画',
animateIn: '进入动画',
@@ -105,5 +102,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.3.0
</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,29 @@ export function mergeConfig (config, defaultConfig) {
return res
}
export default {
mergeConfig
export function toBool (val) {
if (typeof val === 'string') {
return val !== 'false' && val !== ''
}
return !!val
}
export function toInt (val, _default) {
let res = parseInt(val)
if (isNaN(res)) {
res = _default
}
return res
}
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}`
}

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>
@@ -50,21 +50,14 @@
<el-slider v-model="form.blockMedalLevel" show-input :min="0" :max="20"></el-slider>
</el-form-item>
</el-tab-pane>
<el-tab-pane :label="$t('home.style')">
<el-form-item label="CSS">
<el-input v-model="form.css" type="textarea" :rows="20"></el-input>
</el-form-item>
</el-tab-pane>
</el-tabs>
<el-divider></el-divider>
<el-form-item :label="$t('home.roomUrl')" v-show="roomUrl">
<el-input ref="roomUrlInput" readonly :value="roomUrl" style="width: calc(100% - 6em); margin-right: 1em;"></el-input>
<el-form-item :label="$t('home.roomUrl')">
<el-input ref="roomUrlInput" readonly :value="roomUrl" style="width: calc(100% - 8em); margin-right: 1em;"></el-input>
<el-button type="primary" @click="copyUrl">{{$t('home.copy')}}</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveConfig">{{$t('home.saveConfig')}}</el-button>
<el-button type="primary" :disabled="!roomUrl" @click="enterRoom">{{$t('home.enterRoom')}}</el-button>
<el-button type="primary" @click="exportConfig">{{$t('home.exportConfig')}}</el-button>
<el-button type="primary" @click="importConfig">{{$t('home.importConfig')}}</el-button>
@@ -73,10 +66,11 @@
</template>
<script>
import _ from 'lodash'
import download from 'downloadjs'
import {mergeConfig} from '@/utils'
import config from '@/api/config'
import * as config from '@/api/config'
export default {
name: 'Home',
@@ -85,40 +79,27 @@ export default {
form: {
roomId: parseInt(window.localStorage.roomId || '1'),
...config.getLocalConfig()
},
roomUrl: ''
}
}
},
computed: {
roomUrl() {
if (this.form.roomId === '') {
return ''
}
let query = {...this.form}
delete query.roomId
let resolved = this.$router.resolve({name: 'room', params: {roomId: this.form.roomId}, query})
return `${window.location.protocol}//${window.location.host}${resolved.href}`
}
},
watch: {
roomUrl: _.debounce(function() {
window.localStorage.roomId = this.form.roomId
config.setLocalConfig(this.form)
}, 500)
},
methods: {
saveConfig() {
this.$refs.form.validate(async valid => {
if (!valid) {
return
}
window.localStorage.roomId = this.form.roomId
config.setLocalConfig(this.form)
try {
if (window.localStorage.configId) {
try {
await config.setRemoteConfig(window.localStorage.configId, this.form)
} catch (e) { // 404
window.localStorage.configId = (await config.createRemoteConfig(this.form)).id
}
} else {
window.localStorage.configId = (await config.createRemoteConfig(this.form)).id
}
} catch (e) {
this.$message.error(this.$t('home.failedToSave') + e)
return
}
this.$message({message: this.$t('home.successfullySaved'), type: 'success'})
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}`
})
},
enterRoom() {
window.open(this.roomUrl, `room ${this.form.roomId}`, 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
},

View File

@@ -1,16 +1,20 @@
<template>
<chat-renderer :paidMessages="paidMessages" :messages="messages" :css="config.css"></chat-renderer>
<chat-renderer ref="renderer" :maxNumber="config.maxNumber"></chat-renderer>
</template>
<script>
import config from '@/api/config'
import {mergeConfig, toBool, toInt} from '@/utils'
import * as 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 +22,75 @@ 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)
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)
}
computed: {
blockKeywords() {
return this.config.blockKeywords.split('\n').filter(val => val)
},
blockUsers() {
return this.config.blockUsers.split('\n').filter(val => val)
}
},
created() {
this.wsConnect()
this.updateConfig()
},
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: {
updateConfig() {
let cfg = {}
// 留空的使用默认值
for (let i in this.$route.query) {
if (this.$route.query[i] !== '') {
cfg[i] = this.$route.query[i]
}
}
cfg = mergeConfig(cfg, config.DEFAULT_CONFIG)
cfg.minGiftPrice = toInt(cfg.minGiftPrice, config.DEFAULT_CONFIG.minGiftPrice)
cfg.mergeSimilarDanmaku = toBool(cfg.mergeSimilarDanmaku)
cfg.showDanmaku = toBool(cfg.showDanmaku)
cfg.showGift = toBool(cfg.showGift)
cfg.maxNumber = toInt(cfg.maxNumber, config.DEFAULT_CONFIG.maxNumber)
cfg.blockGiftDanmaku = toBool(cfg.blockGiftDanmaku)
cfg.blockLevel = toInt(cfg.blockLevel, config.DEFAULT_CONFIG.blockLevel)
cfg.blockNewbie = toBool(cfg.blockNewbie)
cfg.blockNotMobileVerified = toBool(cfg.blockNotMobileVerified)
cfg.blockMedalLevel = toInt(cfg.blockMedalLevel, config.DEFAULT_CONFIG.blockMedalLevel)
this.config = cfg
},
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 +98,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 +135,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.filterNewMemberMessage(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 || !this.filterSuperChatMessage(data)) {
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 +206,35 @@ 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) {
return this.filterNewMemberMessage(data)
},
filterNewMemberMessage(data) {
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)
}
}
}

File diff suppressed because one or more lines are too long

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>
@@ -200,8 +200,8 @@
<script>
import _ from 'lodash'
import stylegen from './stylegen'
import fonts from './fonts'
import * as stylegen from './stylegen'
import * as fonts from './fonts'
import ChatRenderer from '@/components/ChatRenderer'
import * as constants from '@/components/ChatRenderer/constants'
@@ -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: '少年Pi',
content: 'Welcome 少年Pi!'
},
{
...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: '感谢石油佬送的小电视'
}, {
content: '老板大气,老板身体健康'
},
{
...paidMessageTemplate,
id: nextId++,
authorName: '臭DD',
price: 28,
content: 'Sent 礼花x1'
authorName: '夏色祭保護協会会長',
price: 30,
content: '言いたいことがあるんだよ!'
}
]
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

@@ -1,5 +1,5 @@
import {mergeConfig} from '@/utils'
import fonts from './fonts'
import * as fonts from './fonts'
export const DEFAULT_CONFIG = {
showOutlines: true,
@@ -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);`
@@ -402,10 +404,3 @@ yt-live-chat-legacy-paid-message-renderer {
animation-fill-mode: both;
}`
}
export default {
DEFAULT_CONFIG,
setLocalConfig,
getLocalConfig,
getStyle
}

67
main.py
View File

@@ -1,54 +1,79 @@
# -*- coding: utf-8 -*-
import argparse
import webbrowser
import logging
import os
import webbrowser
import tornado.ioloop
import tornado.web
import views.chat
import views.config
import views.main
import api.chat
import api.main
import config
import models.avatar
import models.database
import update
logger = logging.getLogger(__name__)
WEB_ROOT = os.path.join(os.path.dirname(__file__), 'frontend', 'dist')
routes = [
(r'/chat', api.chat.ChatHandler),
(r'/((css|fonts|img|js|static)/.*)', tornado.web.StaticFileHandler, {'path': WEB_ROOT}),
(r'/(favicon\.ico)', tornado.web.StaticFileHandler, {'path': WEB_ROOT}),
(r'/.*', api.main.MainHandler, {'path': WEB_ROOT})
]
def main():
args = parse_args()
init_logging(args.debug)
config.init()
models.database.init(args.debug)
models.avatar.init()
api.chat.init()
update.check_update()
run_server(args.host, args.port, args.debug)
def parse_args():
parser = argparse.ArgumentParser(description='用于OBS的仿YouTube风格的bilibili直播聊天层')
parser.add_argument('--host', help='服务器host默认为127.0.0.1', default='127.0.0.1')
parser.add_argument('--port', help='服务器端口默认为12450', type=int, default=12450)
parser.add_argument('--debug', help='调试模式', action='store_true')
args = parser.parse_args()
return parser.parse_args()
def init_logging(debug):
logging.basicConfig(
format='{asctime} {levelname} [{name}]: {message}',
datefmt='%Y-%m-%d %H:%M:%S',
style='{',
level=logging.INFO if not args.debug else logging.DEBUG
level=logging.INFO if not debug else logging.DEBUG
)
app = tornado.web.Application(
[
(r'/chat', views.chat.ChatHandler),
(r'/config', views.config.ConfigsHandler),
(r'/config/(.+)', views.config.ConfigHandler),
(r'/((css|fonts|img|js|static)/.*)', tornado.web.StaticFileHandler, {'path': WEB_ROOT}),
(r'/(favicon\.ico)', tornado.web.StaticFileHandler, {'path': WEB_ROOT}),
(r'/.*', views.main.MainHandler, {'path': WEB_ROOT})
],
websocket_ping_interval=30,
debug=args.debug,
def run_server(host, port, debug):
app = tornado.web.Application(
routes,
websocket_ping_interval=10,
debug=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(port, host)
except OSError:
logger.warning('Address is used %s:%d', host, port)
return
finally:
url = 'http://localhost' if port == 80 else f'http://localhost:{port}'
webbrowser.open(url)
logger.info('Server started: %s:%d', host, port)
tornado.ioloop.IOLoop.current().start()

189
models/avatar.py Normal file
View File

@@ -0,0 +1,189 @@
# -*- coding: utf-8 -*-
import asyncio
import datetime
import logging
from typing import *
import aiohttp
import sqlalchemy
import sqlalchemy.exc
import models.database
logger = logging.getLogger(__name__)
DEFAULT_AVATAR_URL = '//static.hdslb.com/images/member/noface.gif'
_main_event_loop = asyncio.get_event_loop()
_http_session = aiohttp.ClientSession()
# user_id -> avatar_url
_avatar_url_cache: Dict[int, str] = {}
# (user_id, future)
_fetch_task_queue = asyncio.Queue(15)
_last_fetch_failed_time: Optional[datetime.datetime] = None
def init():
asyncio.ensure_future(_get_avatar_url_from_web_consumer())
async def get_avatar_url(user_id):
avatar_url = get_avatar_url_from_memory(user_id)
if avatar_url is not None:
return avatar_url
avatar_url = await get_avatar_url_from_database(user_id)
if avatar_url is not None:
return avatar_url
return await get_avatar_url_from_web(user_id)
def get_avatar_url_from_memory(user_id):
return _avatar_url_cache.get(user_id, None)
def get_avatar_url_from_database(user_id) -> Awaitable[Optional[str]]:
return asyncio.get_event_loop().run_in_executor(
None, _do_get_avatar_url_from_database, user_id
)
def _do_get_avatar_url_from_database(user_id):
try:
with models.database.get_session() as session:
user = session.query(BilibiliUser).filter(BilibiliUser.uid == user_id).one_or_none()
if user is None:
return None
avatar_url = user.avatar_url
# 如果离上次更新太久就更新所有缓存
if (datetime.datetime.now() - user.update_time).days >= 3:
def refresh_cache():
try:
del _avatar_url_cache[user_id]
except KeyError:
pass
get_avatar_url_from_web(user_id)
_main_event_loop.call_soon(refresh_cache)
else:
# 否则只更新内存缓存
_update_avatar_cache_in_memory(user_id, avatar_url)
except sqlalchemy.exc.OperationalError:
# SQLite会锁整个文件忽略就行
return None
except sqlalchemy.exc.SQLAlchemyError:
logger.exception('_do_get_avatar_url_from_database failed:')
return None
return avatar_url
def get_avatar_url_from_web(user_id) -> Awaitable[str]:
future = _main_event_loop.create_future()
try:
_fetch_task_queue.put_nowait((user_id, future))
except asyncio.QueueFull:
future.set_result(DEFAULT_AVATAR_URL)
return future
async def _get_avatar_url_from_web_consumer():
while True:
try:
user_id, future = await _fetch_task_queue.get()
# 先查缓存防止队列中出现相同uid时重复获取
avatar_url = get_avatar_url_from_memory(user_id)
if avatar_url is not None:
continue
# 防止在被ban的时候获取
global _last_fetch_failed_time
if _last_fetch_failed_time is not None:
cur_time = datetime.datetime.now()
if (cur_time - _last_fetch_failed_time).total_seconds() < 3 * 60 + 3:
# 3分钟以内被ban则先返回默认头像解封大约要15分钟
future.set_result(DEFAULT_AVATAR_URL)
continue
else:
_last_fetch_failed_time = None
asyncio.ensure_future(_get_avatar_url_from_web_coroutine(user_id, future))
# 限制频率防止被B站ban
await asyncio.sleep(0.2)
except:
pass
async def _get_avatar_url_from_web_coroutine(user_id, future):
try:
avatar_url = await _do_get_avatar_url_from_web(user_id)
except BaseException as e:
future.set_exception(e)
return
future.set_result(avatar_url)
async def _do_get_avatar_url_from_web(user_id):
try:
async with _http_session.get('https://api.bilibili.com/x/space/acc/info',
params={'mid': user_id}) as r:
if r.status != 200:
logger.warning('Failed to fetch avatar: status=%d %s uid=%d', r.status, r.reason, user_id)
if r.status == 412:
# 被B站ban了
global _last_fetch_failed_time
_last_fetch_failed_time = datetime.datetime.now()
return DEFAULT_AVATAR_URL
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return DEFAULT_AVATAR_URL
avatar_url = data['data']['face'].replace('http:', '').replace('https:', '')
if not avatar_url.endswith('noface.gif'):
avatar_url += '@48w_48h'
update_avatar_cache(user_id, avatar_url)
return avatar_url
def update_avatar_cache(user_id, avatar_url):
_update_avatar_cache_in_memory(user_id, avatar_url)
asyncio.get_event_loop().run_in_executor(
None, _update_avatar_cache_in_database, user_id, avatar_url
)
def _update_avatar_cache_in_memory(user_id, avatar_url):
_avatar_url_cache[user_id] = avatar_url
if len(_avatar_url_cache) > 50000:
for _, key in zip(range(100), _avatar_url_cache):
del _avatar_url_cache[key]
def _update_avatar_cache_in_database(user_id, avatar_url):
try:
with models.database.get_session() as session:
user = session.query(BilibiliUser).filter(BilibiliUser.uid == user_id).one_or_none()
if user is None:
user = BilibiliUser(uid=user_id, avatar_url=avatar_url,
update_time=datetime.datetime.now())
session.add(user)
else:
user.avatar_url = avatar_url
user.update_time = datetime.datetime.now()
session.commit()
except (sqlalchemy.exc.OperationalError, sqlalchemy.exc.IntegrityError):
# SQLite会锁整个文件忽略就行另外还有多线程导致ID重复的问题
pass
except sqlalchemy.exc.SQLAlchemyError:
logger.exception('_update_avatar_cache_in_database failed:')
class BilibiliUser(models.database.OrmBase):
__tablename__ = 'bilibili_users'
uid = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)
avatar_url = sqlalchemy.Column(sqlalchemy.Text)
update_time = sqlalchemy.Column(sqlalchemy.DateTime)

34
models/database.py Normal file
View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
import contextlib
from typing import *
import sqlalchemy.ext.declarative
import sqlalchemy.orm
import config
OrmBase = sqlalchemy.ext.declarative.declarative_base()
engine = None
DbSession: Optional[Type[sqlalchemy.orm.Session]] = None
def init(debug):
cfg = config.get_config()
global engine, DbSession
engine = sqlalchemy.create_engine(cfg.database_url, echo=debug)
DbSession = sqlalchemy.orm.sessionmaker(bind=engine)
OrmBase.metadata.create_all(engine)
@contextlib.contextmanager
def get_session():
session = DbSession()
try:
yield session
except:
session.rollback()
raise
finally:
session.close()

View File

@@ -1,2 +1,3 @@
aiohttp==3.5.4
sqlalchemy==1.3.13
tornado==6.0.2

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

24
update.py Normal file
View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
import asyncio
import aiohttp
VERSION = 'v1.3.0'
def check_update():
asyncio.ensure_future(_do_check_update())
async def _do_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

@@ -1,264 +0,0 @@
# -*- coding: utf-8 -*-
import asyncio
import datetime
import enum
import json
import logging
import time
from typing import *
import aiohttp
import tornado.websocket
import blivedm.blivedm as blivedm
logger = logging.getLogger(__name__)
class Command(enum.IntEnum):
JOIN_ROOM = 0
ADD_TEXT = 1
ADD_GIFT = 2
ADD_MEMBER = 3
DEFAULT_AVATAR_URL = 'https://static.hdslb.com/images/member/noface.gif'
_http_session = aiohttp.ClientSession()
_avatar_url_cache: Dict[int, str] = {}
_last_fetch_avatar_time = datetime.datetime.now()
_last_avatar_failed_time = None
_uids_to_fetch_avatar = asyncio.Queue(15)
async def get_avatar_url(user_id):
if user_id in _avatar_url_cache:
return _avatar_url_cache[user_id]
global _last_avatar_failed_time, _last_fetch_avatar_time
cur_time = datetime.datetime.now()
# 防止获取头像频率太高被ban
if (cur_time - _last_fetch_avatar_time).total_seconds() < 0.2:
# 由_fetch_avatar_loop过一段时间再获取并缓存
try:
_uids_to_fetch_avatar.put_nowait(user_id)
except asyncio.QueueFull:
pass
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分钟
return DEFAULT_AVATAR_URL
else:
_last_avatar_failed_time = None
_last_fetch_avatar_time = cur_time
try:
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)
_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']
if not url.endswith('noface.gif'):
url += '@48w_48h'
_avatar_url_cache[user_id] = url
if len(_avatar_url_cache) > 50000:
for _, key in zip(range(100), _avatar_url_cache):
del _avatar_url_cache[key]
return url
async def _fetch_avatar_loop():
while True:
try:
user_id = await _uids_to_fetch_avatar.get()
if user_id in _avatar_url_cache:
continue
# 延时长一些使实时弹幕有机会获取头像
await asyncio.sleep(0.4 - (datetime.datetime.now() - _last_fetch_avatar_time).total_seconds())
asyncio.ensure_future(get_avatar_url(user_id))
except:
pass
asyncio.ensure_future(_fetch_avatar_loop())
class Room(blivedm.BLiveClient):
def __init__(self, room_id):
super().__init__(room_id, session=_http_session)
self.clients: List['ChatHandler'] = []
def stop_and_close(self):
if self.is_running:
future = self.stop()
future.add_done_callback(lambda _future: asyncio.ensure_future(self.close()))
else:
asyncio.ensure_future(self.close())
def send_message(self, cmd, data):
body = json.dumps({'cmd': cmd, 'data': data})
for client in self.clients:
client.write_message(body)
async def _on_receive_danmaku(self, danmaku: blivedm.DanmakuMessage):
asyncio.ensure_future(self.__on_receive_danmaku(danmaku))
async def __on_receive_danmaku(self, danmaku: blivedm.DanmakuMessage):
if danmaku.uid == self.room_owner_uid:
author_type = 3 # 主播
elif danmaku.admin:
author_type = 2 # 房管
elif danmaku.privilege_type != 0: # 1总督2提督3舰长
author_type = 1 # 舰队
else:
author_type = 0
self.send_message(Command.ADD_TEXT, {
'avatarUrl': await get_avatar_url(danmaku.uid),
'timestamp': danmaku.timestamp,
'authorName': danmaku.uname,
'authorType': author_type,
'content': danmaku.msg,
'privilegeType': danmaku.privilege_type,
'isGiftDanmaku': bool(danmaku.msg_type),
'authorLevel': danmaku.user_level,
'isNewbie': danmaku.urank < 10000,
'isMobileVerified': bool(danmaku.mobile_verify),
'medalLevel': 0 if danmaku.room_id != self.room_id else danmaku.medal_level
})
async def _on_receive_gift(self, gift: blivedm.GiftMessage):
if gift.coin_type != 'gold': # 丢人
return
self.send_message(Command.ADD_GIFT, {
'avatarUrl': gift.face,
'timestamp': gift.timestamp,
'authorName': gift.uname,
'giftName': gift.gift_name,
'giftNum': gift.num,
'totalCoin': gift.total_coin
})
async def _on_buy_guard(self, message: blivedm.GuardBuyMessage):
asyncio.ensure_future(self.__on_buy_guard(message))
async def __on_buy_guard(self, message: blivedm.GuardBuyMessage):
self.send_message(Command.ADD_MEMBER, {
'avatarUrl': await get_avatar_url(message.uid),
'timestamp': message.start_time,
'authorName': message.username
})
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()
room.clients.append(client)
if client.application.settings['debug']:
self.__send_test_message(room)
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)
if not room.clients:
logger.info('移除房间%d', room_id)
room.stop_and_close()
del self._rooms[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)
room_manager = RoomManager()
# noinspection PyAbstractClass
class ChatHandler(tornado.websocket.WebSocketHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.room_id = None
def open(self):
logger.info('Websocket连接 %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:
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)
else:
logger.warning('未知的命令: %s data: %s', body['cmd'], body['data'])
def on_close(self):
logger.info('Websocket断开 %s room: %s', self.request.remote_ip, self.room_id)
if self.room_id is not None:
room_manager.del_client(self.room_id, self)
# 跨域测试用
def check_origin(self, origin):
if self.application.settings['debug']:
return True
return super().check_origin(origin)

View File

@@ -1,50 +0,0 @@
# -*- coding: utf-8 -*-
import uuid
import views.base
from typing import *
configs: Dict[str, dict] = {}
ALLOWED_FIELDS = (
'showDanmaku', 'showGift', 'mergeSimilarDanmaku', 'minGiftPrice', 'maxSpeed',
'blockGiftDanmaku', 'blockLevel', 'blockNewbie', 'blockNotMobileVerified',
'blockKeywords', 'blockUsers', 'blockMedalLevel', 'css'
)
# noinspection PyAbstractClass
class ConfigsHandler(views.base.ApiHandler):
async def post(self):
config_id = str(uuid.uuid4())
config = {
name: self.json_args[name] for name in ALLOWED_FIELDS
}
config['id'] = config_id
configs[config_id] = config
self.set_status(201)
self.write(config)
if len(configs) > 10000:
for _, key in zip(range(100), configs):
del configs[key]
# noinspection PyAbstractClass
class ConfigHandler(views.base.ApiHandler):
async def put(self, config_id):
config = configs.get(config_id, None)
if config is None:
self.set_status(404)
return
for name in ALLOWED_FIELDS:
config[name] = self.json_args[name]
self.write(config)
async def get(self, config_id):
config = configs.get(config_id, None)
if config is None:
self.set_status(404)
return
self.write(config)