Compare commits

..

83 Commits

Author SHA1 Message Date
John Smith
b483c6beab 更新版本号v1.6.2 2023-07-08 11:42:21 +08:00
John Smith
f748d35fe4 修复前端页面不活动时心跳超时的问题 2023-07-06 23:26:44 +08:00
John Smith
b98a8f6680 修复cookie导致的获取头像失败 2023-07-06 23:25:07 +08:00
John Smith
914a41700e Squashed commit of the following:
commit 414258724b456628a2e361a11de102dbdc5a2fe4
Author: John Smith <xfgryujk@126.com>
Date:   Tue Jul 4 23:58:59 2023 +0800

    修正代码风格和wbi签名实现

commit 34a6546bd6ac00a893d36a65c20d479b113e3f53
Author: Bryan不可思议 <programripper@foxmail.com>
Date:   Thu Jun 22 15:52:51 2023 +0800

    Update services/avatar.py

    Co-authored-by: Hao Guan <10684225+hguandl@users.noreply.github.com>

commit f9dd56a13c296ff938560bcd161570f000855b35
Author: ProgramRipper <programripper@foxmail.com>
Date:   Tue Jun 13 03:33:15 2023 +0800

    fix: syntax error

commit a0354f2480a7c561e6dcc223fa9d4834310540b5
Author: ProgramRipper <programripper@foxmail.com>
Date:   Tue Jun 13 03:08:55 2023 +0800

    fix: get_avatar_url
2023-07-05 00:04:00 +08:00
John Smith
085765b1a2 修复SC消息创建出错的问题 2023-07-04 21:04:36 +08:00
John Smith
14c2fd48df 临时修复连接5分钟后用户名变星号的问题 2023-07-03 23:03:14 +08:00
John Smith
8683c0bad7 Merge pull request #105 from xfgryujk/dependabot/pip/tornado-6.3.2
Bump tornado from 6.1.0 to 6.3.2
2023-05-26 09:27:44 +08:00
dependabot[bot]
a1189aea69 Bump tornado from 6.1.0 to 6.3.2
Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.1.0 to 6.3.2.
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.1.0...v6.3.2)

---
updated-dependencies:
- dependency-name: tornado
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-05-25 18:33:21 +00:00
John Smith
9ce1dd21aa 更新教程截图 2022-10-09 19:11:21 +08:00
John Smith
1f89d0d1cf 添加cookie提高获取头像成功率 2022-10-09 18:53:42 +08:00
John Smith
062e7ed1aa 临时修复获取头像请求被屏蔽的问题 2022-09-28 00:11:29 +08:00
John Smith
6c84cbb930 更新版本号v1.6.1 2022-06-18 21:46:14 +08:00
John Smith
582508bd3f 移除B站翻译白嫖版 2022-06-18 21:42:03 +08:00
John Smith
2c0dd6e1c9 修复获取头像请求被屏蔽的问题
也修改了其他请求的User-Agent,防止将来被屏蔽
2022-06-18 21:31:25 +08:00
John Smith
1075b458aa 数据库中uid类型改成BigInteger 2022-06-03 13:47:22 +08:00
John Smith
aac9f62a70 Merge pull request #66 from Jeffz615/master
fix del sc msg
2022-03-16 23:13:35 +08:00
v1rg0z
86847c666f fix del sc msg 2022-03-16 22:20:41 +08:00
John Smith
b2d12e84bd 更新版本号v1.6.0 2022-03-04 20:28:49 +08:00
John Smith
618164d77d 自定义表情存储目录改到data里 2022-03-01 22:13:25 +08:00
John Smith
9f8f238426 整理docker构建、使用多阶段构建 2022-03-01 00:57:16 +08:00
John Smith
801280efbd 更新readme 2022-02-28 21:08:12 +08:00
John Smith
2187dfc167 更新截图到新版本 2022-02-28 20:48:23 +08:00
John Smith
02ec4bc4ba 升级SQLAlchemy到1.4.31 2022-02-28 20:17:20 +08:00
John Smith
24d0671dae 完成上传表情文件 2022-02-27 22:05:37 +08:00
John Smith
903cfecab9 完成自定义表情前端 2022-02-27 14:45:19 +08:00
John Smith
3da8cc4227 添加自定义表情配置项 2022-02-26 23:27:10 +08:00
John Smith
4924f15614 房间内也使用i18n 2022-02-26 14:53:22 +08:00
John Smith
3a183a865d i18n延迟加载 2022-02-25 22:42:50 +08:00
John Smith
4c8cde0b3d 支持同时显示房管和舰长图标 2022-02-23 23:15:21 +08:00
John Smith
a68b50e885 更新舰长图标 2022-02-23 23:12:25 +08:00
John Smith
b5b9b2601a 不直接在Ticker修改父组件状态 2022-02-22 23:48:01 +08:00
John Smith
6c92859408 延迟删除房间,防止客户端短时间后重连 2022-02-20 00:42:31 +08:00
John Smith
c87325e5e0 到B站的连接和到客户端的连接解耦 2022-02-19 14:58:29 +08:00
John Smith
e40f1511ed 拆出服务模块 2022-02-15 00:18:46 +08:00
John Smith
3b556fc265 Merge branch 'master' into dev 2022-02-14 22:22:35 +08:00
John Smith
c05c70863a Merge pull request #59 from npmmirror/master
update https://registry.npm.taobao.org to https://registry.npmmirror.com
2022-02-11 22:41:58 +08:00
NPM Mirror Bot
781de48b4b update https://registry.npm.taobao.org to https://registry.npmmirror.com 2022-02-11 14:02:43 +00:00
John Smith
ccf9ceec0e 升级tornado到6.1.0,支持Python 3.8在Windows下的默认事件循环 2022-01-19 21:48:56 +08:00
John Smith
707ad57800 修复接收弹幕的协程被阻塞的问题 2022-01-19 21:47:59 +08:00
John Smith
8eff63e17b 前端显示的版本号用package.json里的 2022-01-07 23:18:34 +08:00
John Smith
36345c37ab 整理前端代码风格 2022-01-04 21:41:12 +08:00
John Smith
03a2801099 默认样式表情可以不是正方形 2022-01-04 20:10:15 +08:00
John Smith
e318bdfcac 前端直连升级弹幕协议版本 2022-01-03 23:35:10 +08:00
John Smith
23ebaca372 添加配置官方翻译接口教程链接 2022-01-02 13:51:22 +08:00
John Smith
6da20d9bdd 修复一些配置的说明 2021-12-31 00:45:47 +08:00
John Smith
dedae6f083 通过服务器转发支持表情 2021-12-26 23:34:45 +08:00
John Smith
3118a7de98 修改表情相关的一些实现和代码风格 2021-12-26 20:44:23 +08:00
Zhu Chuang
34f8d88e05 添加表情支持 (#55)
* 添加表情支持

* 添加表情的样式生成器选项

* 修复了样式生成器表情气泡总为舰长颜色
2021-12-26 19:12:25 +08:00
John Smith
776b354517 升级blivedm 2021-12-19 18:46:32 +08:00
John Smith
2a22617c1a 更新版本号v1.5.3 2021-07-18 14:55:13 +08:00
John Smith
6b77a8a17f 修复腾讯翻译白嫖版 2021-07-18 14:53:36 +08:00
John Smith
8780655341 配置文件兼容带BOM的UTF-8 2021-07-17 13:17:40 +08:00
John Smith
6a4faa83a4 修复一些PyCharm警告 2021-07-17 13:03:45 +08:00
John Smith
335248c076 更新版本号v1.5.2 2021-04-24 11:31:19 +08:00
John Smith
659fddc4d5 Merge branch 'dev' 2021-04-24 11:29:31 +08:00
John Smith
f64a0876a1 升级依赖 2021-04-24 11:28:15 +08:00
John Smith
df6097a184 添加百度翻译官方接口 2021-04-23 21:35:57 +08:00
John Smith
0b848b1288 翻译器负载均衡 2021-04-20 22:09:47 +08:00
John Smith
c6f5411efc 添加获取头像间隔、缓存等配置 2021-04-20 21:30:57 +08:00
John Smith
986f008fae git忽略配置文件 2021-04-18 17:57:43 +08:00
John Smith
2df316b81c 配置文件不进版本库 2021-04-18 17:54:41 +08:00
John Smith
8aa80fadd3 添加腾讯翻译官方接口 2021-04-11 22:11:13 +08:00
John Smith
f6e38c8804 支持配置翻译器、移除有道翻译 2021-04-11 14:16:32 +08:00
John Smith
47e5739ac0 UI优化 2021-04-05 22:42:42 +08:00
John Smith
e2f925426a 样式生成器预览内容自动生成 2021-04-05 20:55:31 +08:00
John Smith
e8f77cd2e0 添加测试房间 2021-04-05 18:48:49 +08:00
John Smith
e899149575 转发WS也不等待onclose回调 2021-03-28 20:01:48 +08:00
John Smith
77d0277d4e 添加HTTP请求超时 2021-03-28 18:30:31 +08:00
John Smith
4601ee328c 样式生成器预览可选明亮背景 2021-03-15 23:13:45 +08:00
John Smith
5c9f11b3f9 优化帮助页 2021-03-14 18:38:59 +08:00
John Smith
95ce33d45a 添加仿line样式生成器 2021-03-14 18:13:58 +08:00
John Smith
609f4c09c6 UI优化 2021-03-07 14:40:58 +08:00
John Smith
31f007fb75 样式拆成计算属性 2021-03-06 23:43:47 +08:00
John Smith
2b98ab9633 优化样式生成器输入框防抖 2021-03-06 19:04:31 +08:00
John Smith
1df03dca78 样式生成器拆成子组件 2021-03-06 17:51:11 +08:00
John Smith
b689c9dce5 Merge pull request #36 from xfgryujk/dependabot/pip/aiohttp-3.7.4
Bump aiohttp from 3.5.4 to 3.7.4
2021-02-26 12:16:52 +08:00
dependabot[bot]
23f4fb95e7 Bump aiohttp from 3.5.4 to 3.7.4
Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.5.4 to 3.7.4.
- [Release notes](https://github.com/aio-libs/aiohttp/releases)
- [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.5.4...v3.7.4)

Signed-off-by: dependabot[bot] <support@github.com>
2021-02-26 02:29:46 +00:00
John Smith
845e144992 修复了样式生成器中切换SC固定栏后不显示的问题 2021-02-14 14:31:53 +08:00
John Smith
c19e7692d4 添加SC固定栏过渡 2021-02-12 17:25:49 +08:00
John Smith
b395886535 添加接收消息超时 2021-02-01 23:49:31 +08:00
John Smith
ddc13a6327 一些小优化 2021-01-28 23:50:12 +08:00
John Smith
d3cd5a3120 优化消息平滑,让发消息速度变化不要太频繁 2021-01-24 19:10:25 +08:00
John Smith
f50652af83 删除package-lock.json 2020-11-28 23:50:18 +08:00
80 changed files with 7316 additions and 2347 deletions

View File

@@ -17,5 +17,8 @@ README.md
# runtime data
data/*
!data/config.ini
!data/config.example.ini
!data/emoticons/
data/emoticons/*
!data/emoticons/.gitkeep
log/*

8
.gitignore vendored
View File

@@ -105,5 +105,9 @@ venv.bak/
.idea/
data/database.db
*.log*
data/*
!data/config.example.ini
!data/emoticons/
data/emoticons/*
!data/emoticons/.gitkeep
log/*

View File

@@ -1,35 +1,46 @@
# 运行时
FROM python:3.6.8-slim-stretch
RUN mv /etc/apt/sources.list /etc/apt/sources.list.bak \
&& echo "deb http://mirrors.tuna.tsinghua.edu.cn/debian/ stretch main contrib non-free">>/etc/apt/sources.list \
&& echo "deb http://mirrors.tuna.tsinghua.edu.cn/debian/ stretch-updates main contrib non-free">>/etc/apt/sources.list \
&& echo "deb http://mirrors.tuna.tsinghua.edu.cn/debian/ stretch-backports main contrib non-free">>/etc/apt/sources.list \
&& echo "deb http://mirrors.tuna.tsinghua.edu.cn/debian-security stretch/updates main contrib non-free">>/etc/apt/sources.list \
&& apt-get update \
&& apt-get install -y wget tar xz-utils
RUN wget https://nodejs.org/dist/v10.16.0/node-v10.16.0-linux-x64.tar.xz \
&& tar -xvf node-v10.16.0-linux-x64.tar.xz \
&& rm node-v10.16.0-linux-x64.tar.xz \
&& ln -s /node-v10.16.0-linux-x64/bin/node /usr/local/bin/node \
&& ln -s /node-v10.16.0-linux-x64/bin/npm /usr/local/bin/npm
#
# 构建前端
#
FROM node:16.14.0-bullseye AS builder
ARG BASE_PATH='/root/blivechat'
WORKDIR "${BASE_PATH}/frontend"
# 前端依赖
COPY frontend/package.json ./
RUN npm i --registry=https://registry.npmmirror.com
# 编译前端
COPY frontend ./
RUN npm run build
#
# 准备后端
#
FROM python:3.8.12-bullseye
ARG BASE_PATH='/root/blivechat'
ARG EXT_DATA_PATH='/mnt/data'
WORKDIR "${BASE_PATH}"
# 后端依赖
WORKDIR /blivechat
COPY requirements.txt ./
RUN pip3 install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
# 前端依赖
WORKDIR ./frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm i --registry=https://registry.npm.taobao.org
# 数据目录
COPY . ./
RUN mkdir -p "${EXT_DATA_PATH}/frontend/dist" \
&& mv data "${EXT_DATA_PATH}/data" \
&& ln -s "${EXT_DATA_PATH}/data" data \
&& mv log "${EXT_DATA_PATH}/log" \
&& ln -s "${EXT_DATA_PATH}/log" log \
&& ln -s "${EXT_DATA_PATH}/frontend/dist" frontend/dist
# 编译前端
COPY . ../
RUN npm run build
# 编译好的前端
COPY --from=builder "${BASE_PATH}/frontend/dist" "${EXT_DATA_PATH}/frontend/dist/"
# 运行
WORKDIR ..
VOLUME /blivechat/data /blivechat/log /blivechat/frontend/dist
VOLUME "${EXT_DATA_PATH}"
EXPOSE 12450
ENTRYPOINT ["python3", "main.py"]
CMD ["--host", "0.0.0.0", "--port", "12450"]

View File

@@ -1,28 +1,30 @@
# blivechat
用于OBS的仿YouTube风格的bilibili直播评论栏
最近喜欢看VTuber想为此写些程序于是有了这个东西。~~写到一半发现有类似项目了:[bilibili-live-chat](https://github.com/Tsuk1ko/bilibili-live-chat)、[BiliChat](https://github.com/3Shain/BiliChat)~~
![OBS截图](https://github.com/xfgryujk/blivechat/blob/master/screenshots/obs.png)
![Chrome截图](https://github.com/xfgryujk/blivechat/blob/master/screenshots/chrome.png)
![样式生成器截图](https://github.com/xfgryujk/blivechat/blob/master/screenshots/stylegen.png)
![OBS截图](./screenshots/obs.png)
![Chrome截图](./screenshots/chrome.png)
![样式生成器截图](./screenshots/stylegen.png)
## 特性
* 兼容YouTube直播评论栏的样式
* 金瓜子礼物模仿醒目留言显示
* 付费礼物模仿醒目留言显示
* 高亮舰队、房管、主播的用户名
* 支持屏蔽弹幕、合并相似弹幕等设置
* 自带样式生成器
* 支持自动翻译弹幕、醒目留言到日语
* 支持标注打赏用户名的读音(拼音和日文假名)
* 自带两种样式生成器经典YouTube风格和仿微信风格
* 支持前端直连B站服务器或者通过后端转发
* 支持自动翻译弹幕、醒目留言到日语,可以在后台配置翻译目标语言
* 支持标注打赏用户名的读音,可选拼音或日文假名
* 支持配置自定义表情不需要开通B站官方表情
## 使用方法
以下几种方式任选一种即可
### 一、本地使用
1. 下载[发布版](https://github.com/xfgryujk/blivechat/releases)仅提供x64 Windows版
2. 双击`blivechat.exe`运行服务器或者用命令行可以指定host和端口号
```bat
blivechat.exe --host 127.0.0.1 --port 12450
```
```sh
blivechat.exe --host 127.0.0.1 --port 12450
```
3. 用浏览器打开[http://localhost:12450](http://localhost:12450)输入房间ID复制房间URL
4. 用样式生成器生成样式复制CSS
5. 在OBS中添加浏览器源输入URL和自定义CSS
@@ -31,47 +33,45 @@
* 本地使用时不要关闭blivechat.exe那个黑框否则不能继续获取头像或弹幕
* 样式生成器没有列出所有本地字体,但是可以手动输入本地字体
* 如果需要使用翻译功能,建议看[配置官方翻译接口傻瓜式教程](https://www.bilibili.com/read/cv14663633)
### 二、公共服务器
请优先在本地使用,使用公共服务器会有更大的延迟,而且服务器故障时可能发生直播事故
* [公共服务器](http://chat.bilisc.com/)
* [仅样式生成器](https://style.vtbs.moe/)
### 三、源代码版自建服务器或在Windows以外平台
0. 由于使用了git子模块clone时需要加上`--recursive`参数:
```sh
git clone --recursive https://github.com/xfgryujk/blivechat.git
```
如果已经clone拉子模块的方法
```sh
git submodule update --init --recursive
```
```sh
git clone --recursive https://github.com/xfgryujk/blivechat.git
```
如果已经clone拉子模块的方法
```sh
git submodule update --init --recursive
```
1. 编译前端需要安装Node.js
```sh
cd frontend
npm i
npm run build
```
2. 运行服务器需要Python3.6以上版本):
```sh
pip3 install -r requirements.txt
python3 main.py
```
或者可以指定host和端口号
```sh
python3 main.py --host 127.0.0.1 --port 12450
```
```sh
cd frontend
npm i
npm run build
```
2. 运行服务器需要Python3.8以上版本):
```sh
pip3 install -r requirements.txt
python3 main.py
```
或者可以指定host和端口号
```sh
python3 main.py --host 127.0.0.1 --port 12450
```
3. 用浏览器打开[http://localhost:12450](http://localhost:12450),以下略
### 四、Docker自建服务器
1. ```sh
docker run --name blivechat -d -p 12450:12450 \
--mount source=blc-data,target=/blivechat/data \
--mount source=blc-log,target=/blivechat/log \
--mount source=blc-frontend,target=/blivechat/frontend/dist \
xfgryujk/blivechat:latest
```
1. ```sh
docker run --name blivechat -d -p 12450:12450 \
--mount source=blivechat-data,target=/mnt/data \
xfgryujk/blivechat:latest
```
2. 用浏览器打开[http://localhost:12450](http://localhost:12450),以下略
## 自建服务器相关补充
@@ -83,7 +83,7 @@
### 参考nginx配置
`sudo vim /etc/nginx/sites-enabled/blivechat.conf`
```conf
```nginx
upstream blivechat {
keepalive 8;
# blivechat地址
@@ -108,6 +108,9 @@ server {
ssl_certificate /PATH/TO/CERT.crt;
ssl_certificate_key /PATH/TO/CERT_KEY.key;
client_body_buffer_size 256k;
client_max_body_size 1.1m;
# 代理header
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -121,6 +124,9 @@ server {
# 如果文件不存在,交给前端路由
try_files $uri $uri/ /index.html;
}
location /emoticons {
alias /PATH/TO/BLIVECHAT/data/emoticons;
}
# 动态API
location /api {
proxy_pass http://blivechat;

View File

@@ -1,31 +1,18 @@
# -*- coding: utf-8 -*-
import json
import tornado.web
# noinspection PyAbstractClass
class ApiHandler(tornado.web.RequestHandler):
def set_default_headers(self):
# 跨域测试用
if not self.application.settings['debug']:
return
self.set_header('Access-Control-Allow-Origin', '*')
self.set_header('Access-Control-Allow-Methods', 'OPTIONS, PUT, POST, GET, DELETE')
if 'Access-Control-Request-Headers' in self.request.headers:
self.set_header('Access-Control-Allow-Headers',
self.request.headers['Access-Control-Request-Headers'])
class ApiHandler(tornado.web.RequestHandler): # noqa
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.json_args = None
def prepare(self):
if self.request.headers.get('Content-Type', '').startswith('application/json'):
try:
self.json_args = json.loads(self.request.body)
except json.JSONDecodeError:
self.json_args = None
else:
self.json_args = None
async def options(self, *_args, **_kwargs):
# 跨域测试用
self.set_status(204 if self.application.settings['debug'] else 405)
if not self.request.headers.get('Content-Type', '').startswith('application/json'):
return
try:
self.json_args = json.loads(self.request.body)
except json.JSONDecodeError:
pass

View File

@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
import asyncio
import enum
import json
@@ -13,10 +12,12 @@ import aiohttp
import tornado.websocket
import api.base
import blivedm.blivedm as blivedm
import blivedm.blivedm.client as blivedm_client
import config
import models.avatar
import models.translate
import services.avatar
import services.chat
import services.translate
import utils.request
logger = logging.getLogger(__name__)
@@ -32,240 +33,43 @@ class Command(enum.IntEnum):
UPDATE_TRANSLATION = 7
_http_session = aiohttp.ClientSession()
room_manager: Optional['RoomManager'] = None
class ContentType(enum.IntEnum):
TEXT = 0
EMOTICON = 1
def init():
global room_manager
room_manager = RoomManager()
def make_message_body(cmd, data):
return json.dumps(
{
'cmd': cmd,
'data': data
}
).encode('utf-8')
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'], data['guard_level'], 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'] = []
self.auto_translate_count = 0
async def init_room(self):
await super().init_room()
return True
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:
room_manager.del_client(self.room_id, client)
def send_message_if(self, can_send_func: Callable[['ChatHandler'], bool], cmd, data):
body = json.dumps({'cmd': cmd, 'data': data})
for client in filter(can_send_func, self.clients):
try:
client.write_message(body)
except tornado.websocket.WebSocketClosedError:
room_manager.del_client(self.room_id, client)
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
need_translate = self._need_translate(danmaku.msg)
if need_translate:
translation = models.translate.get_translation_from_cache(danmaku.msg)
if translation is None:
# 没有缓存,需要后面异步翻译后通知
translation = ''
else:
need_translate = False
else:
translation = ''
id_ = uuid.uuid4().hex
# 为了节省带宽用list而不是dict
self.send_message(Command.ADD_TEXT, make_text_message(
await models.avatar.get_avatar_url(danmaku.uid),
int(danmaku.timestamp / 1000),
danmaku.uname,
author_type,
danmaku.msg,
danmaku.privilege_type,
danmaku.msg_type,
danmaku.user_level,
danmaku.urank < 10000,
danmaku.mobile_verify,
0 if danmaku.room_id != self.room_id else danmaku.medal_level,
id_,
translation
))
if need_translate:
await self._translate_and_response(danmaku.msg, id_)
async def _on_receive_gift(self, gift: blivedm.GiftMessage):
avatar_url = models.avatar.process_avatar_url(gift.face)
models.avatar.update_avatar_cache(gift.uid, avatar_url)
if gift.coin_type != 'gold': # 丢人
return
id_ = uuid.uuid4().hex
self.send_message(Command.ADD_GIFT, {
'id': id_,
'avatarUrl': avatar_url,
'timestamp': gift.timestamp,
'authorName': gift.uname,
'totalCoin': gift.total_coin,
'giftName': gift.gift_name,
'num': gift.num
})
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):
id_ = uuid.uuid4().hex
self.send_message(Command.ADD_MEMBER, {
'id': id_,
'avatarUrl': await models.avatar.get_avatar_url(message.uid),
'timestamp': message.start_time,
'authorName': message.username,
'privilegeType': message.guard_level
})
async def _on_super_chat(self, message: blivedm.SuperChatMessage):
avatar_url = models.avatar.process_avatar_url(message.face)
models.avatar.update_avatar_cache(message.uid, avatar_url)
need_translate = self._need_translate(message.message)
if need_translate:
translation = models.translate.get_translation_from_cache(message.message)
if translation is None:
# 没有缓存,需要后面异步翻译后通知
translation = ''
else:
need_translate = False
else:
translation = ''
id_ = str(message.id)
self.send_message(Command.ADD_SUPER_CHAT, {
'id': id_,
'avatarUrl': avatar_url,
'timestamp': message.start_time,
'authorName': message.uname,
'price': message.price,
'content': message.message,
'translation': translation
})
if need_translate:
asyncio.ensure_future(self._translate_and_response(message.message, id_))
async def _on_super_chat_delete(self, message: blivedm.SuperChatDeleteMessage):
self.send_message(Command.ADD_SUPER_CHAT, {
'ids': list(map(str, message.ids))
})
def _need_translate(self, text):
cfg = config.get_config()
return (
cfg.enable_translate
and (not cfg.allow_translate_rooms or self.room_id in cfg.allow_translate_rooms)
and self.auto_translate_count > 0
and models.translate.need_translate(text)
)
async def _translate_and_response(self, text, msg_id):
translation = await models.translate.translate(text)
if translation is None:
return
self.send_message_if(
lambda client: client.auto_translate,
Command.UPDATE_TRANSLATION, make_translation_message(
msg_id,
translation
)
)
def make_text_message(avatar_url, timestamp, author_name, author_type, content, privilege_type,
is_gift_danmaku, author_level, is_newbie, is_mobile_verified, medal_level,
id_, translation):
def make_text_message_data(
avatar_url: str = services.avatar.DEFAULT_AVATAR_URL,
timestamp: int = None,
author_name: str = '',
author_type: int = 0,
content: str = '',
privilege_type: int = 0,
is_gift_danmaku: bool = False,
author_level: int = 1,
is_newbie: bool = False,
is_mobile_verified: bool = True,
medal_level: int = 0,
id_: str = None,
translation: str = '',
content_type: int = ContentType.TEXT,
content_type_params: list = None,
):
# 为了节省带宽用list而不是dict
return [
# 0: avatarUrl
avatar_url,
# 1: timestamp
timestamp,
timestamp if timestamp is not None else int(time.time()),
# 2: authorName
author_name,
# 3: authorType
@@ -285,137 +89,113 @@ def make_text_message(avatar_url, timestamp, author_name, author_type, content,
# 10: medalLevel
medal_level,
# 11: id
id_,
id_ if id_ is not None else uuid.uuid4().hex,
# 12: translation
translation
translation,
# 13: contentType
content_type,
# 14: contentTypeParams
content_type_params if content_type_params is not None else [],
]
def make_translation_message(msg_id, translation):
def make_emoticon_params(url):
return [
# 0: url
url,
]
def make_translation_message_data(msg_id, translation):
return [
# 0: id
msg_id,
# 1: translation
translation
translation,
]
class RoomManager:
def __init__(self):
self._rooms: Dict[int, Room] = {}
class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
HEARTBEAT_INTERVAL = 10
RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + 5
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.get(room_id, None)
if room is None:
return
room.clients.append(client)
logger.info('%d clients in room %s', len(room.clients), room_id)
if client.auto_translate:
room.auto_translate_count += 1
await client.on_join_room()
def del_client(self, room_id, client: 'ChatHandler'):
room = self._rooms.get(room_id, None)
if room is None:
return
try:
room.clients.remove(client)
except ValueError:
# _add_room未完成没有执行到room.clients.append
pass
else:
logger.info('%d clients in room %s', len(room.clients), room_id)
if client.auto_translate:
room.auto_translate_count = max(0, room.auto_translate_count - 1)
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)
self._rooms[room_id] = room = Room(room_id)
if await room.init_room():
room.start()
logger.info('%d rooms', len(self._rooms))
return True
else:
self._del_room(room_id)
return False
def _del_room(self, room_id):
room = self._rooms.get(room_id, None)
if room is None:
return
logger.info('Removing room %d', room_id)
for client in room.clients:
client.close()
room.stop_and_close()
self._rooms.pop(room_id, None)
logger.info('%d rooms', len(self._rooms))
# noinspection PyAbstractClass
class ChatHandler(tornado.websocket.WebSocketHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._close_on_timeout_future = None
self._heartbeat_timer_handle = None
self._receive_timeout_timer_handle = None
self.room_id = None
self.auto_translate = False
def open(self):
logger.info('Websocket connected %s', self.request.remote_ip)
self._close_on_timeout_future = asyncio.ensure_future(self._close_on_timeout())
logger.info('client=%s connected', self.request.remote_ip)
self._heartbeat_timer_handle = asyncio.get_event_loop().call_later(
self.HEARTBEAT_INTERVAL, self._on_send_heartbeat
)
self._refresh_receive_timeout_timer()
async def _close_on_timeout(self):
try:
# 超过一定时间还没加入房间则断开
await asyncio.sleep(10)
logger.warning('Client %s joining room timed out', self.request.remote_ip)
self.close()
except (asyncio.CancelledError, tornado.websocket.WebSocketClosedError):
pass
def _on_send_heartbeat(self):
self.send_cmd_data(Command.HEARTBEAT, {})
self._heartbeat_timer_handle = asyncio.get_event_loop().call_later(
self.HEARTBEAT_INTERVAL, self._on_send_heartbeat
)
def _refresh_receive_timeout_timer(self):
if self._receive_timeout_timer_handle is not None:
self._receive_timeout_timer_handle.cancel()
self._receive_timeout_timer_handle = asyncio.get_event_loop().call_later(
self.RECEIVE_TIMEOUT, self._on_receive_timeout
)
def _on_receive_timeout(self):
logger.info('client=%s timed out', self.request.remote_ip)
self._receive_timeout_timer_handle = None
self.close()
def on_close(self):
logger.info('client=%s disconnected, room=%s', self.request.remote_ip, str(self.room_id))
if self.has_joined_room:
services.chat.client_room_manager.del_client(self.room_id, self)
if self._heartbeat_timer_handle is not None:
self._heartbeat_timer_handle.cancel()
self._heartbeat_timer_handle = None
if self._receive_timeout_timer_handle is not None:
self._receive_timeout_timer_handle.cancel()
self._receive_timeout_timer_handle = None
def on_message(self, message):
try:
# 超时没有加入房间也断开
if self.has_joined_room:
self._refresh_receive_timeout_timer()
body = json.loads(message)
cmd = body['cmd']
if cmd == Command.HEARTBEAT:
return
pass
elif cmd == Command.JOIN_ROOM:
if self.has_joined_room:
return
self._refresh_receive_timeout_timer()
self.room_id = int(body['data']['roomId'])
logger.info('Client %s is joining room %d', self.request.remote_ip, self.room_id)
logger.info('client=%s joining room %d', self.request.remote_ip, self.room_id)
try:
cfg = body['data']['config']
self.auto_translate = cfg['autoTranslate']
self.auto_translate = bool(cfg['autoTranslate'])
except KeyError:
pass
asyncio.ensure_future(room_manager.add_client(self.room_id, self))
self._close_on_timeout_future.cancel()
self._close_on_timeout_future = None
else:
logger.warning('Unknown cmd, client: %s, cmd: %d, body: %s', self.request.remote_ip, cmd, body)
except Exception:
logger.exception('on_message error, client: %s, message: %s', self.request.remote_ip, message)
services.chat.client_room_manager.add_client(self.room_id, self)
asyncio.ensure_future(self._on_joined_room())
def on_close(self):
logger.info('Websocket disconnected %s room: %s', self.request.remote_ip, str(self.room_id))
if self.has_joined_room:
room_manager.del_client(self.room_id, self)
if self._close_on_timeout_future is not None:
self._close_on_timeout_future.cancel()
self._close_on_timeout_future = None
else:
logger.warning('client=%s unknown cmd=%d, body=%s', self.request.remote_ip, cmd, body)
except Exception: # noqa
logger.exception('client=%s on_message error, message=%s', self.request.remote_ip, message)
# 跨域测试用
def check_origin(self, origin):
@@ -427,58 +207,43 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
def has_joined_room(self):
return self.room_id is not None
def send_message(self, cmd, data):
body = json.dumps({'cmd': cmd, 'data': data})
def send_cmd_data(self, cmd, data):
self.send_body_no_raise(make_message_body(cmd, data))
def send_body_no_raise(self, body: Union[bytes, str, Dict[str, Any]]):
try:
self.write_message(body)
except tornado.websocket.WebSocketClosedError:
self.on_close()
self.close()
async def on_join_room(self):
if self.application.settings['debug']:
await self.send_test_message()
async def _on_joined_room(self):
if self.settings['debug']:
await self._send_test_message()
# 不允许自动翻译的提示
if self.auto_translate:
cfg = config.get_config()
if cfg.allow_translate_rooms and self.room_id not in cfg.allow_translate_rooms:
self.send_message(Command.ADD_TEXT, make_text_message(
models.avatar.DEFAULT_AVATAR_URL,
int(time.time()),
'blivechat',
2,
'Translation is not allowed in this room. Please download to use translation',
0,
False,
60,
False,
True,
0,
uuid.uuid4().hex,
''
self.send_cmd_data(Command.ADD_TEXT, make_text_message_data(
author_name='blivechat',
author_type=2,
content='Translation is not allowed in this room. Please download to use translation',
author_level=60,
))
# 测试用
async def send_test_message(self):
async def _send_test_message(self):
base_data = {
'avatarUrl': await models.avatar.get_avatar_url(300474),
'avatarUrl': await services.avatar.get_avatar_url(300474),
'timestamp': int(time.time()),
'authorName': 'xfgryujk',
}
text_data = make_text_message(
base_data['avatarUrl'],
base_data['timestamp'],
base_data['authorName'],
0,
'我能吞下玻璃而不伤身体',
0,
False,
20,
False,
True,
0,
uuid.uuid4().hex,
''
text_data = make_text_message_data(
avatar_url=base_data['avatarUrl'],
timestamp=base_data['timestamp'],
author_name=base_data['authorName'],
content='我能吞下玻璃而不伤身体',
author_level=60,
)
member_data = {
**base_data,
@@ -499,33 +264,30 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
'content': 'The quick brown fox jumps over the lazy dog',
'translation': ''
}
self.send_message(Command.ADD_TEXT, text_data)
self.send_cmd_data(Command.ADD_TEXT, text_data)
text_data[2] = '主播'
text_data[3] = 3
text_data[4] = "I can eat glass, it doesn't hurt me."
text_data[11] = uuid.uuid4().hex
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)
self.send_cmd_data(Command.ADD_TEXT, text_data)
self.send_cmd_data(Command.ADD_MEMBER, member_data)
self.send_cmd_data(Command.ADD_SUPER_CHAT, sc_data)
sc_data['id'] = str(random.randint(1, 65535))
sc_data['price'] = 100
sc_data['content'] = '敏捷的棕色狐狸跳过了懒狗'
self.send_message(Command.ADD_SUPER_CHAT, sc_data)
# self.send_message(Command.DEL_SUPER_CHAT, {'ids': [sc_data['id']]})
self.send_message(Command.ADD_GIFT, gift_data)
self.send_cmd_data(Command.ADD_SUPER_CHAT, sc_data)
# self.send_cmd_data(Command.DEL_SUPER_CHAT, {'ids': [sc_data['id']]})
self.send_cmd_data(Command.ADD_GIFT, gift_data)
gift_data['id'] = uuid.uuid4().hex
gift_data['totalCoin'] = 1245000
gift_data['giftName'] = '小电视飞船'
self.send_message(Command.ADD_GIFT, gift_data)
self.send_cmd_data(Command.ADD_GIFT, gift_data)
# noinspection PyAbstractClass
class RoomInfoHandler(api.base.ApiHandler):
_host_server_list_cache = blivedm.DEFAULT_DANMAKU_SERVER_LIST
class RoomInfoHandler(api.base.ApiHandler): # noqa
async def get(self):
room_id = int(self.get_query_argument('roomId'))
logger.info('Client %s is getting room info %d', self.request.remote_ip, room_id)
logger.info('client=%s getting room info, room=%d', self.request.remote_ip, room_id)
room_id, owner_uid = await self._get_room_info(room_id)
host_server_list = await self._get_server_host_list(room_id)
if owner_uid == 0:
@@ -543,61 +305,45 @@ class RoomInfoHandler(api.base.ApiHandler):
@staticmethod
async def _get_room_info(room_id):
try:
async with _http_session.get(blivedm.ROOM_INIT_URL, params={'room_id': room_id}
) as res:
async with utils.request.http_session.get(
blivedm_client.ROOM_INIT_URL,
headers={
**utils.request.BILIBILI_COMMON_HEADERS,
'Origin': 'https://live.bilibili.com',
'Referer': f'https://live.bilibili.com/{room_id}'
},
params={
'room_id': room_id
}
) as res:
if res.status != 200:
logger.warning('room %d _get_room_info failed: %d %s', room_id,
logger.warning('room=%d _get_room_info failed: %d %s', room_id,
res.status, res.reason)
return room_id, 0
data = await res.json()
except aiohttp.ClientConnectionError:
logger.exception('room %d _get_room_info failed', room_id)
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
logger.exception('room=%d _get_room_info failed', room_id)
return room_id, 0
if data['code'] != 0:
logger.warning('room %d _get_room_info failed: %s', room_id, data['message'])
logger.warning('room=%d _get_room_info failed: %s', room_id, data['message'])
return room_id, 0
room_info = data['data']['room_info']
return room_info['room_id'], room_info['uid']
@classmethod
async def _get_server_host_list(cls, _room_id):
return cls._host_server_list_cache
@staticmethod
async def _get_server_host_list(_room_id):
# 连接其他host必须要key
# try:
# async with _http_session.get(blivedm.DANMAKU_SERVER_CONF_URL, params={'id': room_id, 'type': 0}
# ) as res:
# if res.status != 200:
# logger.warning('room %d _get_server_host_list failed: %d %s', room_id,
# res.status, res.reason)
# return cls._host_server_list_cache
# data = await res.json()
# except aiohttp.ClientConnectionError:
# logger.exception('room %d _get_server_host_list failed', room_id)
# return cls._host_server_list_cache
#
# if data['code'] != 0:
# logger.warning('room %d _get_server_host_list failed: %s', room_id, data['message'])
# return cls._host_server_list_cache
#
# host_server_list = data['data']['host_list']
# if not host_server_list:
# logger.warning('room %d _get_server_host_list failed: host_server_list is empty')
# return cls._host_server_list_cache
#
# cls._host_server_list_cache = host_server_list
# return host_server_list
return blivedm_client.DEFAULT_DANMAKU_SERVER_LIST
# noinspection PyAbstractClass
class AvatarHandler(api.base.ApiHandler):
class AvatarHandler(api.base.ApiHandler): # noqa
async def get(self):
uid = int(self.get_query_argument('uid'))
avatar_url = await models.avatar.get_avatar_url_or_none(uid)
avatar_url = await services.avatar.get_avatar_url_or_none(uid)
if avatar_url is None:
avatar_url = models.avatar.DEFAULT_AVATAR_URL
avatar_url = services.avatar.DEFAULT_AVATAR_URL
# 缓存3分钟
self.set_header('Cache-Control', 'private, max-age=180')
else:

View File

@@ -1,4 +1,8 @@
# -*- coding: utf-8 -*-
import asyncio
import hashlib
import logging
import os
import tornado.web
@@ -6,9 +10,13 @@ import api.base
import config
import update
logger = logging.getLogger(__name__)
# noinspection PyAbstractClass
class MainHandler(tornado.web.StaticFileHandler):
EMOTICON_UPLOAD_PATH = os.path.join(config.DATA_PATH, 'emoticons')
EMOTICON_BASE_URL = '/emoticons'
class MainHandler(tornado.web.StaticFileHandler): # noqa
"""为了使用Vue Router的history模式把不存在的文件请求转发到index.html"""
async def get(self, path, include_body=True):
try:
@@ -20,14 +28,51 @@ class MainHandler(tornado.web.StaticFileHandler):
await super().get('index.html', include_body)
# noinspection PyAbstractClass
class ServerInfoHandler(api.base.ApiHandler):
class ServerInfoHandler(api.base.ApiHandler): # noqa
async def get(self):
cfg = config.get_config()
self.write({
'version': update.VERSION,
'config': {
'enableTranslate': cfg.enable_translate,
'enableUploadFile': cfg.enable_upload_file,
'loaderUrl': cfg.loader_url
}
})
class UploadEmoticonHandler(api.base.ApiHandler): # noqa
async def post(self):
cfg = config.get_config()
if not cfg.enable_upload_file:
raise tornado.web.HTTPError(403)
try:
file = self.request.files['file'][0]
except LookupError:
raise tornado.web.MissingArgumentError('file')
if len(file.body) > 1024 * 1024:
raise tornado.web.HTTPError(413, 'file is too large, size=%d', len(file.body))
if not file.content_type.lower().startswith('image/'):
raise tornado.web.HTTPError(415)
url = await asyncio.get_event_loop().run_in_executor(
None, self._save_file, file.body, self.request.remote_ip
)
self.write({
'url': url
})
@staticmethod
def _save_file(body, client):
md5 = hashlib.md5(body).hexdigest()
filename = md5 + '.png'
path = os.path.join(EMOTICON_UPLOAD_PATH, filename)
logger.info('client=%s uploaded file, path=%s, size=%d', client, path, len(body))
tmp_path = path + '.tmp'
with open(tmp_path, 'wb') as f:
f.write(body)
os.replace(tmp_path, path)
return f'{EMOTICON_BASE_URL}/{filename}'

Submodule blivedm updated: 8d8cc8c270...9d92fcdc91

118
config.py
View File

@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
import configparser
import logging
import os
@@ -7,7 +6,14 @@ from typing import *
logger = logging.getLogger(__name__)
CONFIG_PATH = os.path.join('data', 'config.ini')
BASE_PATH = os.path.dirname(os.path.realpath(__file__))
WEB_ROOT = os.path.join(BASE_PATH, 'frontend', 'dist')
DATA_PATH = os.path.join(BASE_PATH, 'data')
CONFIG_PATH_LIST = [
os.path.join(DATA_PATH, 'config.ini'),
os.path.join(DATA_PATH, 'config.example.ini')
]
_config: Optional['AppConfig'] = None
@@ -21,8 +27,16 @@ def init():
def reload():
config_path = ''
for path in CONFIG_PATH_LIST:
if os.path.exists(path):
config_path = path
break
if config_path == '':
return False
config = AppConfig()
if not config.load(CONFIG_PATH):
if not config.load(config_path):
return False
global _config
_config = config
@@ -36,31 +50,93 @@ def get_config():
class AppConfig:
def __init__(self):
self.database_url = 'sqlite:///data/database.db'
self.enable_translate = True
self.allow_translate_rooms = {}
self.tornado_xheaders = False
self.loader_url = ''
self.enable_upload_file = True
self.fetch_avatar_interval = 3.5
self.fetch_avatar_max_queue_size = 2
self.avatar_cache_size = 50000
self.enable_translate = True
self.allow_translate_rooms = set()
self.translation_cache_size = 50000
self.translator_configs = []
def load(self, path):
try:
config = configparser.ConfigParser()
config.read(path, 'utf-8')
config.read(path, 'utf-8-sig')
app_section = config['app']
self.database_url = app_section['database_url']
self.enable_translate = app_section.getboolean('enable_translate')
allow_translate_rooms = app_section['allow_translate_rooms']
if allow_translate_rooms == '':
self.allow_translate_rooms = {}
else:
allow_translate_rooms = allow_translate_rooms.split(',')
self.allow_translate_rooms = set(map(lambda id_: int(id_.strip()), allow_translate_rooms))
self.tornado_xheaders = app_section.getboolean('tornado_xheaders')
self.loader_url = app_section['loader_url']
except (KeyError, ValueError):
self._load_app_config(config)
self._load_translator_configs(config)
except Exception: # noqa
logger.exception('Failed to load config:')
return False
return True
def _load_app_config(self, config: configparser.ConfigParser):
app_section = config['app']
self.database_url = app_section.get('database_url', self.database_url)
self.tornado_xheaders = app_section.getboolean('tornado_xheaders', fallback=self.tornado_xheaders)
self.loader_url = app_section.get('loader_url', self.loader_url)
self.enable_upload_file = app_section.getboolean('enable_upload_file', fallback=self.enable_upload_file)
self.fetch_avatar_interval = app_section.getfloat('fetch_avatar_interval', fallback=self.fetch_avatar_interval)
self.fetch_avatar_max_queue_size = app_section.getint('fetch_avatar_max_queue_size',
fallback=self.fetch_avatar_max_queue_size)
self.avatar_cache_size = app_section.getint('avatar_cache_size', fallback=self.avatar_cache_size)
self.enable_translate = app_section.getboolean('enable_translate', fallback=self.enable_translate)
self.allow_translate_rooms = _str_to_list(app_section.get('allow_translate_rooms', ''), int, set)
self.translation_cache_size = app_section.getint('translation_cache_size', self.translation_cache_size)
def _load_translator_configs(self, config: configparser.ConfigParser):
app_section = config['app']
section_names = _str_to_list(app_section.get('translator_configs', ''))
translator_configs = []
for section_name in section_names:
try:
section = config[section_name]
type_ = section['type']
translator_config = {
'type': type_,
'query_interval': section.getfloat('query_interval'),
'max_queue_size': section.getint('max_queue_size')
}
if type_ == 'TencentTranslateFree':
translator_config['source_language'] = section['source_language']
translator_config['target_language'] = section['target_language']
elif type_ == 'BilibiliTranslateFree':
pass
elif type_ == 'TencentTranslate':
translator_config['source_language'] = section['source_language']
translator_config['target_language'] = section['target_language']
translator_config['secret_id'] = section['secret_id']
translator_config['secret_key'] = section['secret_key']
translator_config['region'] = section['region']
elif type_ == 'BaiduTranslate':
translator_config['source_language'] = section['source_language']
translator_config['target_language'] = section['target_language']
translator_config['app_id'] = section['app_id']
translator_config['secret'] = section['secret']
else:
raise ValueError(f'Invalid translator type: {type_}')
except Exception: # noqa
logger.exception('Failed to load translator=%s config:', section_name)
continue
translator_configs.append(translator_config)
self.translator_configs = translator_configs
def _str_to_list(value, item_type: Type = str, container_type: Type = list):
value = value.strip()
if value == '':
return container_type()
items = value.split(',')
items = map(lambda item: item.strip(), items)
if item_type is not str:
items = map(lambda item: item_type(item), items)
return container_type(items)

136
data/config.example.ini Normal file
View File

@@ -0,0 +1,136 @@
# 如果要修改配置可以复制此文件并重命名为“config.ini”再修改
# If you want to modify the configuration, copy this file and rename it to "config.ini" and edit
[app]
# 数据库配置见https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls
# See https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls
database_url = sqlite:///data/database.db
# 如果使用了nginx之类的反向代理服务器设置为true
# Set to true if you are using a reverse proxy server such as nginx
tornado_xheaders = false
# 加载器URL本地使用时加载器可以让你先运行OBS再运行blivechat。如果为空不使用加载器
# **自建服务器时强烈建议不使用加载器**否则可能因为混合HTTP和HTTPS等原因加载不出来
# Use a loader so that you can run OBS before blivechat. If empty, no loader is used
loader_url = https://xfgryujk.sinacloud.net/blivechat/loader.html
# 允许上传自定义表情文件
# Enable uploading custom emote file
enable_upload_file = true
# 获取头像间隔时间。如果小于3秒有很大概率被服务器拉黑
# Interval between fetching avatars (seconds). At least 3 seconds is recommended
fetch_avatar_interval = 3.5
# 获取头像最大队列长度,注意最长等待时间等于 最大队列长度 * 请求间隔时间
# Maximum queue length for fetching avatar
fetch_avatar_max_queue_size = 2
# 头像缓存数量
# Number of avatar caches
avatar_cache_size = 50000
# 允许自动翻译到日语
# Enable auto translate to Japanese
enable_translate = true
# 允许翻译的房间ID以逗号分隔。如果为空允许所有房间
# Comma separated room IDs in which translation are allowed. If empty, all rooms are allowed
# Example: allow_translate_rooms = 4895312,22347054,21693691
allow_translate_rooms =
# 翻译缓存数量
# Number of translation caches
translation_cache_size = 50000
# -------------------------------------------------------------------------------------------------
# 以下是给字幕组看的实在懒得翻译了_(:з」∠)_。如果你不了解以下参数的意思使用默认值就好
# **The following is for translation team. Leave it default if you don't know its meaning**
# -------------------------------------------------------------------------------------------------
# 翻译器配置,索引到下面的配置节。可以以逗号分隔配置多个翻译器,翻译时会自动负载均衡
# 配置多个翻译器可以增加额度、增加QPS、容灾
# 不同配置可以使用同一个类型,但要使用不同的账号,否则还是会遇到额度、调用频率限制
translator_configs = tencent_translate_free
[tencent_translate_free]
# 类型:腾讯翻译白嫖版。使用了网页版的接口,**将来可能失效**
type = TencentTranslateFree
# 请求间隔时间(秒),等于 1 / QPS。目前没有遇到此接口有调用频率限制10QPS应该够用了
query_interval = 0.1
# 最大队列长度,注意最长等待时间等于 最大队列长度 * 请求间隔时间
max_queue_size = 100
# 自动auto中文zh日语jp英语en韩语kr
# 完整语言列表见文档https://cloud.tencent.com/document/product/551/15619
# 源语言
source_language = zh
# 目标语言
target_language = jp
[tencent_translate]
# 文档https://cloud.tencent.com/product/tmt
# 定价https://cloud.tencent.com/document/product/551/35017
# * 文本翻译的每月免费额度为5百万字符
# * 文本翻译当月需付费字符数小于100百万字符1亿字符刊例价为58元/每百万字符
# * 文本翻译当月需付费字符数大于等于100百万字符1亿字符刊例价为50元/每百万字符
# 限制https://cloud.tencent.com/document/product/551/32572
# * 文本翻译最高QPS为5
# 类型:腾讯翻译
type = TencentTranslate
# 请求间隔时间(秒),等于 1 / QPS。理论上最高QPS为5实际测试是3
query_interval = 0.333
# 最大队列长度,注意最长等待时间等于 最大队列长度 * 请求间隔时间
max_queue_size = 30
# 自动auto中文zh日语ja英语en韩语ko
# 完整语言列表见文档https://cloud.tencent.com/document/product/551/15619
# 源语言
source_language = zh
# 目标语言
target_language = ja
# 腾讯云API密钥
secret_id =
secret_key =
# 腾讯云地域参数用来标识希望操作哪个地域的数据建议按照运行blivechat的机器所在地区就近选择
# 北京ap-beijing上海ap-shanghai香港ap-hongkong首尔ap-seoul
# 完整地域列表见文档https://cloud.tencent.com/document/api/551/15615#.E5.9C.B0.E5.9F.9F.E5.88.97.E8.A1.A8
region = ap-shanghai
[baidu_translate]
# 文档https://fanyi-api.baidu.com/
# 定价https://fanyi-api.baidu.com/product/112
# * 标准版完全免费不限使用字符量QPS=1
# * 高级版每月前200万字符免费超出后仅收取超出部分费用QPS=1049元/百万字符
# * 尊享版每月前200万字符免费超出后仅收取超出部分费用QPS=10049元/百万字符
# 类型:百度翻译
type = BaiduTranslate
# 请求间隔时间(秒),等于 1 / QPS
query_interval = 1.5
# 最大队列长度,注意最长等待时间等于 最大队列长度 * 请求间隔时间
max_queue_size = 9
# 自动auto中文zh日语jp英语en韩语kor
# 完整语言列表见文档https://fanyi-api.baidu.com/doc/21
# 源语言
source_language = zh
# 目标语言
target_language = jp
# 百度翻译开放平台应用ID和密钥
app_id =
secret =

View File

@@ -1,22 +0,0 @@
[app]
# 数据库配置见https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls
# See https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls
database_url = sqlite:///data/database.db
# 允许自动翻译到日语
# Enable auto translate to Japanese
enable_translate = true
# 允许翻译的房间ID以逗号分隔。如果为空允许所有房间
# Comma separated room IDs in which translation are not allowed. If empty, all are allowed
# Example: allow_translate_rooms = 4895312,22347054,21693691
allow_translate_rooms =
# 如果使用了nginx之类的反向代理服务器设置为true
# Set to true if you are using a reverse proxy server such as nginx
tornado_xheaders = false
# 加载器URL本地使用时加载器可以让你先运行OBS再运行blivechat。如果为空不使用加载器
# **自建服务器时强烈建议不使用加载器**否则可能因为混合HTTP和HTTPS等原因加载不出来
# Use a loader so that you can run OBS before blivechat. If empty, no loader is used
loader_url = https://xfgryujk.sinacloud.net/blivechat/loader.html

0
data/emoticons/.gitkeep Normal file
View File

2
frontend/.eslintignore Normal file
View File

@@ -0,0 +1,2 @@
brotli_decode.js
pronunciation/dict*.js

79
frontend/.eslintrc.js Normal file
View File

@@ -0,0 +1,79 @@
module.exports = {
"root": true,
"env": {
"browser": true,
"node": true
},
"parserOptions": {
"parser": "babel-eslint"
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"rules": {
"array-bracket-spacing": ["error", "never"], // 数组括号内不加空格
"arrow-parens": ["error", "as-needed"], // 箭头函数单个参数不加括号
"arrow-spacing": "error", // 箭头前后加空格
"block-spacing": "error", // 块大括号内加空格
"brace-style": "error", // 大括号不独占一行
"comma-spacing": "error", // 逗号前面不加空格,后面加空格
"comma-style": "error", // 逗号在语句后面而不是下一条的前面
"computed-property-spacing": "error", // 计算属性名前后不加空格
"curly": "error", // 禁止省略大括号
"dot-notation": "error", // 使用点访问成员
"eol-last": "error", // 文件末尾加换行符
"func-call-spacing": "error", // 调用函数名和括号间不加空格
"func-style": ["error", "declaration", { "allowArrowFunctions": true }], // 使用函数定义语法,而不是把函数表达式赋值到变量
"indent": ["error", 2], // 缩进2空格
"key-spacing": ["error", { "mode": "minimum" }],
"keyword-spacing": "error", // 关键词前后加空格
"lines-between-class-members": "error", // 类成员定义间加空格
"max-lines-per-function": ["error", 150], // 每个函数最多行数
"max-nested-callbacks": ["error", 3], // 每个函数最多嵌套回调数
"new-parens": "error", // new调用构造函数加空格
"no-array-constructor": "error", // 使用数组字面量,而不是数组构造函数
"no-floating-decimal": "error", // 禁止省略浮点数首尾的0
"no-implicit-coercion": "error", // 禁止隐式转换
"no-empty": ["error", { "allowEmptyCatch": true }], // 禁止空的块除了catch
"no-extra-parens": ["error", "all", { "nestedBinaryExpressions": false }], // 禁止多余的括号
"no-labels": "error", // 禁止使用标签
"no-lone-blocks": "error", // 禁止没用的块
"no-mixed-operators": "error", // 禁止混用不同优先级的操作符而不加括号
"no-multi-spaces": ["error", { "ignoreEOLComments": true }], // 禁止多个空格,除了行尾注释前
"no-multiple-empty-lines": "error", // 最多2个连续空行
"no-nested-ternary": "error", // 禁止嵌套三元表达式
"no-sequences": "error", // 禁止使用逗号操作符
"no-tabs": "error", // 禁止使用tab
"no-trailing-spaces": ["error", { "skipBlankLines": true }], // 禁止行尾的空格,除了空行
"no-unused-expressions": "error", // 禁止没用的表达式
"no-useless-concat": "error", // 禁止没用的字符串连接
"no-useless-rename": "error", // 禁止没用的模块导入重命名、解构赋值重命名
"no-useless-return": "error", // 禁止没用的return
"no-var": "error", // 禁止使用var声明变量
"no-void": "error", // 禁止使用void
"no-whitespace-before-property": "error", // 禁止访问属性的点前后加空格
"object-curly-spacing": ["error", "always"], // 对象字面量括号内加空格
"operator-assignment": "error", // 尽量使用+=
"operator-linebreak": ["error", "before"], // 操作符放行首
"prefer-object-spread": "error", // 使用{...obj}而不是Object.assign
"prefer-rest-params": "error", // 使用...args而不是arguments
"prefer-spread": "error", // 使用func(...args)而不是apply
"prefer-template": "error", // 使用模板字符串,而不是字符串连接
"rest-spread-spacing": ["error", "never"], // 解包操作符不加空格
"semi": ["error", "never"], // 禁止使用多余的分号
"semi-spacing": "error", // 分号前面不加空格,后面加空格
"semi-style": "error", // 分号在语句后面而不是下一条的前面
"space-before-blocks": "error", // 块大括号前加空格
"space-before-function-paren": ["error", "never"], // 函数定义名称和括号间不加空格
"space-in-parens": "error", // 括号内不加空格
"space-infix-ops": "error", // 二元操作符前后加空格
"space-unary-ops": "error", // 关键词一元操作符后加空格,符号一元操作符不加
"spaced-comment": ["error", "always", { "block": { "balanced": true } }], // 注释前面加空格
"template-curly-spacing": "error", // 模板字符串中变量大括号内不加空格
"no-shadow": "warn", // 变量名和外部作用域重复
"no-console": "off", // 线上尽量不要用console输出看不到的
}
}

3
frontend/.gitignore vendored
View File

@@ -19,3 +19,6 @@ yarn-error.log*
*.njsproj
*.sln
*.sw?
package-lock.json

View File

@@ -1,6 +1,6 @@
module.exports = {
presets: [
'@vue/app'
'@vue/cli-plugin-babel/preset'
],
plugins: [
[

14
frontend/jsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2015",
"module": "esnext",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"include": [
"./src/**/*.js",
"./src/**/*.vue"
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "blivechat",
"version": "0.1.0",
"version": "1.6.2",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
@@ -8,40 +8,25 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^0.19.0",
"core-js": "^2.6.5",
"axios": "^0.21.1",
"core-js": "^3.6.5",
"downloadjs": "^1.4.7",
"element-ui": "^2.9.1",
"lodash": "^4.17.19",
"pako": "^1.0.11",
"vue": "^2.6.10",
"vue-i18n": "^8.11.2",
"vue-router": "^3.0.6"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^3.7.0",
"@vue/cli-plugin-eslint": "^3.7.0",
"@vue/cli-service": "^4.2.2",
"babel-eslint": "^10.0.1",
"@vue/cli-plugin-babel": "^4.5.12",
"@vue/cli-plugin-eslint": "^4.5.12",
"@vue/cli-service": "~4.5.12",
"babel-eslint": "^10.1.0",
"babel-plugin-component": "^1.1.1",
"eslint": "^5.16.0",
"eslint-plugin-vue": "^5.0.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"vue-template-compiler": "^2.5.21"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"rules": {},
"parserOptions": {
"parser": "babel-eslint"
}
},
"postcss": {
"plugins": {
"autoprefixer": {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 86 KiB

File diff suppressed because one or more lines are too long

View File

@@ -1,14 +1,15 @@
import axios from 'axios'
import * as pako from 'pako'
import {getUuid4Hex} from '@/utils'
import * as avatar from './avatar'
import { BrotliDecode } from './brotli_decode'
import { getUuid4Hex } from '@/utils'
import * as avatar from '../avatar'
const HEADER_SIZE = 16
// const WS_BODY_PROTOCOL_VERSION_NORMAL = 0
// const WS_BODY_PROTOCOL_VERSION_INT = 1 // 用于心跳包
const WS_BODY_PROTOCOL_VERSION_DEFLATE = 2
// const WS_BODY_PROTOCOL_VERSION_HEARTBEAT = 1
// const WS_BODY_PROTOCOL_VERSION_DEFLATE = 2
const WS_BODY_PROTOCOL_VERSION_BROTLI = 3
// const OP_HANDSHAKE = 0
// const OP_HANDSHAKE_REPLY = 1
@@ -32,16 +33,22 @@ const OP_AUTH_REPLY = 8
// const MinBusinessOp = 1000
// const MaxBusinessOp = 10000
const AUTH_REPLY_CODE_OK = 0
// const AUTH_REPLY_CODE_TOKEN_ERROR = -101
const HEARTBEAT_INTERVAL = 10 * 1000
const RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + (5 * 1000)
let textEncoder = new TextEncoder()
let textDecoder = new TextDecoder()
export default class ChatClientDirect {
constructor (roomId) {
constructor(roomId) {
// 调用initRoom后初始化如果失败使用这里的默认值
this.roomId = roomId
this.roomOwnerUid = 0
this.hostServerList = [
{host: "broadcastlv.chat.bilibili.com", port: 2243, wss_port: 443, ws_port: 2244}
{ host: "broadcastlv.chat.bilibili.com", port: 2243, wss_port: 443, ws_port: 2244 }
]
this.onAddText = null
@@ -55,26 +62,27 @@ export default class ChatClientDirect {
this.retryCount = 0
this.isDestroying = false
this.heartbeatTimerId = null
this.receiveTimeoutTimerId = null
}
async start () {
async start() {
await this.initRoom()
this.wsConnect()
}
stop () {
stop() {
this.isDestroying = true
if (this.websocket) {
this.websocket.close()
}
}
async initRoom () {
async initRoom() {
let res
try {
res = (await axios.get('/api/room_info', {params: {
res = (await axios.get('/api/room_info', { params: {
roomId: this.roomId
}})).data
} })).data
} catch {
return
}
@@ -85,7 +93,7 @@ export default class ChatClientDirect {
}
}
makePacket (data, operation) {
makePacket(data, operation) {
let body = textEncoder.encode(JSON.stringify(data))
let header = new ArrayBuffer(HEADER_SIZE)
let headerView = new DataView(header)
@@ -97,19 +105,18 @@ export default class ChatClientDirect {
return new Blob([header, body])
}
sendAuth () {
sendAuth() {
let authParams = {
uid: 0,
uid: this.roomOwnerUid,
roomid: this.roomId,
protover: 2,
protover: 3,
platform: 'web',
clientver: '1.14.3',
type: 2
}
this.websocket.send(this.makePacket(authParams, OP_AUTH))
}
wsConnect () {
wsConnect() {
if (this.isDestroying) {
return
}
@@ -120,106 +127,174 @@ export default class ChatClientDirect {
this.websocket.onopen = this.onWsOpen.bind(this)
this.websocket.onclose = this.onWsClose.bind(this)
this.websocket.onmessage = this.onWsMessage.bind(this)
this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), 10 * 1000)
}
sendHeartbeat () {
onWsOpen() {
this.sendAuth()
this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), HEARTBEAT_INTERVAL)
this.refreshReceiveTimeoutTimer()
}
sendHeartbeat() {
this.websocket.send(this.makePacket({}, OP_HEARTBEAT))
}
onWsOpen () {
this.sendAuth()
refreshReceiveTimeoutTimer() {
if (this.receiveTimeoutTimerId) {
window.clearTimeout(this.receiveTimeoutTimerId)
}
this.receiveTimeoutTimerId = window.setTimeout(this.onReceiveTimeout.bind(this), RECEIVE_TIMEOUT)
}
onWsClose () {
onReceiveTimeout() {
console.warn('接收消息超时')
this.discardWebsocket()
}
discardWebsocket() {
if (this.receiveTimeoutTimerId) {
window.clearTimeout(this.receiveTimeoutTimerId)
this.receiveTimeoutTimerId = null
}
// 直接丢弃阻塞的websocket不等onclose回调了
this.websocket.onopen = this.websocket.onclose = this.websocket.onmessage = null
this.websocket.close()
this.onWsClose()
}
onWsClose() {
this.websocket = null
if (this.heartbeatTimerId) {
window.clearInterval(this.heartbeatTimerId)
this.heartbeatTimerId = null
}
if (this.receiveTimeoutTimerId) {
window.clearTimeout(this.receiveTimeoutTimerId)
this.receiveTimeoutTimerId = null
}
if (this.isDestroying) {
return
}
window.console.log(`掉线重连中${++this.retryCount}`)
this.retryCount++
console.warn('掉线重连中', this.retryCount)
window.setTimeout(this.wsConnect.bind(this), 1000)
}
onWsMessage (event) {
this.retryCount = 0
onWsMessage(event) {
this.refreshReceiveTimeoutTimer()
if (!(event.data instanceof ArrayBuffer)) {
window.console.warn('未知的websocket消息', event.data)
console.warn('未知的websocket消息类型data=', event.data)
return
}
let data = new Uint8Array(event.data)
this.handlerMessage(data)
this.parseWsMessage(data)
// 至少成功处理1条消息
this.retryCount = 0
}
handlerMessage (data) {
parseWsMessage(data) {
let offset = 0
while (offset < data.byteLength) {
let dataView = new DataView(data.buffer, offset)
let packLen = dataView.getUint32(0)
// let rawHeaderSize = dataView.getUint16(4)
let ver = dataView.getUint16(6)
let operation = dataView.getUint32(8)
// let seqId = dataView.getUint32(12)
switch (operation) {
case OP_HEARTBEAT_REPLY: {
// 人气值没用
break
let dataView = new DataView(data.buffer)
let packLen = dataView.getUint32(0)
let rawHeaderSize = dataView.getUint16(4)
// let ver = dataView.getUint16(6)
let operation = dataView.getUint32(8)
// let seqId = dataView.getUint32(12)
switch (operation) {
case OP_AUTH_REPLY:
case OP_SEND_MSG_REPLY: {
// 业务消息,可能有多个包一起发,需要分包
while (true) { // eslint-disable-line no-constant-condition
let body = new Uint8Array(data.buffer, offset + rawHeaderSize, packLen - rawHeaderSize)
this.parseBusinessMessage(dataView, body)
offset += packLen
if (offset >= data.byteLength) {
break
}
dataView = new DataView(data.buffer, offset)
packLen = dataView.getUint32(0)
rawHeaderSize = dataView.getUint16(4)
}
case OP_SEND_MSG_REPLY: {
let body = new Uint8Array(data.buffer, offset + HEADER_SIZE, packLen - HEADER_SIZE)
if (ver == WS_BODY_PROTOCOL_VERSION_DEFLATE) {
body = pako.inflate(body)
this.handlerMessage(body)
} else {
break
}
case OP_HEARTBEAT_REPLY: {
// 服务器心跳包,包含人气值,这里没用
break
}
default: {
// 未知消息
let body = new Uint8Array(data.buffer, offset + rawHeaderSize, packLen - rawHeaderSize)
console.warn('未知包类型operation=', operation, dataView, body)
break
}
}
}
parseBusinessMessage(dataView, body) {
let ver = dataView.getUint16(6)
let operation = dataView.getUint32(8)
switch (operation) {
case OP_SEND_MSG_REPLY: {
// 业务消息
if (ver == WS_BODY_PROTOCOL_VERSION_BROTLI) {
// 压缩过的先解压
body = BrotliDecode(body)
this.parseWsMessage(body)
} else {
// 没压缩过的直接反序列化
if (body.length !== 0) {
try {
body = JSON.parse(textDecoder.decode(body))
this.handlerCommand(body)
} catch (e) {
window.console.warn('body:', body)
console.error('body=', body)
throw e
}
}
break
}
case OP_AUTH_REPLY: {
this.sendHeartbeat()
break
break
}
case OP_AUTH_REPLY: {
// 认证响应
body = JSON.parse(textDecoder.decode(body))
if (body.code !== AUTH_REPLY_CODE_OK) {
console.error('认证响应错误body=', body)
// 这里应该重新获取token再重连的但前端没有用到token所以不重新init了
this.discardWebsocket()
throw new Error('认证响应错误')
}
default: {
let body = new Uint8Array(data.buffer, offset + HEADER_SIZE, packLen - HEADER_SIZE)
window.console.warn('未知包类型operation=', operation, body)
break
}
}
offset += packLen
this.sendHeartbeat()
break
}
default: {
// 未知消息
console.warn('未知包类型operation=', operation, dataView, body)
break
}
}
}
handlerCommand (command) {
if (command instanceof Array) {
for (let oneCommand of command) {
this.handlerCommand(oneCommand)
}
return
}
handlerCommand(command) {
let cmd = command.cmd || ''
let pos = cmd.indexOf(':')
if (pos != -1) {
cmd = cmd.substr(0, pos)
}
let handler = COMMAND_HANDLERS[cmd]
if (handler) {
handler.call(this, command)
let callback = CMD_CALLBACK_MAP[cmd]
if (callback) {
callback.call(this, command)
}
}
async onReceiveDanmaku (command) {
async danmuMsgCallback(command) {
if (!this.onAddText) {
return
}
@@ -247,7 +322,6 @@ export default class ChatClientDirect {
authorType = 0
}
let urank = info[2][5]
let data = {
avatarUrl: await avatar.getAvatarUrl(uid),
timestamp: info[0][4] / 1000,
@@ -255,18 +329,19 @@ export default class ChatClientDirect {
authorType: authorType,
content: info[1],
privilegeType: privilegeType,
isGiftDanmaku: !!info[0][9],
isGiftDanmaku: Boolean(info[0][9]),
authorLevel: info[4][0],
isNewbie: urank < 10000,
isMobileVerified: !!info[2][6],
isNewbie: info[2][5] < 10000,
isMobileVerified: Boolean(info[2][6]),
medalLevel: roomId === this.roomId ? medalLevel : 0,
id: getUuid4Hex(),
translation: ''
translation: '',
emoticon: info[0][13].url || null
}
this.onAddText(data)
}
onReceiveGift (command) {
sendGiftCallback(command) {
if (!this.onAddGift) {
return
}
@@ -287,7 +362,7 @@ export default class ChatClientDirect {
this.onAddGift(data)
}
async onBuyGuard (command) {
async guardBuyCallback(command) {
if (!this.onAddMember) {
return
}
@@ -303,7 +378,7 @@ export default class ChatClientDirect {
this.onAddMember(data)
}
onSuperChat (command) {
superChatMessageCallback(command) {
if (!this.onAddSuperChat) {
return
}
@@ -321,7 +396,7 @@ export default class ChatClientDirect {
this.onAddSuperChat(data)
}
onSuperChatDelete (command) {
superChatMessageDeleteCallback(command) {
if (!this.onDelSuperChat) {
return
}
@@ -330,14 +405,14 @@ export default class ChatClientDirect {
for (let id of command.data.ids) {
ids.push(id.toString())
}
this.onDelSuperChat({ids})
this.onDelSuperChat({ ids })
}
}
const COMMAND_HANDLERS = {
DANMU_MSG: ChatClientDirect.prototype.onReceiveDanmaku,
SEND_GIFT: ChatClientDirect.prototype.onReceiveGift,
GUARD_BUY: ChatClientDirect.prototype.onBuyGuard,
SUPER_CHAT_MESSAGE: ChatClientDirect.prototype.onSuperChat,
SUPER_CHAT_MESSAGE_DELETE: ChatClientDirect.prototype.onSuperChatDelete
const CMD_CALLBACK_MAP = {
DANMU_MSG: ChatClientDirect.prototype.danmuMsgCallback,
SEND_GIFT: ChatClientDirect.prototype.sendGiftCallback,
GUARD_BUY: ChatClientDirect.prototype.guardBuyCallback,
SUPER_CHAT_MESSAGE: ChatClientDirect.prototype.superChatMessageCallback,
SUPER_CHAT_MESSAGE_DELETE: ChatClientDirect.prototype.superChatMessageDeleteCallback
}

View File

@@ -7,8 +7,13 @@ const COMMAND_ADD_SUPER_CHAT = 5
const COMMAND_DEL_SUPER_CHAT = 6
const COMMAND_UPDATE_TRANSLATION = 7
// const CONTENT_TYPE_TEXT = 0
const CONTENT_TYPE_EMOTICON = 1
const RECEIVE_TIMEOUT = 15 * 1000
export default class ChatClientRelay {
constructor (roomId, autoTranslate) {
constructor(roomId, autoTranslate) {
this.roomId = roomId
this.autoTranslate = autoTranslate
@@ -22,42 +27,33 @@ export default class ChatClientRelay {
this.websocket = null
this.retryCount = 0
this.isDestroying = false
this.heartbeatTimerId = null
this.receiveTimeoutTimerId = null
}
start () {
start() {
this.wsConnect()
}
stop () {
stop() {
this.isDestroying = true
if (this.websocket) {
this.websocket.close()
}
}
wsConnect () {
wsConnect() {
if (this.isDestroying) {
return
}
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}/api/chat`
const url = `${protocol}://${window.location.host}/api/chat`
this.websocket = new WebSocket(url)
this.websocket.onopen = this.onWsOpen.bind(this)
this.websocket.onclose = this.onWsClose.bind(this)
this.websocket.onmessage = this.onWsMessage.bind(this)
this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), 10 * 1000)
}
sendHeartbeat () {
this.websocket.send(JSON.stringify({
cmd: COMMAND_HEARTBEAT
}))
}
onWsOpen () {
onWsOpen() {
this.retryCount = 0
this.websocket.send(JSON.stringify({
cmd: COMMAND_JOIN_ROOM,
@@ -68,28 +64,64 @@ export default class ChatClientRelay {
}
}
}))
this.refreshReceiveTimeoutTimer()
}
onWsClose () {
this.websocket = null
if (this.heartbeatTimerId) {
window.clearInterval(this.heartbeatTimerId)
this.heartbeatTimerId = null
refreshReceiveTimeoutTimer() {
if (this.receiveTimeoutTimerId) {
window.clearTimeout(this.receiveTimeoutTimerId)
}
this.receiveTimeoutTimerId = window.setTimeout(this.onReceiveTimeout.bind(this), RECEIVE_TIMEOUT)
}
onReceiveTimeout() {
console.warn('接收消息超时')
this.receiveTimeoutTimerId = null
// 直接丢弃阻塞的websocket不等onclose回调了
this.websocket.onopen = this.websocket.onclose = this.websocket.onmessage = null
this.websocket.close()
this.onWsClose()
}
onWsClose() {
this.websocket = null
if (this.receiveTimeoutTimerId) {
window.clearTimeout(this.receiveTimeoutTimerId)
this.receiveTimeoutTimerId = null
}
if (this.isDestroying) {
return
}
window.console.log(`掉线重连中${++this.retryCount}`)
console.warn(`掉线重连中${++this.retryCount}`)
window.setTimeout(this.wsConnect.bind(this), 1000)
}
onWsMessage (event) {
let {cmd, data} = JSON.parse(event.data)
onWsMessage(event) {
this.refreshReceiveTimeoutTimer()
let { cmd, data } = JSON.parse(event.data)
switch (cmd) {
case COMMAND_HEARTBEAT: {
// 不能由定时器触发发心跳包因为浏览器会把不活动页面的定时器调到1分钟以上
this.websocket.send(JSON.stringify({
cmd: COMMAND_HEARTBEAT
}))
break
}
case COMMAND_ADD_TEXT: {
if (!this.onAddText) {
break
}
let emoticon = null
let contentType = data[13]
let contentTypeParams = data[14]
if (contentType === CONTENT_TYPE_EMOTICON) {
emoticon = contentTypeParams[0]
}
data = {
avatarUrl: data[0],
timestamp: data[1],
@@ -97,13 +129,14 @@ export default class ChatClientRelay {
authorType: data[3],
content: data[4],
privilegeType: data[5],
isGiftDanmaku: !!data[6],
isGiftDanmaku: Boolean(data[6]),
authorLevel: data[7],
isNewbie: !!data[8],
isMobileVerified: !!data[9],
isNewbie: Boolean(data[8]),
isMobileVerified: Boolean(data[9]),
medalLevel: data[10],
id: data[11],
translation: data[12]
translation: data[12],
emoticon: emoticon
}
this.onAddText(data)
break

View File

@@ -0,0 +1,244 @@
import { getUuid4Hex } from '@/utils'
import * as constants from '@/components/ChatRenderer/constants'
import * as avatar from './avatar'
const NAMES = [
'xfgryujk', 'Simon', 'Il Harper', 'Kinori', 'shugen', 'yuyuyzl', '3Shain', '光羊', '黑炎', 'Misty', '孤梦星影',
'ジョナサン・ジョースター', 'ジョセフ・ジョースター', 'ディオ・ブランドー', '空條承太郎', '博丽灵梦', '雾雨魔理沙',
'Rick Astley'
]
const CONTENTS = [
'草', 'kksk', '8888888888', '888888888888888888888888888888', '老板大气,老板身体健康',
'The quick brown fox jumps over the lazy dog', "I can eat glass, it doesn't hurt me",
'我不做人了JOJO', '無駄無駄無駄無駄無駄無駄無駄無駄', '欧啦欧啦欧啦欧啦欧啦欧啦欧啦欧啦', '逃げるんだよォ!',
'嚯,朝我走过来了吗,没有选择逃跑而是主动接近我么', '不要停下来啊', '已经没有什么好怕的了',
'I am the bone of my sword. Steel is my body, and fire is my blood.', '言いたいことがあるんだよ!',
'我忘不掉夏小姐了。如果不是知道了夏小姐,说不定我已经对这个世界没有留恋了', '迷えば、敗れる',
'Farewell, ashen one. May the flame guide thee', '竜神の剣を喰らえ!', '竜が我が敌を喰らう!',
'有一说一,这件事大家懂的都懂,不懂的,说了你也不明白,不如不说', '让我看看', '我柜子动了,我不玩了'
]
const EMOTICONS = [
'/static/img/emoticons/233.png',
'/static/img/emoticons/miaoa.png',
'/static/img/emoticons/lipu.png'
]
const AUTHOR_TYPES = [
{ weight: 10, value: constants.AUTHRO_TYPE_NORMAL },
{ weight: 5, value: constants.AUTHRO_TYPE_MEMBER },
{ weight: 2, value: constants.AUTHRO_TYPE_ADMIN },
{ weight: 1, value: constants.AUTHRO_TYPE_OWNER }
]
function randGuardInfo() {
let authorType = randomChoose(AUTHOR_TYPES)
let privilegeType
if (authorType === constants.AUTHRO_TYPE_MEMBER) {
privilegeType = randInt(1, 3)
} else if (authorType === constants.AUTHRO_TYPE_ADMIN) {
privilegeType = randInt(0, 3)
} else {
privilegeType = 0
}
return { authorType, privilegeType }
}
const GIFT_INFO_LIST = [
{ giftName: 'B坷垃', totalCoin: 9900 },
{ giftName: '礼花', totalCoin: 28000 },
{ giftName: '花式夸夸', totalCoin: 39000 },
{ giftName: '天空之翼', totalCoin: 100000 },
{ giftName: '摩天大楼', totalCoin: 450000 },
{ giftName: '小电视飞船', totalCoin: 1245000 }
]
const SC_PRICES = [
30, 50, 100, 200, 500, 1000
]
const MESSAGE_GENERATORS = [
// 文字
{
weight: 20,
value() {
return {
type: constants.MESSAGE_TYPE_TEXT,
message: {
...randGuardInfo(),
avatarUrl: avatar.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
content: randomChoose(CONTENTS),
isGiftDanmaku: randInt(1, 10) <= 1,
authorLevel: randInt(0, 60),
isNewbie: randInt(1, 10) <= 1,
isMobileVerified: randInt(1, 10) <= 9,
medalLevel: randInt(0, 40),
id: getUuid4Hex(),
translation: '',
emoticon: null
}
}
}
},
// 表情
{
weight: 5,
value() {
return {
type: constants.MESSAGE_TYPE_TEXT,
message: {
...randGuardInfo(),
avatarUrl: avatar.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
content: '',
isGiftDanmaku: false,
authorLevel: randInt(0, 60),
isNewbie: randInt(1, 10) <= 1,
isMobileVerified: randInt(1, 10) <= 9,
medalLevel: randInt(0, 40),
id: getUuid4Hex(),
translation: '',
emoticon: randomChoose(EMOTICONS)
}
}
}
},
// 礼物
{
weight: 1,
value() {
return {
type: constants.MESSAGE_TYPE_GIFT,
message: {
...randomChoose(GIFT_INFO_LIST),
id: getUuid4Hex(),
avatarUrl: avatar.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
num: 1
}
}
}
},
// SC
{
weight: 3,
value() {
return {
type: constants.MESSAGE_TYPE_SUPER_CHAT,
message: {
id: getUuid4Hex(),
avatarUrl: avatar.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
price: randomChoose(SC_PRICES),
content: randomChoose(CONTENTS),
translation: ''
}
}
}
},
// 新舰长
{
weight: 1,
value() {
return {
type: constants.MESSAGE_TYPE_MEMBER,
message: {
id: getUuid4Hex(),
avatarUrl: avatar.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
privilegeType: randInt(1, 3)
}
}
}
}
]
function randomChoose(nodes) {
if (nodes.length === 0) {
return null
}
for (let node of nodes) {
if (node.weight === undefined || node.value === undefined) {
return nodes[randInt(0, nodes.length - 1)]
}
}
let totalWeight = 0
for (let node of nodes) {
totalWeight += node.weight
}
let remainWeight = randInt(1, totalWeight)
for (let node of nodes) {
remainWeight -= node.weight
if (remainWeight > 0) {
continue
}
if (node.value instanceof Array) {
return randomChoose(node.value)
}
return node.value
}
return null
}
function randInt(min, max) {
return Math.floor(min + ((max - min + 1) * Math.random()))
}
export default class ChatClientTest {
constructor() {
this.minSleepTime = 800
this.maxSleepTime = 1200
this.onAddText = null
this.onAddGift = null
this.onAddMember = null
this.onAddSuperChat = null
this.onDelSuperChat = null
this.onUpdateTranslation = null
this.timerId = null
}
start() {
this.refreshTimer()
}
stop() {
if (this.timerId) {
window.clearTimeout(this.timerId)
this.timerId = null
}
}
refreshTimer() {
this.timerId = window.setTimeout(this.onTimeout.bind(this), randInt(this.minSleepTime, this.maxSleepTime))
}
onTimeout() {
this.refreshTimer()
let { type, message } = randomChoose(MESSAGE_GENERATORS)()
switch (type) {
case constants.MESSAGE_TYPE_TEXT:
this.onAddText(message)
break
case constants.MESSAGE_TYPE_GIFT:
this.onAddGift(message)
break
case constants.MESSAGE_TYPE_MEMBER:
this.onAddMember(message)
break
case constants.MESSAGE_TYPE_SUPER_CHAT:
this.onAddSuperChat(message)
break
}
}
}

View File

@@ -2,7 +2,7 @@ import axios from 'axios'
export const DEFAULT_AVATAR_URL = '//static.hdslb.com/images/member/noface.gif'
export function processAvatarUrl (avatarUrl) {
export function processAvatarUrl(avatarUrl) {
// 去掉协议兼容HTTP、HTTPS
let m = avatarUrl.match(/(?:https?:)?(.*)/)
if (m) {
@@ -15,12 +15,12 @@ export function processAvatarUrl (avatarUrl) {
return avatarUrl
}
export async function getAvatarUrl (uid) {
export async function getAvatarUrl(uid) {
let res
try {
res = (await axios.get('/api/avatar_url', {params: {
res = (await axios.get('/api/avatar_url', { params: {
uid: uid
}})).data
} })).data
} catch {
return DEFAULT_AVATAR_URL
}

View File

@@ -1,4 +1,6 @@
import {mergeConfig} from '@/utils'
import _ from 'lodash'
import { mergeConfig } from '@/utils'
export const DEFAULT_CONFIG = {
minGiftPrice: 7, // $1
@@ -11,25 +13,56 @@ export const DEFAULT_CONFIG = {
blockGiftDanmaku: true,
blockLevel: 0,
blockNewbie: true,
blockNotMobileVerified: true,
blockNewbie: false,
blockNotMobileVerified: false,
blockKeywords: '',
blockUsers: '',
blockMedalLevel: 0,
relayMessagesByServer: false,
autoTranslate: false,
giftUsernamePronunciation: ''
giftUsernamePronunciation: '',
emoticons: [] // [{ keyword: '', url: '' }, ...]
}
export function setLocalConfig (config) {
export function deepCloneDefaultConfig() {
return _.cloneDeep(DEFAULT_CONFIG)
}
export function setLocalConfig(config) {
config = mergeConfig(config, DEFAULT_CONFIG)
window.localStorage.config = JSON.stringify(config)
}
export function getLocalConfig () {
if (!window.localStorage.config) {
return DEFAULT_CONFIG
export function getLocalConfig() {
try {
let config = JSON.parse(window.localStorage.config)
config = mergeConfig(config, deepCloneDefaultConfig())
sanitizeConfig(config)
return config
} catch {
return deepCloneDefaultConfig()
}
return mergeConfig(JSON.parse(window.localStorage.config), DEFAULT_CONFIG)
}
export function sanitizeConfig(config) {
let newEmoticons = []
if (config.emoticons instanceof Array) {
for (let emoticon of config.emoticons) {
try {
let newEmoticon = {
keyword: emoticon.keyword,
url: emoticon.url
}
if ((typeof newEmoticon.keyword !== 'string') || (typeof newEmoticon.url !== 'string')) {
continue
}
newEmoticons.push(newEmoticon)
} catch {
continue
}
}
}
config.emoticons = newEmoticons
}

11
frontend/src/api/main.js Normal file
View File

@@ -0,0 +1,11 @@
import axios from 'axios'
export async function getServerInfo() {
return (await axios.get('/api/server_info')).data
}
export async function uploadEmoticon(file) {
let body = new FormData()
body.set('file', file)
return (await axios.post('/api/emoticon', body)).data
}

View File

@@ -314,7 +314,9 @@ yt-live-chat-membership-item-renderer[dashboard-money-feed] #content.yt-live-cha
}
#content.yt-live-chat-membership-item-renderer img.yt-live-chat-membership-item-renderer {
width: var(--yt-live-chat-emoji-size);
/* B站表情有不是正方形的 */
/* width: var(--yt-live-chat-emoji-size); */
width: auto;
height: var(--yt-live-chat-emoji-size);
margin: -1px 2px 1px 2px;

View File

@@ -312,7 +312,9 @@ yt-live-chat-paid-message-renderer[allow-animations] #content.yt-live-chat-paid-
}
#content.yt-live-chat-paid-message-renderer img.yt-live-chat-paid-message-renderer {
width: var(--yt-live-chat-emoji-size);
/* B站表情有不是正方形的 */
/* width: var(--yt-live-chat-emoji-size); */
width: auto;
height: var(--yt-live-chat-emoji-size);
margin: -1px 2px 1px 2px;
vertical-align: middle;

View File

@@ -159,7 +159,9 @@ yt-live-chat-author-chip.yt-live-chat-text-message-renderer {
}
#message.yt-live-chat-text-message-renderer .emoji.yt-live-chat-text-message-renderer {
width: var(--yt-live-chat-emoji-size);
/* B站表情有不是正方形的 */
/* width: var(--yt-live-chat-emoji-size); */
width: auto;
height: var(--yt-live-chat-emoji-size);
margin: -1px 2px 1px 2px;
vertical-align: middle;

View File

@@ -1,5 +1,5 @@
<template>
<yt-live-chat-author-badge-renderer :type="authorTypeText" v-if="isAdmin || privilegeType > 0">
<yt-live-chat-author-badge-renderer :type="authorTypeText">
<el-tooltip :content="readableAuthorTypeText" placement="top">
<div id="image" class="style-scope yt-live-chat-author-badge-renderer">
<yt-icon v-if="isAdmin" class="style-scope yt-live-chat-author-badge-renderer">
@@ -14,7 +14,8 @@
</svg>
</yt-icon>
<img v-else :src="`/static/img/icons/guard-level-${privilegeType}.png`"
class="style-scope yt-live-chat-author-badge-renderer" :alt="readableAuthorTypeText">
class="style-scope yt-live-chat-author-badge-renderer" :alt="readableAuthorTypeText"
>
</div>
</el-tooltip>
</yt-live-chat-author-badge-renderer>
@@ -38,9 +39,9 @@ export default {
},
readableAuthorTypeText() {
if (this.isAdmin) {
return '管理员'
return this.$t('chat.moderator')
}
return constants.GUARD_LEVEL_TO_TEXT[this.privilegeType]
return constants.getShowGuardLevelText(this.privilegeType)
}
}
}

View File

@@ -0,0 +1,54 @@
<template>
<yt-live-chat-author-chip>
<span id="author-name" dir="auto" class="style-scope yt-live-chat-author-chip" :class="{ member: isInMemberMessage }"
:type="authorTypeText"
>
<template>{{ authorName }}</template>
<!-- 这里是已验证勋章 -->
<span id="chip-badges" class="style-scope yt-live-chat-author-chip"></span>
</span>
<span id="chat-badges" class="style-scope yt-live-chat-author-chip">
<author-badge v-if="isInMemberMessage" class="style-scope yt-live-chat-author-chip"
:isAdmin="false" :privilegeType="privilegeType"
></author-badge>
<template v-else>
<author-badge v-if="authorType === AUTHRO_TYPE_ADMIN" class="style-scope yt-live-chat-author-chip"
isAdmin :privilegeType="0"
></author-badge>
<author-badge v-if="privilegeType > 0" class="style-scope yt-live-chat-author-chip"
:isAdmin="false" :privilegeType="privilegeType"
></author-badge>
</template>
</span>
</yt-live-chat-author-chip>
</template>
<script>
import AuthorBadge from './AuthorBadge'
import * as constants from './constants'
export default {
name: 'AuthorChip',
components: {
AuthorBadge
},
props: {
isInMemberMessage: Boolean,
authorName: String,
authorType: Number,
privilegeType: Number
},
data() {
return {
AUTHRO_TYPE_ADMIN: constants.AUTHRO_TYPE_ADMIN
}
},
computed: {
authorTypeText() {
return constants.AUTHOR_TYPE_TO_TEXT[this.authorType]
}
}
}
</script>
<style src="@/assets/css/youtube/yt-live-chat-author-chip.css"></style>

View File

@@ -8,22 +8,13 @@
<div id="header-content" class="style-scope yt-live-chat-membership-item-renderer">
<div id="header-content-primary-column" class="style-scope yt-live-chat-membership-item-renderer">
<div id="header-content-inner-column" class="style-scope yt-live-chat-membership-item-renderer">
<yt-live-chat-author-chip class="style-scope yt-live-chat-membership-item-renderer">
<span id="author-name" dir="auto" class="member style-scope yt-live-chat-author-chip">{{
authorName
}}<!-- 这里是已验证勋章 -->
<span id="chip-badges" class="style-scope yt-live-chat-author-chip"></span>
</span>
<span id="chat-badges" class="style-scope yt-live-chat-author-chip">
<author-badge class="style-scope yt-live-chat-author-chip"
:isAdmin="false" :privilegeType="privilegeType"
></author-badge>
</span>
</yt-live-chat-author-chip>
<author-chip class="style-scope yt-live-chat-membership-item-renderer"
isInMemberMessage :authorName="authorName" :authorType="0" :privilegeType="privilegeType"
></author-chip>
</div>
<div id="header-subtext" class="style-scope yt-live-chat-membership-item-renderer">{{title}}</div>
<div id="header-subtext" class="style-scope yt-live-chat-membership-item-renderer">{{ title }}</div>
</div>
<div id="timestamp" class="style-scope yt-live-chat-membership-item-renderer">{{timeText}}</div>
<div id="timestamp" class="style-scope yt-live-chat-membership-item-renderer">{{ timeText }}</div>
</div>
</div>
</div>
@@ -31,15 +22,15 @@
</template>
<script>
import ImgShadow from './ImgShadow.vue'
import AuthorBadge from './AuthorBadge.vue'
import ImgShadow from './ImgShadow'
import AuthorChip from './AuthorChip'
import * as utils from '@/utils'
export default {
name: 'MembershipItem',
components: {
ImgShadow,
AuthorBadge
AuthorChip
},
props: {
avatarUrl: String,

View File

@@ -16,23 +16,21 @@
></img-shadow>
<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">{{priceText}}</div>
<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">{{ priceText }}</div>
</div>
<span id="timestamp" class="style-scope yt-live-chat-paid-message-renderer">{{timeText}}</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">
<div id="message" dir="auto" class="style-scope yt-live-chat-paid-message-renderer">{{
content
}}</div>
<div id="message" dir="auto" class="style-scope yt-live-chat-paid-message-renderer">{{ content }}</div>
</div>
</div>
</yt-live-chat-paid-message-renderer>
</template>
<script>
import ImgShadow from './ImgShadow.vue'
import ImgShadow from './ImgShadow'
import * as constants from './constants'
import * as utils from '@/utils'
@@ -53,7 +51,7 @@ export default {
return constants.getPriceConfig(this.price).colors
},
priceText() {
return 'CN¥' + utils.formatCurrency(this.price)
return `CN¥${utils.formatCurrency(this.price)}`
},
timeText() {
return utils.getTimeTextHourMin(this.time)

View File

@@ -4,23 +4,20 @@
: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">{{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
}}<!-- 这里是已验证勋章 -->
<span id="chip-badges" class="style-scope yt-live-chat-author-chip"></span>
</span>
<span id="chat-badges" class="style-scope yt-live-chat-author-chip">
<author-badge class="style-scope yt-live-chat-author-chip"
:isAdmin="authorType === 2" :privilegeType="privilegeType"
></author-badge>
</span>
</yt-live-chat-author-chip>
<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}"
<span id="timestamp" class="style-scope yt-live-chat-text-message-renderer">{{ timeText }}</span>
<author-chip class="style-scope yt-live-chat-text-message-renderer"
:isInMemberMessage="false" :authorName="authorName" :authorType="authorType" :privilegeType="privilegeType"
></author-chip>
<span id="message" class="style-scope yt-live-chat-text-message-renderer">
<template v-for="(content, index) in richContent">
<span :key="index" v-if="content.type === CONTENT_TYPE_TEXT">{{ content.text }}</span>
<img :key="index" v-else-if="content.type === CONTENT_TYPE_IMAGE"
class="emoji yt-formatted-string style-scope yt-live-chat-text-message-renderer"
:src="content.url" :alt="content.text" :shared-tooltip-text="content.text" :id="`emoji-${content.text}`"
>
</template>
<el-badge :value="repeated" :max="99" v-if="repeated > 1" class="style-scope yt-live-chat-text-message-renderer"
:style="{ '--repeated-mark-color': repeatedMarkColor }"
></el-badge>
</span>
</div>
@@ -28,8 +25,8 @@
</template>
<script>
import ImgShadow from './ImgShadow.vue'
import AuthorBadge from './AuthorBadge.vue'
import ImgShadow from './ImgShadow'
import AuthorChip from './AuthorChip'
import * as constants from './constants'
import * as utils from '@/utils'
@@ -41,17 +38,23 @@ export default {
name: 'TextMessage',
components: {
ImgShadow,
AuthorBadge
AuthorChip
},
props: {
avatarUrl: String,
time: Date,
authorName: String,
authorType: Number,
content: String,
richContent: Array,
privilegeType: Number,
repeated: Number
},
data() {
return {
CONTENT_TYPE_TEXT: constants.CONTENT_TYPE_TEXT,
CONTENT_TYPE_IMAGE: constants.CONTENT_TYPE_IMAGE
}
},
computed: {
timeText() {
return utils.getTimeTextHourMin(this.time)
@@ -69,7 +72,7 @@ export default {
color = [0, 0, 0]
let t = (this.repeated - 2) / (10 - 2)
for (let i = 0; i < 3; i++) {
color[i] = REPEATED_MARK_COLOR_START[i] + (REPEATED_MARK_COLOR_END[i] - REPEATED_MARK_COLOR_START[i]) * t
color[i] = REPEATED_MARK_COLOR_START[i] + ((REPEATED_MARK_COLOR_END[i] - REPEATED_MARK_COLOR_START[i]) * t)
}
}
return `hsl(${color[0]}, ${color[1]}%, ${color[2]}%)`
@@ -95,4 +98,3 @@ yt-live-chat-text-message-renderer>#content>#message>.el-badge .el-badge__conten
</style>
<style src="@/assets/css/youtube/yt-live-chat-text-message-renderer.css"></style>
<style src="@/assets/css/youtube/yt-live-chat-author-chip.css"></style>

View File

@@ -1,7 +1,9 @@
<template>
<yt-live-chat-ticker-renderer :hidden="showMessages.length === 0">
<div id="container" dir="ltr" class="style-scope yt-live-chat-ticker-renderer">
<div id="items" class="style-scope yt-live-chat-ticker-renderer">
<transition-group tag="div" :css="false" @enter="onTickerItemEnter" @leave="onTickerItemLeave"
id="items" class="style-scope yt-live-chat-ticker-renderer"
>
<yt-live-chat-ticker-paid-message-item-renderer v-for="message in showMessages" :key="message.raw.id"
tabindex="0" class="style-scope yt-live-chat-ticker-renderer" style="overflow: hidden;"
@click="onItemClick(message.raw)"
@@ -15,11 +17,11 @@
<img-shadow id="author-photo" height="24" width="24" class="style-scope yt-live-chat-ticker-paid-message-item-renderer"
:imgUrl="message.raw.avatarUrl"
></img-shadow>
<span id="text" dir="ltr" class="style-scope yt-live-chat-ticker-paid-message-item-renderer">{{message.text}}</span>
<span id="text" dir="ltr" class="style-scope yt-live-chat-ticker-paid-message-item-renderer">{{ message.text }}</span>
</div>
</div>
</yt-live-chat-ticker-paid-message-item-renderer>
</div>
</transition-group>
</div>
<template v-if="pinnedMessage">
<membership-item :key="pinnedMessage.id" v-if="pinnedMessage.type === MESSAGE_TYPE_MEMBER"
@@ -38,10 +40,10 @@
<script>
import * as chatConfig from '@/api/chatConfig'
import {formatCurrency} from '@/utils'
import ImgShadow from './ImgShadow.vue'
import MembershipItem from './MembershipItem.vue'
import PaidMessage from './PaidMessage.vue'
import { formatCurrency } from '@/utils'
import ImgShadow from './ImgShadow'
import MembershipItem from './MembershipItem'
import PaidMessage from './PaidMessage'
import * as constants from './constants'
export default {
@@ -98,6 +100,32 @@ export default {
window.clearInterval(this.updateTimerId)
},
methods: {
async onTickerItemEnter(el, done) {
let width = el.clientWidth
if (width === 0) {
// CSS指定了不显示固定栏
done()
return
}
el.style.width = 0
await this.$nextTick()
el.style.width = `${width}px`
window.setTimeout(done, 200)
},
onTickerItemLeave(el, done) {
el.classList.add('sliding-down')
window.setTimeout(() => {
el.classList.add('collapsing')
el.style.width = 0
window.setTimeout(() => {
el.classList.remove('sliding-down')
el.classList.remove('collapsing')
el.style.width = 'auto'
done()
}, 200)
}, 200)
},
getShowAuthorName: constants.getShowAuthorName,
needToShow(message) {
let pinTime = this.getPinTime(message)
@@ -114,7 +142,7 @@ export default {
color2 = config.colors.headerBg
}
let pinTime = this.getPinTime(message)
let progress = (1 - (this.curTime - message.addTime) / (60 * 1000) / pinTime) * 100
let progress = (1 - ((this.curTime - message.addTime) / (60 * 1000) / pinTime)) * 100
if (progress < 0) {
progress = 0
} else if (progress > 100) {
@@ -130,9 +158,9 @@ export default {
},
getText(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
return 'Member'
return this.$t('chat.tickerMembership')
}
return 'CN¥' + formatCurrency(message.price)
return `CN¥${formatCurrency(message.price)}`
},
getPinTime(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
@@ -141,18 +169,25 @@ export default {
return constants.getPriceConfig(message.price).pinTime
},
updateProgress() {
// 更新进度
this.curTime = new Date()
for (let i = 0; i < this.messages.length;) {
let message = this.messages[i]
// 删除过期的消息
let filteredMessages = []
let messagesChanged = false
for (let message of this.messages) {
let pinTime = this.getPinTime(message)
if ((this.curTime - message.addTime) / (60 * 1000) >= pinTime) {
if (this.pinnedMessage == message) {
messagesChanged = true
if (this.pinnedMessage === message) {
this.pinnedMessage = null
}
this.messages.splice(i, 1)
} else {
i++
continue
}
filteredMessages.push(message)
}
if (messagesChanged) {
this.$emit('update:messages', filteredMessages)
}
},
onItemClick(message) {

View File

@@ -1,3 +1,5 @@
import * as i18n from '@/i18n'
export const AUTHRO_TYPE_NORMAL = 0
export const AUTHRO_TYPE_MEMBER = 1
export const AUTHRO_TYPE_ADMIN = 2
@@ -10,13 +12,21 @@ export const AUTHOR_TYPE_TO_TEXT = [
'owner' // 主播
]
export const GUARD_LEVEL_TO_TEXT = [
const GUARD_LEVEL_TO_TEXT_KEY = [
'',
'总督',
'提督',
'舰长'
'chat.guardLevel1',
'chat.guardLevel2',
'chat.guardLevel3'
]
export function getShowGuardLevelText(guardLevel) {
let key = GUARD_LEVEL_TO_TEXT_KEY[guardLevel] || ''
if (key === '') {
return ''
}
return i18n.i18n.t(key)
}
export const MESSAGE_TYPE_TEXT = 0
export const MESSAGE_TYPE_GIFT = 1
export const MESSAGE_TYPE_MEMBER = 2
@@ -24,6 +34,9 @@ export const MESSAGE_TYPE_SUPER_CHAT = 3
export const MESSAGE_TYPE_DEL = 4
export const MESSAGE_TYPE_UPDATE = 5
export const CONTENT_TYPE_TEXT = 0
export const CONTENT_TYPE_IMAGE = 1
// 美元 -> 人民币 汇率
const EXCHANGE_RATE = 7
export const PRICE_CONFIGS = [
@@ -100,7 +113,7 @@ export const PRICE_CONFIGS = [
pinTime: 0
},
{ // $1蓝
price: 1 * EXCHANGE_RATE,
price: EXCHANGE_RATE,
colors: {
contentBg: 'rgba(30,136,229,1)',
headerBg: 'rgba(21,101,192,1)',
@@ -113,7 +126,7 @@ export const PRICE_CONFIGS = [
}
]
export function getPriceConfig (price) {
export function getPriceConfig(price) {
for (const config of PRICE_CONFIGS) {
if (price >= config.price) {
return config
@@ -122,22 +135,33 @@ export function getPriceConfig (price) {
return PRICE_CONFIGS[PRICE_CONFIGS.length - 1]
}
export function getShowContent (message) {
export function getShowContent(message) {
if (message.translation) {
return `${message.content}${message.translation}`
}
return message.content
}
export function getGiftShowContent (message, showGiftName) {
export function getShowRichContent(message) {
let richContent = [...message.richContent]
if (message.translation) {
richContent.push({
type: CONTENT_TYPE_TEXT,
text: `${message.translation}`
})
}
return richContent
}
export function getGiftShowContent(message, showGiftName) {
if (!showGiftName) {
return ''
}
return `Sent ${message.giftName}x${message.num}`
return i18n.i18n.t('chat.sendGift', { giftName: message.giftName, num: message.num })
}
export function getShowAuthorName (message) {
if (message.authorNamePronunciation) {
export function getShowAuthorName(message) {
if (message.authorNamePronunciation && message.authorNamePronunciation !== message.authorName) {
return `${message.authorName}(${message.authorNamePronunciation})`
}
return message.authorName

View File

@@ -2,34 +2,47 @@
<yt-live-chat-renderer class="style-scope yt-live-chat-app" style="--scrollbar-width:11px;" hide-timestamps
@mousemove="refreshCantScrollStartTime"
>
<ticker class="style-scope yt-live-chat-renderer" :messages="paidMessages" :showGiftName="showGiftName"></ticker>
<ticker class="style-scope yt-live-chat-renderer" :messages.sync="paidMessages" :showGiftName="showGiftName"></ticker>
<yt-live-chat-item-list-renderer class="style-scope yt-live-chat-renderer" allow-scroll>
<div ref="scroller" id="item-scroller" class="style-scope yt-live-chat-item-list-renderer animated" @scroll="onScroll">
<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)`}"
: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"
:avatarUrl="message.avatarUrl" :time="message.time" :authorName="message.authorName"
:authorType="message.authorType" :content="getShowContent(message)" :privilegeType="message.privilegeType"
:time="message.time"
:avatarUrl="message.avatarUrl"
:authorName="message.authorName"
:authorType="message.authorType"
:privilegeType="message.privilegeType"
:richContent="getShowRichContent(message)"
:repeated="message.repeated"
></text-message>
<paid-message :key="message.id" v-else-if="message.type === MESSAGE_TYPE_GIFT"
class="style-scope yt-live-chat-item-list-renderer"
:price="message.price" :avatarUrl="message.avatarUrl" :authorName="getShowAuthorName(message)"
:time="message.time" :content="getGiftShowContent(message)"
:time="message.time"
:avatarUrl="message.avatarUrl"
:authorName="getShowAuthorName(message)"
:price="message.price"
:content="getGiftShowContent(message)"
></paid-message>
<membership-item :key="message.id" v-else-if="message.type === MESSAGE_TYPE_MEMBER"
class="style-scope yt-live-chat-item-list-renderer"
:avatarUrl="message.avatarUrl" :authorName="getShowAuthorName(message)" :privilegeType="message.privilegeType"
:title="message.title" :time="message.time"
:time="message.time"
:avatarUrl="message.avatarUrl"
:authorName="getShowAuthorName(message)"
:privilegeType="message.privilegeType"
:title="message.title"
></membership-item>
<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="getShowAuthorName(message)"
:time="message.time" :content="getShowContent(message)"
:time="message.time"
:avatarUrl="message.avatarUrl"
:authorName="getShowAuthorName(message)"
:price="message.price"
:content="getShowContent(message)"
></paid-message>
</template>
</div>
@@ -41,13 +54,27 @@
<script>
import * as chatConfig from '@/api/chatConfig'
import Ticker from './Ticker.vue'
import TextMessage from './TextMessage.vue'
import MembershipItem from './MembershipItem.vue'
import PaidMessage from './PaidMessage.vue'
import Ticker from './Ticker'
import TextMessage from './TextMessage'
import MembershipItem from './MembershipItem'
import PaidMessage from './PaidMessage'
import * as constants from './constants'
// 只有要添加的消息需要平滑
const NEED_SMOOTH_MESSAGE_TYPES = [
constants.MESSAGE_TYPE_TEXT,
constants.MESSAGE_TYPE_GIFT,
constants.MESSAGE_TYPE_MEMBER,
constants.MESSAGE_TYPE_SUPER_CHAT
]
// 发送消息时间间隔范围
const MESSAGE_MIN_INTERVAL = 80
const MESSAGE_MAX_INTERVAL = 1000
// 每次发送消息后增加的动画时间要比MESSAGE_MIN_INTERVAL稍微大一点太小了动画不连续太大了发送消息时会中断动画
// 84 = ceil((1000 / 60) * 5)
const CHAT_SMOOTH_ANIMATION_TIME_MS = 84
// 滚动条距离底部小于多少像素则认为在底部
const SCROLLED_TO_BOTTOM_EPSILON = 15
export default {
@@ -59,7 +86,6 @@ export default {
PaidMessage
},
props: {
css: String,
maxNumber: {
type: Number,
default: chatConfig.DEFAULT_CONFIG.maxNumber
@@ -70,15 +96,12 @@ export default {
}
},
data() {
let styleElement = document.createElement('style')
document.head.appendChild(styleElement)
return {
MESSAGE_TYPE_TEXT: constants.MESSAGE_TYPE_TEXT,
MESSAGE_TYPE_GIFT: constants.MESSAGE_TYPE_GIFT,
MESSAGE_TYPE_MEMBER: constants.MESSAGE_TYPE_MEMBER,
MESSAGE_TYPE_SUPER_CHAT: constants.MESSAGE_TYPE_SUPER_CHAT,
styleElement,
messages: [], // 显示的消息
paidMessages: [], // 固定在上方的消息
@@ -104,23 +127,18 @@ export default {
},
computed: {
canScrollToBottom() {
return this.atBottom/* || this.allowScroll*/
return this.atBottom/* || this.allowScroll */
}
},
watch: {
css(val) {
this.styleElement.innerText = val
},
canScrollToBottom(val) {
this.cantScrollStartTime = val ? null : new Date()
}
},
mounted() {
this.styleElement.innerText = this.css
this.scrollToBottom()
},
beforeDestroy() {
document.head.removeChild(this.styleElement)
if (this.emitSmoothedMessageTimerId) {
window.clearTimeout(this.emitSmoothedMessageTimerId)
this.emitSmoothedMessageTimerId = null
@@ -132,6 +150,7 @@ export default {
return constants.getGiftShowContent(message, this.showGiftName)
},
getShowContent: constants.getShowContent,
getShowRichContent: constants.getShowRichContent,
getShowAuthorName: constants.getShowAuthorName,
addMessage(message) {
@@ -206,12 +225,12 @@ export default {
this.delMessages([id])
},
delMessages(ids) {
this.enqueueMessages(ids.map(id => {
return {
this.enqueueMessages(ids.map(
id => ({
type: constants.MESSAGE_TYPE_DEL,
id
}
}))
})
))
},
clearMessages() {
this.messages = []
@@ -239,36 +258,53 @@ export default {
},
enqueueMessages(messages) {
if (this.lastEnqueueTime) {
let interval = new Date() - this.lastEnqueueTime
// 理论上B站发包间隔1S如果不过滤间隔太短的会导致消息平滑失效
if (interval > 100) {
// 估计进队列时间间隔
if (!this.lastEnqueueTime) {
this.lastEnqueueTime = new Date()
} else {
let curTime = new Date()
let interval = curTime - this.lastEnqueueTime
// 让发消息速度变化不要太频繁
if (interval > 1000) {
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 = curTime
}
}
this.lastEnqueueTime = new Date()
// 只有要显示的消息需要平滑
// 把messages分成messageGroup每个组里最多有1个需要平滑的消息
let messageGroup = []
for (let message of messages) {
messageGroup.push(message)
if (message.type !== constants.MESSAGE_TYPE_DEL && message.type !== constants.MESSAGE_TYPE_UPDATE) {
if (this.messageNeedSmooth(message)) {
this.smoothedMessageQueue.push(messageGroup)
messageGroup = []
}
}
// 还剩下不需要平滑的消息
if (messageGroup.length > 0) {
if (this.smoothedMessageQueue.length > 0) {
// 和上一组合并
let lastMessageGroup = this.smoothedMessageQueue[this.smoothedMessageQueue.length - 1]
for (let message of messageGroup) {
lastMessageGroup.push(message)
}
} else {
// 自己一个组
this.smoothedMessageQueue.push(messageGroup)
}
}
if (!this.emitSmoothedMessageTimerId) {
this.emitSmoothedMessageTimerId = window.setTimeout(this.emitSmoothedMessages)
}
},
messageNeedSmooth({ type }) {
return NEED_SMOOTH_MESSAGE_TYPES.indexOf(type) !== -1
},
emitSmoothedMessages() {
this.emitSmoothedMessageTimerId = null
if (this.smoothedMessageQueue.length <= 0) {
@@ -280,21 +316,18 @@ export default {
if (this.estimatedEnqueueInterval) {
estimatedNextEnqueueRemainTime = Math.max(this.lastEnqueueTime - new Date() + this.estimatedEnqueueInterval, 1)
}
// 最快80ms/条,计算发送的消息数,保证在下次进队列之前消费队列到最多剩3条消息不消费完是为了防止消息速度变慢时突然停顿
const MIN_SLEEP_TIME = 80
const MAX_SLEEP_TIME = 1000
const MAX_REMAIN_GROUP_NUM = 3
// 计算发送的消息数,保证在下次进队列之前发完
// 下次进队列之前应该发多少条消息
let shouldEmitGroupNum = Math.max(this.smoothedMessageQueue.length - MAX_REMAIN_GROUP_NUM, 0)
let shouldEmitGroupNum = Math.max(this.smoothedMessageQueue.length, 0)
// 下次进队列之前最多能发多少次
let maxCanEmitCount = estimatedNextEnqueueRemainTime / MIN_SLEEP_TIME
let maxCanEmitCount = estimatedNextEnqueueRemainTime / MESSAGE_MIN_INTERVAL
// 这次发多少条消息
let groupNumToEmit
if (shouldEmitGroupNum < maxCanEmitCount) {
// 队列中消息数很少每次发1条也能发到最多剩3条
// 队列中消息数很少每次发1条也能发
groupNumToEmit = 1
} else {
// 每次发1条以上保证按最快速度能发到最多剩3条
// 每次发1条以上保证按最快速度能发
groupNumToEmit = Math.ceil(shouldEmitGroupNum / maxCanEmitCount)
}
@@ -314,17 +347,17 @@ export default {
// 消息没发完,计算下次发消息时间
let sleepTime
if (groupNumToEmit === 1) {
// 队列中消息数很少,随便定个[MIN_SLEEP_TIME, MAX_SLEEP_TIME]的时间
// 队列中消息数很少,随便定个[MESSAGE_MIN_INTERVAL, MESSAGE_MAX_INTERVAL]的时间
sleepTime = estimatedNextEnqueueRemainTime / this.smoothedMessageQueue.length
sleepTime *= 0.5 + Math.random()
if (sleepTime > MAX_SLEEP_TIME) {
sleepTime = MAX_SLEEP_TIME
} else if (sleepTime < MIN_SLEEP_TIME) {
sleepTime = MIN_SLEEP_TIME
if (sleepTime > MESSAGE_MAX_INTERVAL) {
sleepTime = MESSAGE_MAX_INTERVAL
} else if (sleepTime < MESSAGE_MIN_INTERVAL) {
sleepTime = MESSAGE_MIN_INTERVAL
}
} else {
// 按最快速度发
sleepTime = MIN_SLEEP_TIME
sleepTime = MESSAGE_MIN_INTERVAL
}
this.emitSmoothedMessageTimerId = window.setTimeout(this.emitSmoothedMessages, sleepTime)
},
@@ -336,22 +369,22 @@ export default {
for (let message of messageGroup) {
switch (message.type) {
case constants.MESSAGE_TYPE_TEXT:
case constants.MESSAGE_TYPE_GIFT:
case constants.MESSAGE_TYPE_MEMBER:
case constants.MESSAGE_TYPE_SUPER_CHAT:
this.handleAddMessage(message)
break
case constants.MESSAGE_TYPE_DEL:
this.handleDelMessage(message)
break
case constants.MESSAGE_TYPE_UPDATE:
this.handleUpdateMessage(message)
break
case constants.MESSAGE_TYPE_TEXT:
case constants.MESSAGE_TYPE_GIFT:
case constants.MESSAGE_TYPE_MEMBER:
case constants.MESSAGE_TYPE_SUPER_CHAT:
this.handleAddMessage(message)
break
case constants.MESSAGE_TYPE_DEL:
this.handleDelMessage(message)
break
case constants.MESSAGE_TYPE_UPDATE:
this.handleUpdateMessage(message)
break
}
}
this.maybeResizeScrollContainer(),
this.maybeResizeScrollContainer()
this.flushMessagesBuffer()
this.$nextTick(this.maybeScrollToBottom)
},
@@ -361,11 +394,16 @@ export default {
addTime: new Date() // 添加一个本地时间给Ticker用防止本地时间和服务器时间相差很大的情况
}
this.messagesBuffer.push(message)
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
this.paidMessages.unshift(message)
const MAX_PAID_MESSAGE_NUM = 100
if (this.paidMessages.length > MAX_PAID_MESSAGE_NUM) {
this.paidMessages.splice(MAX_PAID_MESSAGE_NUM, this.paidMessages.length - MAX_PAID_MESSAGE_NUM)
}
}
},
handleDelMessage({id}) {
handleDelMessage({ id }) {
for (let arr of [this.messages, this.paidMessages, this.messagesBuffer]) {
for (let i = 0; i < arr.length; i++) {
if (arr[i].id === id) {
@@ -376,7 +414,7 @@ export default {
}
}
},
handleUpdateMessage({id, newValuesObj}) {
handleUpdateMessage({ id, newValuesObj }) {
// 遍历滚动的消息
this.forEachRecentMessage(999999999, message => {
if (message.id !== id) {
@@ -425,7 +463,8 @@ export default {
}
this.messagesBuffer = []
// 等items高度变化
this.$nextTick(this.showNewMessages)
await this.$nextTick()
this.showNewMessages()
},
showNewMessages() {
let hasScrollBar = this.$refs.items.clientHeight > this.$refs.scroller.clientHeight
@@ -443,7 +482,7 @@ export default {
this.lastSmoothChatMessageAddMs = performance.now()
}
let interval = performance.now() - this.lastSmoothChatMessageAddMs
this.chatRateMs = 0.9 * this.chatRateMs + 0.1 * interval
this.chatRateMs = (0.9 * this.chatRateMs) + (0.1 * interval)
if (this.isSmoothed) {
if (this.chatRateMs < 400) {
this.isSmoothed = false

49
frontend/src/i18n.js Normal file
View File

@@ -0,0 +1,49 @@
import Vue from 'vue'
import VueI18n from 'vue-i18n'
import zh from '@/lang/zh'
let lastSetLocale = 'zh'
let loadedLocales = ['zh']
Vue.use(VueI18n)
export async function setLocale(locale) {
lastSetLocale = locale
if (loadedLocales.indexOf(locale) === -1) {
// eslint-disable-next-line prefer-template
let langModule = await import('@/lang/' + locale)
i18n.setLocaleMessage(locale, langModule.default)
loadedLocales.push(locale)
// 加载完成之前又调用了setLocale这次的不生效
if (locale !== lastSetLocale) {
return
}
}
window.localStorage.lang = i18n.locale = locale
}
export const i18n = new VueI18n({
locale: 'zh',
fallbackLocale: 'zh',
messages: {
zh
}
})
function getDefaultLocale() {
let locale = window.localStorage.lang
if (!locale) {
let lang = navigator.language
if (lang.startsWith('zh')) {
locale = 'zh'
} else if (lang.startsWith('ja')) {
locale = 'ja'
} else {
locale = 'en'
}
}
return locale
}
setLocale(getDefaultLocale())

View File

@@ -1,10 +1,10 @@
export default {
sidebar: {
home: 'Home',
stylegen: 'Style generator',
stylegen: 'Style Generator',
help: 'Help',
projectAddress: 'Project address',
giftRecordOfficial: 'Official Super Chat record',
projectAddress: 'Project Address',
giftRecordOfficial: 'Official Super Chat Record',
},
home: {
roomIdEmpty: "Room ID can't be empty",
@@ -38,15 +38,28 @@ export default {
pinyin: 'Pinyin',
kana: 'Kana',
emoticon: 'Custom Emotes',
emoticonKeyword: 'Emote Code',
emoticonUrl: 'URL',
operation: 'Operation',
addEmoticon: 'Add emote',
emoticonFileTooLarge: 'File size is too large. Max size is 1MB',
roomUrl: 'Room URL',
copy: 'Copy',
enterRoom: 'Enter room',
enterTestRoom: 'Enter test room',
exportConfig: 'Export config',
importConfig: 'Import config',
failedToParseConfig: 'Failed to parse config: '
},
stylegen: {
legacy: 'Classic',
lineLike: 'Line-like',
light: 'light',
dark: 'dark',
outlines: 'Outlines',
showOutlines: 'Show outlines',
outlineSize: 'Outline size',
@@ -56,7 +69,7 @@ export default {
showAvatars: 'Show avatars',
avatarSize: 'Avatar size',
userNames: 'User names',
userNames: 'User Names',
showUserNames: 'Show user names',
font: 'Font',
fontSize: 'Font size',
@@ -67,6 +80,7 @@ export default {
memberColor: 'Member color',
showBadges: 'Show badges',
showColon: 'Show colon after name',
emoticonSize: 'Emoticon size',
messages: 'Messages',
color: 'Color',
@@ -83,7 +97,7 @@ export default {
moderatorMessageBgColor: 'Moderator background color',
memberMessageBgColor: 'Member background color',
scAndNewMember: 'Super Chat / New member',
scAndNewMember: 'Super Chat / New Member',
firstLineFont: 'First line font',
firstLineFontSize: 'First line font size',
firstLineLineHeight: 'First line line height (0 for default)',
@@ -121,5 +135,14 @@ export default {
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'
},
chat: {
moderator: 'moderator',
guardLevel1: 'governor',
guardLevel2: 'admiral',
guardLevel3: 'captain',
sendGift: 'Sent {giftName}x{num}',
membershipTitle: 'New member',
tickerMembership: 'Member'
}
}

View File

@@ -38,15 +38,28 @@ export default {
pinyin: 'ピンイン',
kana: '仮名',
emoticon: 'カスタムスタンプ',
emoticonKeyword: '置き換えるキーワード',
emoticonUrl: 'URL',
operation: '操作',
addEmoticon: 'スタンプを追加',
emoticonFileTooLarge: 'ファイルサイズが大きすぎます。最大サイズは1MBです',
roomUrl: 'ルームのURL',
copy: 'コピー',
enterRoom: 'ルームに入る',
enterTestRoom: 'テストルームに入る',
exportConfig: 'コンフィグの導出',
importConfig: 'コンフィグの導入',
failedToParseConfig: 'コンフィグ解析に失敗しました'
},
stylegen: {
legacy: '古典',
lineLike: 'Line風',
light: '明るい',
dark: '暗い',
outlines: 'アウトライン',
showOutlines: 'アウトラインを表示する',
outlineSize: 'アウトラインのサイズ',
@@ -67,6 +80,7 @@ export default {
memberColor: 'メンバーの色',
showBadges: '勲章を見せる',
showColon: 'ユーザー名の後にコロンが表示されます',
emoticonSize: 'スタンプサイズ',
messages: 'コメント',
color: '色',
@@ -121,5 +135,14 @@ export default {
p3: '3. スタイルジェネレータでお好みのコメント様子を選び、出力したCSSをコピーする',
p4: '4. OBSでブラウザを新規作成する',
p5: '5. プロパティでこぴーしたURLを入力し、カスタムCSSでスタイルジェネレータのCSSを入力する'
},
chat: {
moderator: 'モデレーター',
guardLevel1: '総督',
guardLevel2: '提督',
guardLevel3: '艦長',
sendGift: '{giftName}x{num} を贈りました',
membershipTitle: '新規メンバー',
tickerMembership: 'メンバー'
}
}

View File

@@ -38,15 +38,28 @@ export default {
pinyin: '拼音',
kana: '日文假名',
emoticon: '自定义表情',
emoticonKeyword: '替换关键词',
emoticonUrl: 'URL',
operation: '操作',
addEmoticon: '添加表情',
emoticonFileTooLarge: '文件尺寸太大最大1MB',
roomUrl: '房间URL',
copy: '复制',
enterRoom: '进入房间',
enterTestRoom: '进入测试房间',
exportConfig: '导出配置',
importConfig: '导入配置',
failedToParseConfig: '配置解析失败:'
},
stylegen: {
legacy: '经典',
lineLike: '仿微信',
light: '明亮',
dark: '黑暗',
outlines: '描边',
showOutlines: '显示描边',
outlineSize: '描边尺寸',
@@ -67,6 +80,7 @@ export default {
memberColor: '舰长颜色',
showBadges: '显示勋章',
showColon: '用户名后显示冒号',
emoticonSize: '表情大小',
messages: '消息',
color: '颜色',
@@ -77,7 +91,7 @@ export default {
backgrounds: '背景',
bgColor: '背景色',
useBarsInsteadOfBg: '用条代替背景',
useBarsInsteadOfBg: '用条代替消息背景',
messageBgColor: '消息背景色',
ownerMessageBgColor: '主播消息背景色',
moderatorMessageBgColor: '房管消息背景色',
@@ -104,7 +118,7 @@ export default {
animateIn: '进入动画',
fadeInTime: '淡入时间(毫秒)',
animateOut: '移除旧消息',
animateOutWaitTime: '等待时间(秒)',
animateOutWaitTime: '移除前等待时间(秒)',
fadeOutTime: '淡出时间(毫秒)',
slide: '滑动',
reverseSlide: '反向滑动',
@@ -121,5 +135,14 @@ export default {
p3: '3. 使用样式生成器生成样式复制CSS',
p4: '4. 在OBS中添加浏览器源',
p5: '5. URL处输入之前复制的房间URL自定义CSS处输入之前复制的CSS'
},
chat: {
moderator: '管理员',
guardLevel1: '总督',
guardLevel2: '提督',
guardLevel3: '舰长',
sendGift: '赠送 {giftName}x{num}',
membershipTitle: '新会员',
tickerMembership: '会员'
}
}

View File

@@ -8,46 +8,53 @@
:default-active="$route.path"
>
<el-menu-item index="/">
<i class="el-icon-s-home"></i>{{$t('sidebar.home')}}
<i class="el-icon-s-home"></i>{{ $t('sidebar.home') }}
</el-menu-item>
<el-menu-item :index="$router.resolve({name: 'stylegen'}).href">
<i class="el-icon-brush"></i>{{$t('sidebar.stylegen')}}
<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 :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')}}
<i class="el-icon-share"></i>{{ $t('sidebar.projectAddress') }}
</el-menu-item>
</a>
<a href="http://link.bilibili.com/ctool/vtuber" target="_blank">
<el-menu-item>
<i class="el-icon-share"></i>{{$t('sidebar.giftRecordOfficial')}}
<i class="el-icon-link"></i>{{ $t('sidebar.giftRecordOfficial') }}
</el-menu-item>
</a>
<el-submenu index="null">
<template slot="title">
<i class="el-icon-chat-line-square"></i>Language
</template>
<el-menu-item v-for="{locale, name} in [
{locale: 'zh', name: '中文'},
{locale: 'ja', name: '日本語'},
{locale: 'en', name: 'English'}
]" :key="locale"
@click="onSelectLanguage(locale)"
>{{name}}</el-menu-item>
<el-menu-item v-for="locale in LOCALES" :key="locale.locale" @click="onSelectLanguage(locale.locale)">
<template>{{ locale.name }}</template>
</el-menu-item>
</el-submenu>
</el-menu>
</el-scrollbar>
</template>
<script>
import * as i18n from '@/i18n'
export default {
name: 'Sidebar',
data() {
return {
LOCALES: [
{ locale: 'zh', name: '中文' },
{ locale: 'ja', name: '日本語' },
{ locale: 'en', name: 'English' }
]
}
},
methods: {
onSelectLanguage(locale) {
window.localStorage.lang = this.$i18n.locale = locale
i18n.setLocale(locale)
}
}
}

View File

@@ -1,16 +1,14 @@
<template>
<el-container class="app-wrapper" :class="{mobile: isMobile}">
<el-container class="app-wrapper" :class="{ mobile: isMobile }">
<div v-show="isMobile && !hideSidebar" class="drawer-bg" @click="hideSidebar = true"></div>
<el-aside width="230px" class="sidebar-container" :class="{'hide-sidebar': hideSidebar}">
<el-aside width="230px" class="sidebar-container" :class="{ 'hide-sidebar': hideSidebar }">
<div class="logo-container">
<router-link to="/">
<img src="@/assets/img/logo.png" class="sidebar-logo">
<h1 class="sidebar-title">blivechat</h1>
</router-link>
</div>
<div class="version">
v1.5.1
</div>
<div class="version">{{ APP_VERSION }}</div>
<sidebar></sidebar>
</el-aside>
<el-main>
@@ -23,7 +21,7 @@
</template>
<script>
import Sidebar from './Sidebar.vue'
import Sidebar from './Sidebar'
export default {
name: 'Layout',
@@ -32,6 +30,8 @@ export default {
},
data() {
return {
APP_VERSION: process.env.APP_VERSION,
isMobile: false,
hideSidebar: true
}
@@ -53,7 +53,7 @@ export default {
<style>
html {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "\5FAE\8F6F\96C5\9ED1", "微软雅黑", Arial, sans-serif;
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "\5FAE \8F6F \96C5 \9ED1 ", "微软雅黑", Arial, sans-serif;
}
html, body, #app, .app-wrapper, .sidebar-container {
@@ -62,6 +62,7 @@ html, body, #app, .app-wrapper, .sidebar-container {
body {
margin: 0;
background-color: #f6f8fa;
}
a, a:focus, a:hover {

View File

@@ -1,37 +1,31 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import VueI18n from 'vue-i18n'
import {
Aside, Autocomplete, Badge, Button, Col, ColorPicker, Container, Divider, Form, FormItem, Image,
Input, Main, Menu, MenuItem, Message, Radio, RadioGroup, Row, Scrollbar, Slider, Submenu, Switch,
TabPane, Tabs, Tooltip
Aside, Autocomplete, Badge, Button, ButtonGroup, Card, Col, ColorPicker, Container, Divider, Form, FormItem, Image,
Input, Main, Menu, MenuItem, Message, Option, OptionGroup, Radio, RadioGroup, Row, Select, Scrollbar,
Slider, Submenu, Switch, Table, TableColumn, TabPane, Tabs, Tooltip
} from 'element-ui'
import axios from 'axios'
import App from './App.vue'
import * as i18n from './i18n'
import App from './App'
import Layout from './layout'
import Home from './views/Home.vue'
import Home from './views/Home'
import StyleGenerator from './views/StyleGenerator'
import Help from './views/Help'
import Room from './views/Room.vue'
import NotFound from './views/NotFound.vue'
import Room from './views/Room'
import NotFound from './views/NotFound'
import zh from './lang/zh'
import ja from './lang/ja'
import en from './lang/en'
if (process.env.NODE_ENV === 'development') {
// 开发时使用localhost:12450
axios.defaults.baseURL = 'http://localhost:12450'
}
axios.defaults.timeout = 10 * 1000
Vue.use(VueRouter)
Vue.use(VueI18n)
// 初始化element
Vue.use(Aside)
Vue.use(Autocomplete)
Vue.use(Badge)
Vue.use(Button)
Vue.use(ButtonGroup)
Vue.use(Card)
Vue.use(Col)
Vue.use(ColorPicker)
Vue.use(Container)
@@ -43,13 +37,18 @@ Vue.use(Input)
Vue.use(Main)
Vue.use(Menu)
Vue.use(MenuItem)
Vue.use(Option)
Vue.use(OptionGroup)
Vue.use(Radio)
Vue.use(RadioGroup)
Vue.use(Row)
Vue.use(Select)
Vue.use(Scrollbar)
Vue.use(Slider)
Vue.use(Submenu)
Vue.use(Switch)
Vue.use(Table)
Vue.use(TableColumn)
Vue.use(TabPane)
Vue.use(Tabs)
Vue.use(Tooltip)
@@ -66,37 +65,35 @@ const router = new VueRouter({
path: '/',
component: Layout,
children: [
{path: '', component: Home},
{path: 'stylegen', name: 'stylegen', component: StyleGenerator},
{path: 'help', name: 'help', component: Help}
{ path: '', component: Home },
{ path: 'stylegen', name: 'stylegen', component: StyleGenerator },
{ path: 'help', name: 'help', component: Help }
]
},
{path: '/room/:roomId', name: 'room', component: Room},
{path: '*', component: NotFound}
{
path: '/room/test',
name: 'test_room',
component: Room,
props: route => ({ strConfig: route.query })
},
{
path: '/room/:roomId',
name: 'room',
component: Room,
props(route) {
let roomId = parseInt(route.params.roomId)
if (isNaN(roomId)) {
roomId = null
}
return { roomId, strConfig: route.query }
}
},
{ path: '*', component: NotFound }
]
})
let locale = window.localStorage.lang
if (!locale) {
let lang = navigator.language
if (lang.startsWith('zh')) {
locale = 'zh'
} else if (lang.startsWith('ja')) {
locale = 'ja'
} else {
locale = 'en'
}
}
const i18n = new VueI18n({
locale,
fallbackLocale: 'en',
messages: {
zh, ja, en
}
})
new Vue({
render: h => h(App),
router,
i18n
i18n: i18n.i18n
}).$mount('#app')

View File

@@ -1,4 +1,4 @@
export function mergeConfig (config, defaultConfig) {
export function mergeConfig(config, defaultConfig) {
let res = {}
for (let i in defaultConfig) {
res[i] = i in config ? config[i] : defaultConfig[i]
@@ -6,14 +6,14 @@ export function mergeConfig (config, defaultConfig) {
return res
}
export function toBool (val) {
export function toBool(val) {
if (typeof val === 'string') {
return ['false', 'no', 'off', '0', ''].indexOf(val.toLowerCase()) === -1
}
return !!val
return Boolean(val)
}
export function toInt (val, _default) {
export function toInt(val, _default) {
let res = parseInt(val)
if (isNaN(res)) {
res = _default
@@ -21,19 +21,19 @@ export function toInt (val, _default) {
return res
}
export function formatCurrency (price) {
export function formatCurrency(price) {
return new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: price < 100 ? 2 : 0
}).format(price)
}
export function getTimeTextHourMin (date) {
export function getTimeTextHourMin(date) {
let hour = date.getHours()
let min = ('00' + date.getMinutes()).slice(-2)
let min = `00${date.getMinutes()}`.slice(-2)
return `${hour}:${min}`
}
export function getUuid4Hex () {
export function getUuid4Hex() {
let chars = []
for (let i = 0; i < 32; i++) {
let char = Math.floor(Math.random() * 16).toString(16)

View File

@@ -2,21 +2,21 @@ export const DICT_PINYIN = 'pinyin'
export const DICT_KANA = 'kana'
export class PronunciationConverter {
constructor () {
constructor() {
this.pronunciationMap = new Map()
}
async loadDict (dictName) {
async loadDict(dictName) {
let promise
switch (dictName) {
case DICT_PINYIN:
promise = import('./dictPinyin')
break
case DICT_KANA:
promise = import('./dictKana')
break
default:
return
case DICT_PINYIN:
promise = import('./dictPinyin')
break
case DICT_KANA:
promise = import('./dictKana')
break
default:
return
}
let dictTxt = (await promise).default
@@ -30,7 +30,7 @@ export class PronunciationConverter {
this.pronunciationMap = pronunciationMap
}
getPronunciation (text) {
getPronunciation(text) {
let res = []
let lastHasPronunciation = null
for (let char of text) {

View File

@@ -0,0 +1,58 @@
export class Trie {
constructor() {
this._root = this._createNode()
}
_createNode() {
return {
children: {}, // char -> node
value: null
}
}
set(key, value) {
if (key === '') {
throw new Error('key is empty')
}
let node = this._root
for (let char of key) {
let nextNode = node.children[char]
if (nextNode === undefined) {
nextNode = node.children[char] = this._createNode()
}
node = nextNode
}
node.value = value
}
get(key) {
let node = this._root
for (let char of key) {
let nextNode = node.children[char]
if (nextNode === undefined) {
return null
}
node = nextNode
}
return node.value
}
has(key) {
return this.get(key) !== null
}
greedyMatch(str) {
let node = this._root
for (let char of str) {
let nextNode = node.children[char]
if (nextNode === undefined) {
return null
}
if (nextNode.value !== null) {
return nextNode.value
}
node = nextNode
}
return null
}
}

View File

@@ -1,18 +1,19 @@
<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>
<h1>{{ $t('help.help') }}</h1>
<p>{{ $t('help.p1') }}</p>
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-1.png"></el-image></p>
<p>{{ $t('help.p2') }}</p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-2.png"></el-image></p>
<p>{{ $t('help.p3') }}</p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-3.png"></el-image></p>
<p>{{ $t('help.p4') }}</p>
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-4.png"></el-image></p>
<p>{{ $t('help.p5') }}</p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-5.png"></el-image></p>
<p><br><br><br><br><br><br><br><br>--------------------------------------------------------------------------------------------------------</p>
<p>喜欢的话可以推荐给别人专栏求支持_(:з)_ <a href="https://www.bilibili.com/read/cv4594365" target="_blank">https://www.bilibili.com/read/cv4594365</a></p>
<p>喜欢的话可以推荐给别人专栏求支持_(:з)_ <a href="https://www.bilibili.com/read/cv4594365" target="_blank">https://www.bilibili.com/read/cv4594365</a></p>
<p>配置官方翻译接口傻瓜式教程 <a href="https://www.bilibili.com/read/cv14663633" target="_blank">https://www.bilibili.com/read/cv14663633</a>。注意必须下载到本地才能改后台配置,本地使用方法看 <a href="https://github.com/xfgryujk/blivechat#%E4%BD%BF%E7%94%A8%E6%96%B9%E6%B3%95" target="_blank">项目地址中的使用方法说明</a></p>
</div>
</template>
@@ -21,3 +22,13 @@ export default {
name: 'Help'
}
</script>
<style scoped>
.img-container {
text-align: center;
}
.img-container.large-img .el-image {
height: 80vh;
}
</style>

View File

@@ -1,98 +1,176 @@
<template>
<el-form :model="form" ref="form" label-width="150px" :rules="{
roomId: [
{required: true, message: $t('home.roomIdEmpty'), trigger: 'blur'},
{type: 'integer', min: 1, message: $t('home.roomIdInteger'), trigger: 'blur'}
]
}">
<el-tabs>
<el-tab-pane :label="$t('home.general')">
<el-form-item :label="$t('home.roomId')" required prop="roomId">
<el-input v-model.number="form.roomId" type="number" min="1"></el-input>
</el-form-item>
<el-form-item :label="$t('home.showDanmaku')">
<el-switch v-model="form.showDanmaku"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.showGift')">
<el-switch v-model="form.showGift"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.showGiftName')">
<el-switch v-model="form.showGiftName"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.mergeSimilarDanmaku')">
<el-switch v-model="form.mergeSimilarDanmaku"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.mergeGift')">
<el-switch v-model="form.mergeGift"></el-switch>
</el-form-item>
<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.maxNumber')">
<el-input v-model.number="form.maxNumber" type="number" min="1"></el-input>
</el-form-item>
</el-tab-pane>
<div>
<p>
<el-form :model="form" ref="form" label-width="150px" :rules="{
roomId: [
{required: true, message: $t('home.roomIdEmpty'), trigger: 'blur'},
{type: 'integer', min: 1, message: $t('home.roomIdInteger'), trigger: 'blur'}
]
}">
<el-tabs type="border-card">
<el-tab-pane :label="$t('home.general')">
<el-form-item :label="$t('home.roomId')" required prop="roomId">
<el-input v-model.number="form.roomId" type="number" min="1"></el-input>
</el-form-item>
<el-row :gutter="20">
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.showDanmaku')">
<el-switch v-model="form.showDanmaku"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.showGift')">
<el-switch v-model="form.showGift"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.showGiftName')">
<el-switch v-model="form.showGiftName"></el-switch>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.mergeSimilarDanmaku')">
<el-switch v-model="form.mergeSimilarDanmaku"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.mergeGift')">
<el-switch v-model="form.mergeGift"></el-switch>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="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-col>
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.maxNumber')">
<el-input v-model.number="form.maxNumber" type="number" min="1"></el-input>
</el-form-item>
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane :label="$t('home.block')">
<el-form-item :label="$t('home.giftDanmaku')">
<el-switch v-model="form.blockGiftDanmaku"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.blockLevel')">
<el-slider v-model="form.blockLevel" show-input :min="0" :max="60"></el-slider>
</el-form-item>
<el-form-item :label="$t('home.informalUser')">
<el-switch v-model="form.blockNewbie"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.unverifiedUser')">
<el-switch v-model="form.blockNotMobileVerified"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.blockKeywords')">
<el-input v-model="form.blockKeywords" type="textarea" :rows="5" :placeholder="$t('home.onePerLine')"></el-input>
</el-form-item>
<el-form-item :label="$t('home.blockUsers')">
<el-input v-model="form.blockUsers" type="textarea" :rows="5" :placeholder="$t('home.onePerLine')"></el-input>
</el-form-item>
<el-form-item :label="$t('home.blockMedalLevel')">
<el-slider v-model="form.blockMedalLevel" show-input :min="0" :max="40"></el-slider>
</el-form-item>
</el-tab-pane>
<el-tab-pane :label="$t('home.block')">
<el-row :gutter="20">
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.giftDanmaku')">
<el-switch v-model="form.blockGiftDanmaku"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.informalUser')">
<el-switch v-model="form.blockNewbie"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.unverifiedUser')">
<el-switch v-model="form.blockNotMobileVerified"></el-switch>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('home.blockLevel')">
<el-slider v-model="form.blockLevel" show-input :min="0" :max="60"></el-slider>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('home.blockMedalLevel')">
<el-slider v-model="form.blockMedalLevel" show-input :min="0" :max="40"></el-slider>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="$t('home.blockKeywords')">
<el-input v-model="form.blockKeywords" type="textarea" :rows="5" :placeholder="$t('home.onePerLine')"></el-input>
</el-form-item>
<el-form-item :label="$t('home.blockUsers')">
<el-input v-model="form.blockUsers" type="textarea" :rows="5" :placeholder="$t('home.onePerLine')"></el-input>
</el-form-item>
</el-tab-pane>
<el-tab-pane :label="$t('home.advanced')">
<el-form-item :label="$t('home.relayMessagesByServer')">
<el-switch v-model="form.relayMessagesByServer"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.autoTranslate')">
<el-switch v-model="form.autoTranslate" :disabled="!serverConfig.enableTranslate || !form.relayMessagesByServer"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.giftUsernamePronunciation')">
<el-radio-group v-model="form.giftUsernamePronunciation">
<el-radio label="">{{$t('home.dontShow')}}</el-radio>
<el-radio label="pinyin">{{$t('home.pinyin')}}</el-radio>
<el-radio label="kana">{{$t('home.kana')}}</el-radio>
</el-radio-group>
</el-form-item>
</el-tab-pane>
</el-tabs>
<el-tab-pane :label="$t('home.advanced')">
<el-row :gutter="20">
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.relayMessagesByServer')">
<el-switch v-model="form.relayMessagesByServer"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.autoTranslate')">
<el-switch v-model="form.autoTranslate" :disabled="!serverConfig.enableTranslate || !form.relayMessagesByServer"></el-switch>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="$t('home.giftUsernamePronunciation')">
<el-radio-group v-model="form.giftUsernamePronunciation">
<el-radio label="">{{$t('home.dontShow')}}</el-radio>
<el-radio label="pinyin">{{$t('home.pinyin')}}</el-radio>
<el-radio label="kana">{{$t('home.kana')}}</el-radio>
</el-radio-group>
</el-form-item>
</el-tab-pane>
<el-divider></el-divider>
<el-form-item :label="$t('home.roomUrl')">
<el-input ref="roomUrlInput" readonly :value="obsRoomUrl" 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" :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>
</el-form-item>
</el-form>
<el-tab-pane :label="$t('home.emoticon')">
<el-table :data="form.emoticons">
<el-table-column prop="keyword" :label="$t('home.emoticonKeyword')" width="170">
<template slot-scope="scope">
<el-input v-model="scope.row.keyword"></el-input>
</template>
</el-table-column>
<el-table-column prop="url" :label="$t('home.emoticonUrl')">
<template slot-scope="scope">
<el-input v-model="scope.row.url"></el-input>
</template>
</el-table-column>
<el-table-column :label="$t('home.operation')" width="170">
<template slot-scope="scope">
<el-button-group>
<el-button type="primary" icon="el-icon-upload2" :disabled="!serverConfig.enableUploadFile"
@click="uploadEmoticon(scope.row)"
></el-button>
<el-button type="danger" icon="el-icon-minus" @click="delEmoticon(scope.$index)"></el-button>
</el-button-group>
</template>
</el-table-column>
</el-table>
<p>
<el-button type="primary" icon="el-icon-plus" @click="addEmoticon">{{$t('home.addEmoticon')}}</el-button>
</p>
</el-tab-pane>
</el-tabs>
</el-form>
</p>
<p>
<el-card>
<el-form :model="form" label-width="150px">
<el-form-item :label="$t('home.roomUrl')">
<el-input ref="roomUrlInput" readonly :value="obsRoomUrl" style="width: calc(100% - 8em); margin-right: 1em;"></el-input>
<el-button type="primary" icon="el-icon-copy-document" @click="copyUrl"></el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" :disabled="!roomUrl" @click="enterRoom">{{$t('home.enterRoom')}}</el-button>
<el-button @click="enterTestRoom">{{$t('home.enterTestRoom')}}</el-button>
<el-button @click="exportConfig">{{$t('home.exportConfig')}}</el-button>
<el-button @click="importConfig">{{$t('home.importConfig')}}</el-button>
</el-form-item>
</el-form>
</el-card>
</p>
</div>
</template>
<script>
import _ from 'lodash'
import axios from 'axios'
import download from 'downloadjs'
import {mergeConfig} from '@/utils'
import { mergeConfig } from '@/utils'
import * as mainApi from '@/api/main'
import * as chatConfig from '@/api/chatConfig'
export default {
@@ -101,23 +179,18 @@ export default {
return {
serverConfig: {
enableTranslate: true,
enableUploadFile: true,
loaderUrl: ''
},
form: {
roomId: parseInt(window.localStorage.roomId || '1'),
...chatConfig.getLocalConfig()
...chatConfig.getLocalConfig(),
roomId: parseInt(window.localStorage.roomId || '1')
}
}
},
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}`
return this.getRoomUrl(false)
},
obsRoomUrl() {
if (this.roomUrl === '') {
@@ -143,14 +216,71 @@ export default {
methods: {
async updateServerConfig() {
try {
this.serverConfig = (await axios.get('/api/server_info')).data.config
this.serverConfig = (await mainApi.getServerInfo()).config
} catch (e) {
this.$message.error('Failed to fetch server information: ' + e)
this.$message.error(`Failed to fetch server information: ${e}`)
throw e
}
},
addEmoticon() {
this.form.emoticons.push({
keyword: '[Kappa]',
url: ''
})
},
delEmoticon(index) {
this.form.emoticons.splice(index, 1)
},
uploadEmoticon(emoticon) {
let input = document.createElement('input')
input.type = 'file'
input.accept = 'image/png, image/jpeg, image/jpg, image/gif'
input.onchange = async() => {
let file = input.files[0]
if (file.size > 1024 * 1024) {
this.$message.error(this.$t('home.emoticonFileTooLarge'))
return
}
let res
try {
res = await mainApi.uploadEmoticon(file)
} catch (e) {
this.$message.error(`Failed to upload: ${e}`)
throw e
}
emoticon.url = res.url
}
input.click()
},
enterRoom() {
window.open(this.roomUrl, `room ${this.form.roomId}`, 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
},
enterTestRoom() {
window.open(this.getRoomUrl(true), 'test room', 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
},
getRoomUrl(isTestRoom) {
if (!isTestRoom && this.form.roomId === '') {
return ''
}
let query = {
...this.form,
emoticons: JSON.stringify(this.form.emoticons),
lang: this.$i18n.locale
}
delete query.roomId
let resolved
if (isTestRoom) {
resolved = this.$router.resolve({ name: 'test_room', query })
} else {
resolved = this.$router.resolve({ name: 'room', params: { roomId: this.form.roomId }, query })
}
return `${window.location.protocol}//${window.location.host}${resolved.href}`
},
copyUrl() {
this.$refs.roomUrlInput.select()
document.execCommand('Copy')
@@ -173,19 +303,20 @@ export default {
this.$message.error(this.$t('home.failedToParseConfig') + e)
return
}
cfg = mergeConfig(cfg, chatConfig.DEFAULT_CONFIG)
this.form = {roomId: this.form.roomId, ...cfg}
this.importConfigFromObj(cfg)
}
reader.readAsText(input.files[0])
}
input.click()
},
importConfigFromObj(cfg) {
cfg = mergeConfig(cfg, chatConfig.deepCloneDefaultConfig())
chatConfig.sanitizeConfig(cfg)
this.form = {
...cfg,
roomId: this.form.roomId
}
}
}
}
</script>
<style scoped>
.el-form {
max-width: 800px;
}
</style>

View File

@@ -3,9 +3,12 @@
</template>
<script>
import {mergeConfig, toBool, toInt} from '@/utils'
import * as i18n from '@/i18n'
import { mergeConfig, toBool, toInt } from '@/utils'
import * as trie from '@/utils/trie'
import * as pronunciation from '@/utils/pronunciation'
import * as chatConfig from '@/api/chatConfig'
import ChatClientTest from '@/api/chat/ChatClientTest'
import ChatClientDirect from '@/api/chat/ChatClientDirect'
import ChatClientRelay from '@/api/chat/ChatClientRelay'
import ChatRenderer from '@/components/ChatRenderer'
@@ -16,19 +19,52 @@ export default {
components: {
ChatRenderer
},
props: {
roomId: {
type: Number,
default: null
},
strConfig: {
type: Object,
default: () => ({})
}
},
data() {
return {
config: {...chatConfig.DEFAULT_CONFIG},
config: chatConfig.deepCloneDefaultConfig(),
chatClient: null,
pronunciationConverter: null
}
},
computed: {
blockKeywords() {
return this.config.blockKeywords.split('\n').filter(val => val)
blockKeywordsTrie() {
let blockKeywords = this.config.blockKeywords.split('\n')
let res = new trie.Trie()
for (let keyword of blockKeywords) {
if (keyword !== '') {
res.set(keyword, true)
}
}
return res
},
blockUsers() {
return this.config.blockUsers.split('\n').filter(val => val)
blockUsersTrie() {
let blockUsers = this.config.blockUsers.split('\n')
let res = new trie.Trie()
for (let user of blockUsers) {
if (user !== '') {
res.set(user, true)
}
}
return res
},
emoticonsTrie() {
let res = new trie.Trie()
for (let emoticon of this.config.emoticons) {
if (emoticon.keyword !== '' && emoticon.url !== '') {
res.set(emoticon.keyword, emoticon)
}
}
return res
}
},
mounted() {
@@ -52,14 +88,19 @@ export default {
},
methods: {
initConfig() {
let locale = this.strConfig.lang
if (locale) {
i18n.setLocale(locale)
}
let cfg = {}
// 留空的使用默认值
for (let i in this.$route.query) {
if (this.$route.query[i] !== '') {
cfg[i] = this.$route.query[i]
for (let i in this.strConfig) {
if (this.strConfig[i] !== '') {
cfg[i] = this.strConfig[i]
}
}
cfg = mergeConfig(cfg, chatConfig.DEFAULT_CONFIG)
cfg = mergeConfig(cfg, chatConfig.deepCloneDefaultConfig())
cfg.minGiftPrice = toInt(cfg.minGiftPrice, chatConfig.DEFAULT_CONFIG.minGiftPrice)
cfg.showDanmaku = toBool(cfg.showDanmaku)
@@ -68,22 +109,39 @@ export default {
cfg.mergeSimilarDanmaku = toBool(cfg.mergeSimilarDanmaku)
cfg.mergeGift = toBool(cfg.mergeGift)
cfg.maxNumber = toInt(cfg.maxNumber, chatConfig.DEFAULT_CONFIG.maxNumber)
cfg.blockGiftDanmaku = toBool(cfg.blockGiftDanmaku)
cfg.blockLevel = toInt(cfg.blockLevel, chatConfig.DEFAULT_CONFIG.blockLevel)
cfg.blockNewbie = toBool(cfg.blockNewbie)
cfg.blockNotMobileVerified = toBool(cfg.blockNotMobileVerified)
cfg.blockMedalLevel = toInt(cfg.blockMedalLevel, chatConfig.DEFAULT_CONFIG.blockMedalLevel)
cfg.relayMessagesByServer = toBool(cfg.relayMessagesByServer)
cfg.autoTranslate = toBool(cfg.autoTranslate)
cfg.emoticons = this.toObjIfJson(cfg.emoticons)
chatConfig.sanitizeConfig(cfg)
this.config = cfg
},
toObjIfJson(str) {
if (typeof str !== 'string') {
return str
}
try {
return JSON.parse(str)
} catch {
return {}
}
},
initChatClient() {
let roomId = parseInt(this.$route.params.roomId)
if (!this.config.relayMessagesByServer) {
this.chatClient = new ChatClientDirect(roomId)
if (this.roomId === null) {
this.chatClient = new ChatClientTest()
} else {
this.chatClient = new ChatClientRelay(roomId, this.config.autoTranslate)
if (!this.config.relayMessagesByServer) {
this.chatClient = new ChatClientDirect(this.roomId)
} else {
this.chatClient = new ChatClientRelay(this.roomId, this.config.autoTranslate)
}
}
this.chatClient.onAddText = this.onAddText
this.chatClient.onAddGift = this.onAddGift
@@ -94,6 +152,13 @@ export default {
this.chatClient.start()
},
start() {
this.chatClient.start()
},
stop() {
this.chatClient.stop()
},
onAddText(data) {
if (!this.config.showDanmaku || !this.filterTextMessage(data) || this.mergeSimilarText(data.content)) {
return
@@ -106,6 +171,7 @@ export default {
authorName: data.authorName,
authorType: data.authorType,
content: data.content,
richContent: this.getRichContent(data),
privilegeType: data.privilegeType,
repeated: 1,
translation: data.translation
@@ -148,7 +214,7 @@ export default {
authorName: data.authorName,
authorNamePronunciation: this.getPronunciation(data.authorName),
privilegeType: data.privilegeType,
title: 'New member'
title: this.$t('chat.membershipTitle')
}
this.$refs.renderer.addMessage(message)
},
@@ -173,15 +239,13 @@ export default {
this.$refs.renderer.addMessage(message)
},
onDelSuperChat(data) {
for (let id of data.ids) {
this.$refs.renderer.delMessage(id)
}
this.$refs.renderer.delMessages(data.ids)
},
onUpdateTranslation(data) {
if (!this.config.autoTranslate) {
return
}
this.$refs.renderer.updateMessage(data.id, {translation: data.translation})
this.$refs.renderer.updateMessage(data.id, { translation: data.translation })
},
filterTextMessage(data) {
@@ -196,24 +260,27 @@ export default {
} else if (this.config.blockMedalLevel > 0 && data.medalLevel < this.config.blockMedalLevel) {
return false
}
return this.filterSuperChatMessage(data)
return this.filterByContent(data.content) && this.filterByAuthorName(data.authorName)
},
filterSuperChatMessage(data) {
for (let keyword of this.blockKeywords) {
if (data.content.indexOf(keyword) !== -1) {
return false
}
}
return this.filterNewMemberMessage(data)
return this.filterByContent(data.content) && this.filterByAuthorName(data.authorName)
},
filterNewMemberMessage(data) {
for (let user of this.blockUsers) {
if (data.authorName === user) {
return this.filterByAuthorName(data.authorName)
},
filterByContent(content) {
let blockKeywordsTrie = this.blockKeywordsTrie
for (let i = 0; i < content.length; i++) {
let remainContent = content.substring(i)
if (blockKeywordsTrie.greedyMatch(remainContent) !== null) {
return false
}
}
return true
},
filterByAuthorName(authorName) {
return !this.blockUsersTrie.has(authorName)
},
mergeSimilarText(content) {
if (!this.config.mergeSimilarDanmaku) {
return false
@@ -231,6 +298,66 @@ export default {
return ''
}
return this.pronunciationConverter.getPronunciation(text)
},
getRichContent(data) {
let richContent = []
// B站官方表情
if (data.emoticon !== null) {
richContent.push({
type: constants.CONTENT_TYPE_IMAGE,
text: data.content,
url: data.emoticon
})
return richContent
}
// 没有自定义表情,只能是文本
if (this.config.emoticons.length === 0) {
richContent.push({
type: constants.CONTENT_TYPE_TEXT,
text: data.content
})
return richContent
}
// 可能含有自定义表情,需要解析
let emoticonsTrie = this.emoticonsTrie
let startPos = 0
let pos = 0
while (pos < data.content.length) {
let remainContent = data.content.substring(pos)
let matchEmoticon = emoticonsTrie.greedyMatch(remainContent)
if (matchEmoticon === null) {
pos++
continue
}
// 加入之前的文本
if (pos !== startPos) {
richContent.push({
type: constants.CONTENT_TYPE_TEXT,
text: data.content.slice(startPos, pos)
})
}
// 加入表情
richContent.push({
type: constants.CONTENT_TYPE_IMAGE,
text: matchEmoticon.keyword,
url: matchEmoticon.url
})
pos += matchEmoticon.keyword.length
startPos = pos
}
// 加入尾部的文本
if (pos !== startPos) {
richContent.push({
type: constants.CONTENT_TYPE_TEXT,
text: data.content.slice(startPos, pos)
})
}
return richContent
}
}
}

View File

@@ -0,0 +1,33 @@
<template>
<el-select :value="value" @input="val => $emit('input', val)" filterable allow-create default-first-option>
<el-option-group>
<el-option v-for="font in LOCAL_FONTS" :key="font" :value="font"></el-option>
</el-option-group>
<el-option-group>
<el-option v-for="font in NETWORK_FONTS" :key="font" :value="font"></el-option>
</el-option-group>
</el-select>
</template>
<script>
import * as fonts from './fonts'
export default {
name: 'FontSelect',
props: {
value: String
},
data() {
return {
LOCAL_FONTS: fonts.LOCAL_FONTS,
NETWORK_FONTS: fonts.NETWORK_FONTS
}
}
}
</script>
<style scoped>
.el-select {
width: 100%
}
</style>

View File

@@ -0,0 +1,702 @@
<template>
<div>
<el-form label-width="150px" size="mini">
<h3>{{ $t('stylegen.outlines') }}</h3>
<el-card shadow="never">
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.showOutlines')">
<el-switch v-model="form.showOutlines"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.outlineColor')">
<el-color-picker v-model="form.outlineColor"></el-color-picker>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="$t('stylegen.outlineSize')">
<el-input v-model.number="form.outlineSize" type="number" min="0"></el-input>
</el-form-item>
</el-card>
<h3>{{ $t('stylegen.avatars') }}</h3>
<el-card shadow="never">
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.showAvatars')">
<el-switch v-model="form.showAvatars"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.avatarSize')">
<el-input v-model.number="form.avatarSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
</el-card>
<h3>{{ $t('stylegen.userNames') }}</h3>
<el-card shadow="never">
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.showUserNames')">
<el-switch v-model="form.showUserNames"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.font')">
<font-select v-model="form.userNameFont"></font-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.fontSize')">
<el-input v-model.number="form.userNameFontSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.lineHeight')">
<el-input v-model.number="form.userNameLineHeight" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.normalColor')">
<el-color-picker v-model="form.userNameColor"></el-color-picker>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.memberColor')">
<el-color-picker v-model="form.memberUserNameColor"></el-color-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.moderatorColor')">
<el-color-picker v-model="form.moderatorUserNameColor"></el-color-picker>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.ownerColor')">
<el-color-picker v-model="form.ownerUserNameColor"></el-color-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.showBadges')">
<el-switch v-model="form.showBadges"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.showColon')">
<el-switch v-model="form.showColon"></el-switch>
</el-form-item>
</el-col>
</el-row>
</el-card>
<h3>{{ $t('stylegen.messages') }}</h3>
<el-card shadow="never">
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.font')">
<font-select v-model="form.messageFont"></font-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.color')">
<el-color-picker v-model="form.messageColor"></el-color-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.fontSize')">
<el-input v-model.number="form.messageFontSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.lineHeight')">
<el-input v-model.number="form.messageLineHeight" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="$t('stylegen.onNewLine')">
<el-switch v-model="form.messageOnNewLine"></el-switch>
</el-form-item>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.emoticonSize')">
<el-input v-model.number="form.emoticonSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
</el-card>
<h3>{{ $t('stylegen.time') }}</h3>
<el-card shadow="never">
<el-form-item :label="$t('stylegen.showTime')">
<el-switch v-model="form.showTime"></el-switch>
</el-form-item>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.font')">
<font-select v-model="form.timeFont"></font-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.color')">
<el-color-picker v-model="form.timeColor"></el-color-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.fontSize')">
<el-input v-model.number="form.timeFontSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.lineHeight')">
<el-input v-model.number="form.timeLineHeight" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
</el-card>
<h3>{{ $t('stylegen.backgrounds') }}</h3>
<el-card shadow="never">
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.bgColor')">
<el-color-picker v-model="form.bgColor" show-alpha></el-color-picker>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.useBarsInsteadOfBg')">
<el-switch v-model="form.useBarsInsteadOfBg"></el-switch>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.messageBgColor')">
<el-color-picker v-model="form.messageBgColor" show-alpha></el-color-picker>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.memberMessageBgColor')">
<el-color-picker v-model="form.memberMessageBgColor" show-alpha></el-color-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.moderatorMessageBgColor')">
<el-color-picker v-model="form.moderatorMessageBgColor" show-alpha></el-color-picker>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.ownerMessageBgColor')">
<el-color-picker v-model="form.ownerMessageBgColor" show-alpha></el-color-picker>
</el-form-item>
</el-col>
</el-row>
</el-card>
<h3>{{ $t('stylegen.scAndNewMember') }}</h3>
<el-card shadow="never">
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.firstLineFont')">
<font-select v-model="form.firstLineFont"></font-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.firstLineColor')">
<el-color-picker v-model="form.firstLineColor"></el-color-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.firstLineFontSize')">
<el-input v-model.number="form.firstLineFontSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.firstLineLineHeight')">
<el-input v-model.number="form.firstLineLineHeight" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-divider></el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.secondLineFont')">
<font-select v-model="form.secondLineFont"></font-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.secondLineColor')">
<el-color-picker v-model="form.secondLineColor"></el-color-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.secondLineFontSize')">
<el-input v-model.number="form.secondLineFontSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.secondLineLineHeight')">
<el-input v-model.number="form.secondLineLineHeight" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-divider></el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.scContentLineFont')">
<font-select v-model="form.scContentFont"></font-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.scContentLineColor')">
<el-color-picker v-model="form.scContentColor"></el-color-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.scContentLineFontSize')">
<el-input v-model.number="form.scContentFontSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.scContentLineLineHeight')">
<el-input v-model.number="form.scContentLineHeight" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-divider></el-divider>
<el-form-item :label="$t('stylegen.showNewMemberBg')">
<el-switch v-model="form.showNewMemberBg"></el-switch>
</el-form-item>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.showScTicker')">
<el-switch v-model="form.showScTicker"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.showOtherThings')">
<el-switch v-model="form.showOtherThings"></el-switch>
</el-form-item>
</el-col>
</el-row>
</el-card>
<h3>{{ $t('stylegen.animation') }}</h3>
<el-card shadow="never">
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.animateIn')">
<el-switch v-model="form.animateIn"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.fadeInTime')">
<el-input v-model.number="form.fadeInTime" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.animateOut')">
<el-switch v-model="form.animateOut"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.fadeOutTime')">
<el-input v-model.number="form.fadeOutTime" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="$t('stylegen.animateOutWaitTime')">
<el-input v-model.number="form.animateOutWaitTime" type="number" min="0"></el-input>
</el-form-item>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.slide')">
<el-switch v-model="form.slide"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.reverseSlide')">
<el-switch v-model="form.reverseSlide"></el-switch>
</el-form-item>
</el-col>
</el-row>
</el-card>
</el-form>
</div>
</template>
<script>
import _ from 'lodash'
import FontSelect from './FontSelect'
import * as common from './common'
import { mergeConfig } from '@/utils'
export const DEFAULT_CONFIG = {
showOutlines: true,
outlineSize: 2,
outlineColor: '#000000',
showAvatars: true,
avatarSize: 24,
showUserNames: true,
userNameFont: 'Changa One',
userNameFontSize: 20,
userNameLineHeight: 0,
userNameColor: '#cccccc',
ownerUserNameColor: '#ffd600',
moderatorUserNameColor: '#5e84f1',
memberUserNameColor: '#0f9d58',
showBadges: true,
showColon: true,
messageFont: 'Imprima',
messageFontSize: 18,
messageLineHeight: 0,
messageColor: '#ffffff',
messageOnNewLine: false,
emoticonSize: 18,
showTime: false,
timeFont: 'Imprima',
timeFontSize: 16,
timeLineHeight: 0,
timeColor: '#999999',
bgColor: 'rgba(0, 0, 0, 0)',
useBarsInsteadOfBg: false,
messageBgColor: 'rgba(204, 204, 204, 0)',
ownerMessageBgColor: 'rgba(255, 214, 0, 0)',
moderatorMessageBgColor: 'rgba(94, 132, 241, 0)',
memberMessageBgColor: 'rgba(15, 157, 88, 0)',
firstLineFont: 'Changa One',
firstLineFontSize: 20,
firstLineLineHeight: 0,
firstLineColor: '#ffffff',
secondLineFont: 'Imprima',
secondLineFontSize: 18,
secondLineLineHeight: 0,
secondLineColor: '#ffffff',
scContentFont: 'Imprima',
scContentFontSize: 18,
scContentLineHeight: 0,
scContentColor: '#ffffff',
showNewMemberBg: true,
showScTicker: false,
showOtherThings: true,
animateIn: false,
fadeInTime: 200, // ms
animateOut: false,
animateOutWaitTime: 30, // s
fadeOutTime: 200, // ms
slide: false,
reverseSlide: false
}
export default {
name: 'Legacy',
components: {
FontSelect
},
props: {
value: String
},
data() {
return {
form: this.loadConfig()
}
},
computed: {
result() {
return `${this.importStyle}
${common.COMMON_STYLE}
${this.paddingStyle}
${this.outlineStyle}
${this.avatarStyle}
${this.userNameStyle}
${this.messageStyle}
${this.timeStyle}
${this.backgroundStyle}
${this.scAndNewMemberStyle}
${this.animationStyle}
`
},
importStyle() {
let allFonts = []
for (let name of ['userNameFont', 'messageFont', 'timeFont', 'firstLineFont', 'secondLineFont', 'scContentFont']) {
allFonts.push(this.form[name])
}
return common.getImportStyle(allFonts)
},
paddingStyle() {
return `/* Reduce side padding */
yt-live-chat-text-message-renderer {
padding-left: ${this.form.useBarsInsteadOfBg ? 20 : 4}px !important;
padding-right: 4px !important;
}`
},
outlineStyle() {
return `/* Outlines */
yt-live-chat-renderer * {
${this.showOutlinesStyle}
font-family: "${common.cssEscapeStr(this.form.messageFont)}"${common.FALLBACK_FONTS};
font-size: ${this.form.messageFontSize}px !important;
line-height: ${this.form.messageLineHeight || this.form.messageFontSize}px !important;
}`
},
showOutlinesStyle() {
if (!this.form.showOutlines || !this.form.outlineSize) {
return ''
}
let shadow = []
for (let x = -this.form.outlineSize; x <= this.form.outlineSize; x += Math.ceil(this.form.outlineSize / 4)) {
for (let y = -this.form.outlineSize; y <= this.form.outlineSize; y += Math.ceil(this.form.outlineSize / 4)) {
shadow.push(`${x}px ${y}px ${this.form.outlineColor}`)
}
}
return `text-shadow: ${shadow.join(', ')};`
},
avatarStyle() {
return common.getAvatarStyle(this.form)
},
userNameStyle() {
return `/* Channel names */
yt-live-chat-text-message-renderer #author-name[type="owner"],
yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="owner"] {
${this.form.ownerUserNameColor ? `color: ${this.form.ownerUserNameColor} !important;` : ''}
}
yt-live-chat-text-message-renderer #author-name[type="moderator"],
yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="moderator"] {
${this.form.moderatorUserNameColor ? `color: ${this.form.moderatorUserNameColor} !important;` : ''}
}
yt-live-chat-text-message-renderer #author-name[type="member"],
yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="member"] {
${this.form.memberUserNameColor ? `color: ${this.form.memberUserNameColor} !important;` : ''}
}
yt-live-chat-text-message-renderer #author-name {
${this.form.showUserNames ? '' : 'display: none !important;'}
${this.form.userNameColor ? `color: ${this.form.userNameColor} !important;` : ''}
font-family: "${common.cssEscapeStr(this.form.userNameFont)}"${common.FALLBACK_FONTS};
font-size: ${this.form.userNameFontSize}px !important;
line-height: ${this.form.userNameLineHeight || this.form.userNameFontSize}px !important;
}
${!this.form.showColon ? '' : `/* Show colon */
yt-live-chat-text-message-renderer #author-name::after {
content: ":";
margin-left: ${this.form.outlineSize}px;
}`}
/* Hide badges */
yt-live-chat-text-message-renderer #chat-badges {
${this.form.showBadges ? '' : 'display: none !important;'}
vertical-align: text-top !important;
}`
},
messageStyle() {
return `/* Messages */
yt-live-chat-text-message-renderer #message,
yt-live-chat-text-message-renderer #message * {
${this.form.messageColor ? `color: ${this.form.messageColor} !important;` : ''}
font-family: "${common.cssEscapeStr(this.form.messageFont)}"${common.FALLBACK_FONTS};
font-size: ${this.form.messageFontSize}px !important;
line-height: ${this.form.messageLineHeight || this.form.messageFontSize}px !important;
}
${!this.form.messageOnNewLine ? '' : `yt-live-chat-text-message-renderer #message {
display: block !important;
overflow: visible !important;
}`}
yt-live-chat-text-message-renderer #message .emoji {
width: auto !important;
height: ${this.form.emoticonSize}px !important;
}`
},
timeStyle() {
return common.getTimeStyle(this.form)
},
backgroundStyle() {
return `/* Background colors */
body {
overflow: hidden;
${this.form.bgColor ? `background-color: ${this.form.bgColor};` : ''}
}
${this.getBgStyleForAuthorType('', this.form.messageBgColor)}
${this.getBgStyleForAuthorType('owner', this.form.ownerMessageBgColor)}
${this.getBgStyleForAuthorType('moderator', this.form.moderatorMessageBgColor)}
${this.getBgStyleForAuthorType('member', this.form.memberMessageBgColor)}`
},
scAndNewMemberStyle() {
return `/* SuperChat/Fan Funding Messages */
yt-live-chat-paid-message-renderer {
margin: 4px 0 !important;
}
${this.scAndNewMemberFontStyle}
yt-live-chat-membership-item-renderer #card,
yt-live-chat-membership-item-renderer #header {
${this.showNewMemberBgStyle}
}
${this.scTickerStyle}
${this.form.showOtherThings ? '' : `yt-live-chat-item-list-renderer {
display: none !important;
}`}`
},
scAndNewMemberFontStyle() {
return `yt-live-chat-paid-message-renderer #author-name,
yt-live-chat-paid-message-renderer #author-name *,
yt-live-chat-membership-item-renderer #header-content-inner-column,
yt-live-chat-membership-item-renderer #header-content-inner-column * {
${this.form.firstLineColor ? `color: ${this.form.firstLineColor} !important;` : ''}
font-family: "${common.cssEscapeStr(this.form.firstLineFont)}"${common.FALLBACK_FONTS};
font-size: ${this.form.firstLineFontSize}px !important;
line-height: ${this.form.firstLineLineHeight || this.form.firstLineFontSize}px !important;
}
yt-live-chat-paid-message-renderer #purchase-amount,
yt-live-chat-paid-message-renderer #purchase-amount *,
yt-live-chat-membership-item-renderer #header-subtext,
yt-live-chat-membership-item-renderer #header-subtext * {
${this.form.secondLineColor ? `color: ${this.form.secondLineColor} !important;` : ''}
font-family: "${common.cssEscapeStr(this.form.secondLineFont)}"${common.FALLBACK_FONTS};
font-size: ${this.form.secondLineFontSize}px !important;
line-height: ${this.form.secondLineLineHeight || this.form.secondLineFontSize}px !important;
}
yt-live-chat-paid-message-renderer #content,
yt-live-chat-paid-message-renderer #content * {
${this.form.scContentColor ? `color: ${this.form.scContentColor} !important;` : ''}
font-family: "${common.cssEscapeStr(this.form.scContentFont)}"${common.FALLBACK_FONTS};
font-size: ${this.form.scContentFontSize}px !important;
line-height: ${this.form.scContentLineHeight || this.form.scContentFontSize}px !important;
}`
},
showNewMemberBgStyle() {
if (this.form.showNewMemberBg) {
return `background-color: ${this.form.memberUserNameColor} !important;
margin: 4px 0 !important;`
} else {
return `background-color: transparent !important;
box-shadow: none !important;
margin: 0 !important;`
}
},
scTickerStyle() {
return `${this.form.showScTicker ? '' : `yt-live-chat-ticker-renderer {
display: none !important;
}`}
/* SuperChat Ticker */
yt-live-chat-ticker-paid-message-item-renderer,
yt-live-chat-ticker-paid-message-item-renderer *,
yt-live-chat-ticker-sponsor-item-renderer,
yt-live-chat-ticker-sponsor-item-renderer * {
${this.form.secondLineColor ? `color: ${this.form.secondLineColor} !important;` : ''}
font-family: "${common.cssEscapeStr(this.form.secondLineFont)}"${common.FALLBACK_FONTS};
}`
},
animationStyle() {
return common.getAnimationStyle(this.form)
}
},
watch: {
result(val) {
this.$emit('input', val)
this.saveConfig()
}
},
created() {
this.$emit('input', this.result)
},
methods: {
saveConfig: _.debounce(function() {
let config = mergeConfig(this.form, DEFAULT_CONFIG)
window.localStorage.stylegenConfig = JSON.stringify(config)
}, 500),
loadConfig() {
try {
return mergeConfig(JSON.parse(window.localStorage.stylegenConfig), DEFAULT_CONFIG)
} catch {
return { ...DEFAULT_CONFIG }
}
},
resetConfig() {
this.form = { ...DEFAULT_CONFIG }
},
getBgStyleForAuthorType(authorType, color) {
let typeSelector = authorType ? `[author-type="${authorType}"]` : ''
if (!this.form.useBarsInsteadOfBg) {
return `yt-live-chat-text-message-renderer${typeSelector},
yt-live-chat-text-message-renderer${typeSelector}[is-highlighted] {
${color ? `background-color: ${color} !important;` : ''}
}`
} else {
return `yt-live-chat-text-message-renderer${typeSelector}::after {
${color ? `border: 2px solid ${color};` : ''}
content: "";
position: absolute;
display: block;
left: 8px;
top: 4px;
bottom: 4px;
width: 1px;
box-sizing: border-box;
border-radius: 2px;
}`
}
}
}
}
</script>

View File

@@ -0,0 +1,611 @@
<template>
<div>
<el-form label-width="150px" size="mini">
<h3>{{ $t('stylegen.avatars') }}</h3>
<el-card shadow="never">
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.showAvatars')">
<el-switch v-model="form.showAvatars"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.avatarSize')">
<el-input v-model.number="form.avatarSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
</el-card>
<h3>{{ $t('stylegen.userNames') }}</h3>
<el-card shadow="never">
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.showUserNames')">
<el-switch v-model="form.showUserNames"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.font')">
<font-select v-model="form.userNameFont"></font-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.fontSize')">
<el-input v-model.number="form.userNameFontSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.lineHeight')">
<el-input v-model.number="form.userNameLineHeight" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.normalColor')">
<el-color-picker v-model="form.userNameColor"></el-color-picker>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.memberColor')">
<el-color-picker v-model="form.memberUserNameColor"></el-color-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.moderatorColor')">
<el-color-picker v-model="form.moderatorUserNameColor"></el-color-picker>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.ownerColor')">
<el-color-picker v-model="form.ownerUserNameColor"></el-color-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.showBadges')">
<el-switch v-model="form.showBadges"></el-switch>
</el-form-item>
</el-col>
</el-row>
</el-card>
<h3>{{ $t('stylegen.messages') }}</h3>
<el-card shadow="never">
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.font')">
<font-select v-model="form.messageFont"></font-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.color')">
<el-color-picker v-model="form.messageColor"></el-color-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.fontSize')">
<el-input v-model.number="form.messageFontSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.lineHeight')">
<el-input v-model.number="form.messageLineHeight" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.emoticonSize')">
<el-input v-model.number="form.emoticonSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
</el-card>
<h3>{{ $t('stylegen.time') }}</h3>
<el-card shadow="never">
<el-form-item :label="$t('stylegen.showTime')">
<el-switch v-model="form.showTime"></el-switch>
</el-form-item>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.font')">
<font-select v-model="form.timeFont"></font-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.color')">
<el-color-picker v-model="form.timeColor"></el-color-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.fontSize')">
<el-input v-model.number="form.timeFontSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.lineHeight')">
<el-input v-model.number="form.timeLineHeight" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
</el-card>
<h3>{{ $t('stylegen.backgrounds') }}</h3>
<el-card shadow="never">
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.bgColor')">
<el-color-picker v-model="form.bgColor" show-alpha></el-color-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.messageBgColor')">
<el-color-picker v-model="form.messageBgColor" show-alpha></el-color-picker>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.memberMessageBgColor')">
<el-color-picker v-model="form.memberMessageBgColor" show-alpha></el-color-picker>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.moderatorMessageBgColor')">
<el-color-picker v-model="form.moderatorMessageBgColor" show-alpha></el-color-picker>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.ownerMessageBgColor')">
<el-color-picker v-model="form.ownerMessageBgColor" show-alpha></el-color-picker>
</el-form-item>
</el-col>
</el-row>
</el-card>
<h3>{{ $t('stylegen.scAndNewMember') }}</h3>
<el-card shadow="never">
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.firstLineFont')">
<font-select v-model="form.firstLineFont"></font-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.firstLineFontSize')">
<el-input v-model.number="form.firstLineFontSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.firstLineLineHeight')">
<el-input v-model.number="form.firstLineLineHeight" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-divider></el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.secondLineFont')">
<font-select v-model="form.secondLineFont"></font-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.secondLineFontSize')">
<el-input v-model.number="form.secondLineFontSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.secondLineLineHeight')">
<el-input v-model.number="form.secondLineLineHeight" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-divider></el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.scContentLineFont')">
<font-select v-model="form.scContentFont"></font-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.scContentLineFontSize')">
<el-input v-model.number="form.scContentFontSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.scContentLineLineHeight')">
<el-input v-model.number="form.scContentLineHeight" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-divider></el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.showScTicker')">
<el-switch v-model="form.showScTicker"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.showOtherThings')">
<el-switch v-model="form.showOtherThings"></el-switch>
</el-form-item>
</el-col>
</el-row>
</el-card>
<h3>{{ $t('stylegen.animation') }}</h3>
<el-card shadow="never">
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.animateIn')">
<el-switch v-model="form.animateIn"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.fadeInTime')">
<el-input v-model.number="form.fadeInTime" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.animateOut')">
<el-switch v-model="form.animateOut"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.fadeOutTime')">
<el-input v-model.number="form.fadeOutTime" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="$t('stylegen.animateOutWaitTime')">
<el-input v-model.number="form.animateOutWaitTime" type="number" min="0"></el-input>
</el-form-item>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.slide')">
<el-switch v-model="form.slide"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.reverseSlide')">
<el-switch v-model="form.reverseSlide"></el-switch>
</el-form-item>
</el-col>
</el-row>
</el-card>
</el-form>
</div>
</template>
<script>
import _ from 'lodash'
import FontSelect from './FontSelect'
import * as common from './common'
import { mergeConfig } from '@/utils'
export const DEFAULT_CONFIG = {
showAvatars: true,
avatarSize: 40,
showUserNames: true,
userNameFont: 'Noto Sans SC',
userNameFontSize: 20,
userNameLineHeight: 0,
userNameColor: '#cccccc',
ownerUserNameColor: '#ffd600',
moderatorUserNameColor: '#5e84f1',
memberUserNameColor: '#0f9d58',
showBadges: true,
messageFont: 'Noto Sans SC',
messageFontSize: 18,
messageLineHeight: 0,
messageColor: '#000000',
emoticonSize: 18,
showTime: false,
timeFont: 'Noto Sans SC',
timeFontSize: 16,
timeLineHeight: 0,
timeColor: '#999999',
bgColor: 'rgba(0, 0, 0, 0)',
messageBgColor: '#ffffff',
ownerMessageBgColor: 'rgba(231, 199, 30, 1)',
moderatorMessageBgColor: 'rgba(41, 95, 251, 1)',
memberMessageBgColor: 'rgba(43, 234, 43, 1)',
firstLineFont: 'Noto Sans SC',
firstLineFontSize: 20,
firstLineLineHeight: 0,
secondLineFont: 'Noto Sans SC',
secondLineFontSize: 18,
secondLineLineHeight: 0,
scContentFont: 'Noto Sans SC',
scContentFontSize: 18,
scContentLineHeight: 0,
showScTicker: false,
showOtherThings: true,
animateIn: true,
fadeInTime: 200, // ms
animateOut: false,
animateOutWaitTime: 30, // s
fadeOutTime: 200, // ms
slide: true,
reverseSlide: false
}
export default {
name: 'LineLike',
components: {
FontSelect
},
props: {
value: String
},
data() {
return {
form: this.loadConfig()
}
},
computed: {
result() {
return `${this.importStyle}
${common.COMMON_STYLE}
${this.paddingStyle}
${this.avatarStyle}
${this.userNameStyle}
${this.messageStyle}
${this.timeStyle}
${this.backgroundStyle}
${this.scAndNewMemberStyle}
${this.animationStyle}
`
},
importStyle() {
let allFonts = []
for (let name of ['userNameFont', 'messageFont', 'timeFont', 'firstLineFont', 'secondLineFont', 'scContentFont']) {
allFonts.push(this.form[name])
}
return common.getImportStyle(allFonts)
},
paddingStyle() {
return `/* Reduce side padding */
yt-live-chat-text-message-renderer {
padding-left: 4px !important;
padding-right: 4px !important;
}`
},
avatarStyle() {
return common.getAvatarStyle(this.form)
},
userNameStyle() {
return `/* Channel names */
yt-live-chat-text-message-renderer yt-live-chat-author-chip {
margin-bottom: 5px;
}
yt-live-chat-text-message-renderer #author-name[type="owner"],
yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="owner"] {
${this.form.ownerUserNameColor ? `color: ${this.form.ownerUserNameColor} !important;` : ''}
}
yt-live-chat-text-message-renderer #author-name[type="moderator"],
yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="moderator"] {
${this.form.moderatorUserNameColor ? `color: ${this.form.moderatorUserNameColor} !important;` : ''}
}
yt-live-chat-text-message-renderer #author-name[type="member"],
yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="member"] {
${this.form.memberUserNameColor ? `color: ${this.form.memberUserNameColor} !important;` : ''}
}
yt-live-chat-text-message-renderer #author-name {
${this.form.showUserNames ? '' : 'display: none !important;'}
${this.form.userNameColor ? `color: ${this.form.userNameColor} !important;` : ''}
font-family: "${common.cssEscapeStr(this.form.userNameFont)}"${common.FALLBACK_FONTS};
font-size: ${this.form.userNameFontSize}px !important;
line-height: ${this.form.userNameLineHeight || this.form.userNameFontSize}px !important;
}
/* Hide badges */
yt-live-chat-text-message-renderer #chat-badges {
${this.form.showBadges ? '' : 'display: none !important;'}
vertical-align: text-top !important;
}`
},
messageStyle() {
return `/* Messages */
yt-live-chat-text-message-renderer #message,
yt-live-chat-text-message-renderer #message * {
${this.form.messageColor ? `color: ${this.form.messageColor} !important;` : ''}
font-family: "${common.cssEscapeStr(this.form.messageFont)}"${common.FALLBACK_FONTS};
font-size: ${this.form.messageFontSize}px !important;
line-height: ${this.form.messageLineHeight || this.form.messageFontSize}px !important;
}
yt-live-chat-text-message-renderer #message {
display: block !important;
overflow: visible !important;
padding: 20px;
border-radius: 30px;
}
yt-live-chat-text-message-renderer #message .emoji {
width: auto !important;
height: ${this.form.emoticonSize}px !important;
}
/* The triangle beside dialog */
yt-live-chat-text-message-renderer #message::before {
content: "";
display: inline-block;
position: absolute;
top: ${this.form.showUserNames ? (this.form.userNameLineHeight || this.form.userNameFontSize) + 10 : 20}px;
left: ${this.form.showAvatars ? this.form.avatarSize + (this.form.avatarSize / 4) - 8 : -8}px;
border: 8px solid transparent;
border-right: 18px solid;
transform: rotate(35deg);
}`
},
timeStyle() {
return common.getTimeStyle(this.form)
},
backgroundStyle() {
return `/* Background colors */
body {
overflow: hidden;
${this.form.bgColor ? `background-color: ${this.form.bgColor};` : ''}
}
${this.getBgStyleForAuthorType('', this.form.messageBgColor)}
${this.getBgStyleForAuthorType('owner', this.form.ownerMessageBgColor)}
${this.getBgStyleForAuthorType('moderator', this.form.moderatorMessageBgColor)}
${this.getBgStyleForAuthorType('member', this.form.memberMessageBgColor)}`
},
scAndNewMemberStyle() {
return `/* SuperChat/Fan Funding Messages */
yt-live-chat-paid-message-renderer {
margin: 4px 0 !important;
}
${this.scAndNewMemberFontStyle}
yt-live-chat-membership-item-renderer #card,
yt-live-chat-membership-item-renderer #header {
${this.showNewMemberBgStyle}
}
${this.scTickerStyle}
${this.form.showOtherThings ? '' : `yt-live-chat-item-list-renderer {
display: none !important;
}`}`
},
scAndNewMemberFontStyle() {
return `yt-live-chat-paid-message-renderer #author-name,
yt-live-chat-paid-message-renderer #author-name *,
yt-live-chat-membership-item-renderer #header-content-inner-column,
yt-live-chat-membership-item-renderer #header-content-inner-column * {
font-family: "${common.cssEscapeStr(this.form.firstLineFont)}"${common.FALLBACK_FONTS};
font-size: ${this.form.firstLineFontSize}px !important;
line-height: ${this.form.firstLineLineHeight || this.form.firstLineFontSize}px !important;
}
yt-live-chat-paid-message-renderer #purchase-amount,
yt-live-chat-paid-message-renderer #purchase-amount *,
yt-live-chat-membership-item-renderer #header-subtext,
yt-live-chat-membership-item-renderer #header-subtext * {
font-family: "${common.cssEscapeStr(this.form.secondLineFont)}"${common.FALLBACK_FONTS};
font-size: ${this.form.secondLineFontSize}px !important;
line-height: ${this.form.secondLineLineHeight || this.form.secondLineFontSize}px !important;
}
yt-live-chat-paid-message-renderer #content,
yt-live-chat-paid-message-renderer #content * {
font-family: "${common.cssEscapeStr(this.form.scContentFont)}"${common.FALLBACK_FONTS};
font-size: ${this.form.scContentFontSize}px !important;
line-height: ${this.form.scContentLineHeight || this.form.scContentFontSize}px !important;
}`
},
showNewMemberBgStyle() {
return `background-color: ${this.form.memberUserNameColor} !important;
margin: 4px 0 !important;`
},
scTickerStyle() {
return `${this.form.showScTicker ? '' : `yt-live-chat-ticker-renderer {
display: none !important;
}`}
/* SuperChat Ticker */
yt-live-chat-ticker-paid-message-item-renderer,
yt-live-chat-ticker-paid-message-item-renderer *,
yt-live-chat-ticker-sponsor-item-renderer,
yt-live-chat-ticker-sponsor-item-renderer * {
font-family: "${common.cssEscapeStr(this.form.secondLineFont)}"${common.FALLBACK_FONTS};
}`
},
animationStyle() {
return common.getAnimationStyle(this.form)
}
},
watch: {
result(val) {
this.$emit('input', val)
this.saveConfig()
}
},
created() {
this.$emit('input', this.result)
},
methods: {
saveConfig: _.debounce(function() {
let config = mergeConfig(this.form, DEFAULT_CONFIG)
window.localStorage.stylegenLineLikeConfig = JSON.stringify(config)
}, 500),
loadConfig() {
try {
return mergeConfig(JSON.parse(window.localStorage.stylegenLineLikeConfig), DEFAULT_CONFIG)
} catch {
return { ...DEFAULT_CONFIG }
}
},
resetConfig() {
this.form = { ...DEFAULT_CONFIG }
},
getBgStyleForAuthorType(authorType, color) {
if (!color) {
color = '#ffffff'
}
let typeSelector = authorType ? `[author-type="${authorType}"]` : ''
return `yt-live-chat-text-message-renderer${typeSelector} #message {
background-color: ${color} !important;
}
yt-live-chat-text-message-renderer${typeSelector} #message::before {
border-right-color: ${color};
}`
}
}
}
</script>

View File

@@ -0,0 +1,162 @@
import * as fonts from './fonts'
export const FALLBACK_FONTS = ', "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "\\5FAE \\8F6F \\96C5 \\9ED1 ", SimHei, Arial, sans-serif'
export const COMMON_STYLE = `/* Transparent background */
yt-live-chat-renderer {
background-color: transparent !important;
}
yt-live-chat-ticker-renderer {
background-color: transparent !important;
box-shadow: none !important;
}
yt-live-chat-author-chip #author-name {
background-color: transparent !important;
}
/* Hide scrollbar */
yt-live-chat-item-list-renderer #items {
overflow: hidden !important;
}
yt-live-chat-item-list-renderer #item-scroller {
overflow: hidden !important;
}
yt-live-chat-text-message-renderer #content,
yt-live-chat-membership-item-renderer #content {
overflow: visible !important;
}
/* Hide header and input */
yt-live-chat-header-renderer,
yt-live-chat-message-input-renderer {
display: none !important;
}
/* Hide unimportant messages */
yt-live-chat-text-message-renderer[is-deleted],
yt-live-chat-membership-item-renderer[is-deleted] {
display: none !important;
}
yt-live-chat-mode-change-message-renderer,
yt-live-chat-viewer-engagement-message-renderer,
yt-live-chat-restricted-participation-renderer {
display: none !important;
}
yt-live-chat-text-message-renderer a,
yt-live-chat-membership-item-renderer a {
text-decoration: none !important;
}`
export function getImportStyle(allFonts) {
let fontsNeedToImport = new Set()
for (let font of allFonts) {
if (fonts.NETWORK_FONTS.indexOf(font) !== -1) {
fontsNeedToImport.add(font)
}
}
let res = []
for (let font of fontsNeedToImport) {
res.push(`@import url("https://fonts.googleapis.com/css?family=${encodeURIComponent(font)}");`)
}
return res.join('\n')
}
export function getAvatarStyle(config) {
return `/* Avatars */
yt-live-chat-text-message-renderer #author-photo,
yt-live-chat-text-message-renderer #author-photo img,
yt-live-chat-paid-message-renderer #author-photo,
yt-live-chat-paid-message-renderer #author-photo img,
yt-live-chat-membership-item-renderer #author-photo,
yt-live-chat-membership-item-renderer #author-photo img {
${config.showAvatars ? '' : 'display: none !important;'}
width: ${config.avatarSize}px !important;
height: ${config.avatarSize}px !important;
border-radius: ${config.avatarSize}px !important;
margin-right: ${config.avatarSize / 4}px !important;
}`
}
export function getTimeStyle(config) {
return `/* Timestamps */
yt-live-chat-text-message-renderer #timestamp {
display: ${config.showTime ? 'inline' : 'none'} !important;
${config.timeColor ? `color: ${config.timeColor} !important;` : ''}
font-family: "${cssEscapeStr(config.timeFont)}"${FALLBACK_FONTS};
font-size: ${config.timeFontSize}px !important;
line-height: ${config.timeLineHeight || config.timeFontSize}px !important;
}`
}
export function getAnimationStyle(config) {
if (!config.animateIn && !config.animateOut) {
return ''
}
let totalTime = 0
if (config.animateIn) {
totalTime += config.fadeInTime
}
if (config.animateOut) {
totalTime += config.animateOutWaitTime * 1000
totalTime += config.fadeOutTime
}
let keyframes = []
let curTime = 0
if (config.animateIn) {
keyframes.push(` 0% { opacity: 0;${!config.slide ? ''
: ` transform: translateX(${config.reverseSlide ? 16 : -16}px);`
} }`)
curTime += config.fadeInTime
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; }`)
curTime += config.fadeOutTime
keyframes.push(` ${curTime / totalTime * 100}% { opacity: 0;${!config.slide ? ''
: ` transform: translateX(${config.reverseSlide ? -16 : 16}px);`
} }`)
}
return `/* Animation */
@keyframes anim {
${keyframes.join('\n')}
}
yt-live-chat-text-message-renderer,
yt-live-chat-membership-item-renderer,
yt-live-chat-paid-message-renderer {
animation: anim ${totalTime}ms;
animation-fill-mode: both;
}`
}
export function cssEscapeStr(str) {
let res = []
for (let char of str) {
res.push(cssEscapeChar(char))
}
return res.join('')
}
function cssEscapeChar(char) {
if (!needEscapeChar(char)) {
return char
}
let hexCode = char.codePointAt(0).toString(16)
// https://drafts.csswg.org/cssom/#escape-a-character-as-code-point
return `\\${hexCode} `
}
function needEscapeChar(char) {
let code = char.codePointAt(0)
if (0x20 <= code && code <= 0x7E) {
return char === '"' || char === '\\'
}
return true
}

File diff suppressed because one or more lines are too long

View File

@@ -1,199 +1,43 @@
<template>
<el-row :gutter="20">
<el-col :span="12">
<el-col :sm="24" :md="16">
<el-tabs v-model="activeTab">
<el-tab-pane :label="$t('stylegen.legacy')" name="legacy">
<legacy ref="legacy" v-model="subComponentResults.legacy"></legacy>
</el-tab-pane>
<el-tab-pane :label="$t('stylegen.lineLike')" name="lineLike">
<line-like ref="lineLike" v-model="subComponentResults.lineLike"></line-like>
</el-tab-pane>
</el-tabs>
<el-form label-width="150px" size="mini">
<h3>{{$t('stylegen.outlines')}}</h3>
<el-form-item :label="$t('stylegen.showOutlines')">
<el-switch v-model="form.showOutlines"></el-switch>
</el-form-item>
<el-form-item :label="$t('stylegen.outlineSize')">
<el-input v-model.number="form.outlineSize" type="number" min="0"></el-input>
</el-form-item>
<el-form-item :label="$t('stylegen.outlineColor')">
<el-color-picker v-model="form.outlineColor"></el-color-picker>
</el-form-item>
<h3>{{$t('stylegen.avatars')}}</h3>
<el-form-item :label="$t('stylegen.showAvatars')">
<el-switch v-model="form.showAvatars"></el-switch>
</el-form-item>
<el-form-item :label="$t('stylegen.avatarSize')">
<el-input v-model.number="form.avatarSize" type="number" min="0"></el-input>
</el-form-item>
<h3>{{$t('stylegen.userNames')}}</h3>
<el-form-item :label="$t('stylegen.showUserNames')">
<el-switch v-model="form.showUserNames"></el-switch>
</el-form-item>
<el-form-item :label="$t('stylegen.font')">
<el-autocomplete v-model="form.userNameFont" :fetch-suggestions="getFontSuggestions"></el-autocomplete>
</el-form-item>
<el-form-item :label="$t('stylegen.fontSize')">
<el-input v-model.number="form.userNameFontSize" type="number" min="0"></el-input>
</el-form-item>
<el-form-item :label="$t('stylegen.lineHeight')">
<el-input v-model.number="form.userNameLineHeight" type="number" min="0"></el-input>
</el-form-item>
<el-form-item :label="$t('stylegen.normalColor')">
<el-color-picker v-model="form.userNameColor"></el-color-picker>
</el-form-item>
<el-form-item :label="$t('stylegen.ownerColor')">
<el-color-picker v-model="form.ownerUserNameColor"></el-color-picker>
</el-form-item>
<el-form-item :label="$t('stylegen.moderatorColor')">
<el-color-picker v-model="form.moderatorUserNameColor"></el-color-picker>
</el-form-item>
<el-form-item :label="$t('stylegen.memberColor')">
<el-color-picker v-model="form.memberUserNameColor"></el-color-picker>
</el-form-item>
<el-form-item :label="$t('stylegen.showBadges')">
<el-switch v-model="form.showBadges"></el-switch>
</el-form-item>
<el-form-item :label="$t('stylegen.showColon')">
<el-switch v-model="form.showColon"></el-switch>
</el-form-item>
<h3>{{$t('stylegen.messages')}}</h3>
<el-form-item :label="$t('stylegen.font')">
<el-autocomplete v-model="form.messageFont" :fetch-suggestions="getFontSuggestions"></el-autocomplete>
</el-form-item>
<el-form-item :label="$t('stylegen.fontSize')">
<el-input v-model.number="form.messageFontSize" type="number" min="0"></el-input>
</el-form-item>
<el-form-item :label="$t('stylegen.lineHeight')">
<el-input v-model.number="form.messageLineHeight" type="number" min="0"></el-input>
</el-form-item>
<el-form-item :label="$t('stylegen.color')">
<el-color-picker v-model="form.messageColor"></el-color-picker>
</el-form-item>
<el-form-item :label="$t('stylegen.onNewLine')">
<el-switch v-model="form.messageOnNewLine"></el-switch>
</el-form-item>
<h3>{{$t('stylegen.time')}}</h3>
<el-form-item :label="$t('stylegen.showTime')">
<el-switch v-model="form.showTime"></el-switch>
</el-form-item>
<el-form-item :label="$t('stylegen.font')">
<el-autocomplete v-model="form.timeFont" :fetch-suggestions="getFontSuggestions"></el-autocomplete>
</el-form-item>
<el-form-item :label="$t('stylegen.fontSize')">
<el-input v-model.number="form.timeFontSize" type="number" min="0"></el-input>
</el-form-item>
<el-form-item :label="$t('stylegen.lineHeight')">
<el-input v-model.number="form.timeLineHeight" type="number" min="0"></el-input>
</el-form-item>
<el-form-item :label="$t('stylegen.color')">
<el-color-picker v-model="form.timeColor"></el-color-picker>
</el-form-item>
<h3>{{$t('stylegen.backgrounds')}}</h3>
<el-form-item :label="$t('stylegen.bgColor')">
<el-color-picker v-model="form.bgColor" show-alpha></el-color-picker>
</el-form-item>
<el-form-item :label="$t('stylegen.useBarsInsteadOfBg')">
<el-switch v-model="form.useBarsInsteadOfBg"></el-switch>
</el-form-item>
<el-form-item :label="$t('stylegen.messageBgColor')">
<el-color-picker v-model="form.messageBgColor" show-alpha></el-color-picker>
</el-form-item>
<el-form-item :label="$t('stylegen.ownerMessageBgColor')">
<el-color-picker v-model="form.ownerMessageBgColor" show-alpha></el-color-picker>
</el-form-item>
<el-form-item :label="$t('stylegen.moderatorMessageBgColor')">
<el-color-picker v-model="form.moderatorMessageBgColor" show-alpha></el-color-picker>
</el-form-item>
<el-form-item :label="$t('stylegen.memberMessageBgColor')">
<el-color-picker v-model="form.memberMessageBgColor" show-alpha></el-color-picker>
</el-form-item>
<h3>{{$t('stylegen.scAndNewMember')}}</h3>
<el-form-item :label="$t('stylegen.firstLineFont')">
<el-autocomplete v-model="form.firstLineFont" :fetch-suggestions="getFontSuggestions"></el-autocomplete>
</el-form-item>
<el-form-item :label="$t('stylegen.firstLineFontSize')">
<el-input v-model.number="form.firstLineFontSize" type="number" min="0"></el-input>
</el-form-item>
<el-form-item :label="$t('stylegen.firstLineLineHeight')">
<el-input v-model.number="form.firstLineLineHeight" type="number" min="0"></el-input>
</el-form-item>
<el-form-item :label="$t('stylegen.firstLineColor')">
<el-color-picker v-model="form.firstLineColor"></el-color-picker>
</el-form-item>
<el-form-item :label="$t('stylegen.secondLineFont')">
<el-autocomplete v-model="form.secondLineFont" :fetch-suggestions="getFontSuggestions"></el-autocomplete>
</el-form-item>
<el-form-item :label="$t('stylegen.secondLineFontSize')">
<el-input v-model.number="form.secondLineFontSize" type="number" min="0"></el-input>
</el-form-item>
<el-form-item :label="$t('stylegen.secondLineLineHeight')">
<el-input v-model.number="form.secondLineLineHeight" type="number" min="0"></el-input>
</el-form-item>
<el-form-item :label="$t('stylegen.secondLineColor')">
<el-color-picker v-model="form.secondLineColor"></el-color-picker>
</el-form-item>
<el-form-item :label="$t('stylegen.scContentLineFont')">
<el-autocomplete v-model="form.scContentFont" :fetch-suggestions="getFontSuggestions"></el-autocomplete>
</el-form-item>
<el-form-item :label="$t('stylegen.scContentLineFontSize')">
<el-input v-model.number="form.scContentFontSize" type="number" min="0"></el-input>
</el-form-item>
<el-form-item :label="$t('stylegen.scContentLineLineHeight')">
<el-input v-model.number="form.scContentLineHeight" type="number" min="0"></el-input>
</el-form-item>
<el-form-item :label="$t('stylegen.scContentLineColor')">
<el-color-picker v-model="form.scContentColor"></el-color-picker>
</el-form-item>
<el-form-item :label="$t('stylegen.showNewMemberBg')">
<el-switch v-model="form.showNewMemberBg"></el-switch>
</el-form-item>
<el-form-item :label="$t('stylegen.showScTicker')">
<el-switch v-model="form.showScTicker"></el-switch>
</el-form-item>
<el-form-item :label="$t('stylegen.showOtherThings')">
<el-switch v-model="form.showOtherThings"></el-switch>
</el-form-item>
<h3>{{$t('stylegen.animation')}}</h3>
<el-form-item :label="$t('stylegen.animateIn')">
<el-switch v-model="form.animateIn"></el-switch>
</el-form-item>
<el-form-item :label="$t('stylegen.fadeInTime')">
<el-input v-model.number="form.fadeInTime" type="number" min="0"></el-input>
</el-form-item>
<el-form-item :label="$t('stylegen.animateOut')">
<el-switch v-model="form.animateOut"></el-switch>
</el-form-item>
<el-form-item :label="$t('stylegen.animateOutWaitTime')">
<el-input v-model.number="form.animateOutWaitTime" type="number" min="0"></el-input>
</el-form-item>
<el-form-item :label="$t('stylegen.fadeOutTime')">
<el-input v-model.number="form.fadeOutTime" type="number" min="0"></el-input>
</el-form-item>
<el-form-item :label="$t('stylegen.slide')">
<el-switch v-model="form.slide"></el-switch>
</el-form-item>
<el-form-item :label="$t('stylegen.reverseSlide')">
<el-switch v-model="form.reverseSlide"></el-switch>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="playAnimation">{{$t('stylegen.playAnimation')}}</el-button>
</el-form-item>
<h3>{{$t('stylegen.result')}}</h3>
<el-form-item label="CSS">
<el-input v-model="result" ref="result" type="textarea" :rows="20"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="copyResult">{{$t('stylegen.copy')}}</el-button>
<el-button @click="resetConfig">{{$t('stylegen.resetConfig')}}</el-button>
</el-form-item>
<h3>{{ $t('stylegen.result') }}</h3>
<el-card shadow="never">
<el-form-item label="CSS">
<el-input v-model="inputResult" ref="result" type="textarea" :rows="20"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="copyResult">{{ $t('stylegen.copy') }}</el-button>
<el-button @click="resetConfig">{{ $t('stylegen.resetConfig') }}</el-button>
</el-form-item>
</el-card>
</el-form>
</el-col>
<el-col :span="12">
<div ref="exampleContainer" id="example-container">
<div id="fakebody">
<chat-renderer ref="renderer" :css="exampleCss"></chat-renderer>
<el-col :sm="24" :md="8">
<div :style="{ position: 'relative', top: `${exampleTop}px` }">
<el-form inline style="line-height: 40px">
<el-form-item :label="$t('stylegen.playAnimation')" style="margin: 0">
<el-switch v-model="playAnimation" @change="onPlayAnimationChange"></el-switch>
</el-form-item>
<el-form-item :label="$t('stylegen.backgrounds')" style="margin: 0 0 0 30px">
<el-switch v-model="exampleBgLight" :active-text="$t('stylegen.light')" :inactive-text="$t('stylegen.dark')"></el-switch>
</el-form-item>
</el-form>
<div id="example-container" :class="{ light: exampleBgLight }">
<div id="fakebody">
<room ref="room"></room>
</div>
</div>
</div>
</el-col>
@@ -203,172 +47,100 @@
<script>
import _ from 'lodash'
import * as stylegen from './stylegen'
import * as fonts from './fonts'
import ChatRenderer from '@/components/ChatRenderer'
import * as constants from '@/components/ChatRenderer/constants'
let time = new Date()
let textMessageTemplate = {
id: 0,
addTime: time,
type: constants.MESSAGE_TYPE_TEXT,
avatarUrl: 'https://static.hdslb.com/images/member/noface.gif',
time: time,
authorName: '',
authorType: constants.AUTHRO_TYPE_NORMAL,
content: '',
privilegeType: 0,
repeated: 1,
translation: ''
}
let membershipItemTemplate = {
id: 0,
addTime: time,
type: constants.MESSAGE_TYPE_MEMBER,
avatarUrl: 'https://static.hdslb.com/images/member/noface.gif',
time: time,
authorName: '',
privilegeType: 3,
title: 'New member'
}
let paidMessageTemplate = {
id: 0,
addTime: time,
type: constants.MESSAGE_TYPE_SUPER_CHAT,
avatarUrl: 'https://static.hdslb.com/images/member/noface.gif',
authorName: '',
price: 0,
time: time,
content: '',
translation: ''
}
let nextId = 0
const EXAMPLE_MESSAGES = [
{
...textMessageTemplate,
id: (nextId++).toString(),
authorName: 'mob路人',
content: '8888888888',
repeated: 12
},
{
...textMessageTemplate,
id: (nextId++).toString(),
authorName: 'member舰长',
authorType: constants.AUTHRO_TYPE_MEMBER,
content: '草',
privilegeType: 3,
repeated: 3
},
{
...textMessageTemplate,
id: (nextId++).toString(),
authorName: 'admin房管',
authorType: constants.AUTHRO_TYPE_ADMIN,
content: 'kksk'
},
{
...membershipItemTemplate,
id: (nextId++).toString(),
authorName: '艾米亚official'
},
{
...paidMessageTemplate,
id: (nextId++).toString(),
authorName: '愛里紗メイプル',
price: 66600,
content: 'Sent 小电视飞船x100'
},
{
...textMessageTemplate,
id: (nextId++).toString(),
authorName: 'streamer主播',
authorType: constants.AUTHRO_TYPE_OWNER,
content: '老板大气,老板身体健康'
},
{
...paidMessageTemplate,
id: (nextId++).toString(),
authorName: 'AstralisUP',
price: 30,
content: '言いたいことがあるんだよ!'
}
]
import Legacy from './Legacy'
import LineLike from './LineLike'
import Room from '@/views/Room'
export default {
name: 'StyleGenerator',
components: {
ChatRenderer
Legacy, LineLike, Room
},
data() {
let stylegenConfig = stylegen.getLocalConfig()
let result = stylegen.getStyle(stylegenConfig)
let styleElement = document.createElement('style')
document.head.appendChild(styleElement)
// 数据流:
// 输入框 --\
// 子组件 -> subComponentResults -> subComponentResult -> inputResult -> 防抖延迟0.5s后 -> debounceResult -> exampleCss
return {
FONTS: [...fonts.LOCAL_FONTS, ...fonts.NETWORK_FONTS],
// 子组件的结果
subComponentResults: {
legacy: '',
lineLike: ''
},
activeTab: 'legacy',
// 输入框的结果
inputResult: '',
// 防抖后延迟变化的结果
debounceResult: '',
form: {...stylegenConfig},
result,
exampleCss: result.replace(/^body\b/gm, '#fakebody'),
styleElement,
exampleTop: 0,
playAnimation: true,
exampleBgLight: false
}
},
computed: {
computedResult() {
return stylegen.getStyle(this.form)
// 子组件的结果
subComponentResult() {
return this.subComponentResults[this.activeTab]
},
// 应用到预览上的CSS
exampleCss() {
return this.debounceResult.replace(/^body\b/gm, '#fakebody')
}
},
watch: {
computedResult: _.debounce(function(val) {
this.result = val
stylegen.setLocalConfig(this.form)
subComponentResult(val) {
this.inputResult = val
},
inputResult: _.debounce(function(val) {
this.debounceResult = val
}, 500),
result(val) {
this.exampleCss = val.replace(/^body\b/gm, '#fakebody')
exampleCss(val) {
this.styleElement.innerText = val
}
},
mounted() {
this.$refs.renderer.addMessages(EXAMPLE_MESSAGES)
this.debounceResult = this.inputResult = this.subComponentResult
let observer = new MutationObserver(() => this.$refs.renderer.scrollToBottom())
observer.observe(this.$refs.exampleContainer, {attributes: true})
this.$parent.$el.addEventListener('scroll', this.onParentScroll)
},
beforeDestroy() {
this.$parent.$el.removeEventListener('scroll', this.onParentScroll)
document.head.removeChild(this.styleElement)
},
methods: {
getFontSuggestions(query, callback) {
let res = this.FONTS.map(font => {return {value: font}})
if (query) {
query = query.toLowerCase()
res = res.filter(
font => font.value.toLowerCase().indexOf(query) !== -1
)
onParentScroll(event) {
if (document.body.clientWidth <= 992) {
this.exampleTop = 0
} else {
this.exampleTop = event.target.scrollTop
}
callback(res)
},
playAnimation() {
this.$refs.renderer.clearMessages()
this.$nextTick(() => this.$refs.renderer.addMessages(EXAMPLE_MESSAGES))
onPlayAnimationChange(value) {
if (value) {
this.$refs.room.start()
} else {
this.$refs.room.stop()
}
},
copyResult() {
this.$refs.result.select()
document.execCommand('Copy')
},
resetConfig() {
this.form = {...stylegen.DEFAULT_CONFIG}
this.$refs[this.activeTab].resetConfig()
this.inputResult = this.subComponentResult
}
}
}
</script>
<style scoped>
.el-form {
max-width: 800px;
}
#example-container {
position: fixed;
top: 30px;
left: calc(210px + 40px + (100vw - 210px - 40px) / 2);
width: calc((100vw - 210px - 40px) / 2 - 40px - 30px);
height: calc(100vh - 110px);
height: calc(100vh - 150px);
background-color: #444;
background-image:
@@ -382,11 +154,11 @@ export default {
-webkit-gradient(linear, 0 100%, 100% 0, color-stop(.75, transparent), color-stop(.75, #333)),
-webkit-gradient(linear, 0 0, 100% 100%, color-stop(.75, transparent), color-stop(.75, #333));
-moz-background-size:32px 32px;
background-size:32px 32px;
-webkit-background-size:32px 32px;
-moz-background-size: 32px 32px;
background-size: 32px 32px;
-webkit-background-size: 32px 32px;
background-position:0 0, 16px 0, 16px -16px, 0px 16px;
background-position: 0 0, 16px 0, 16px -16px, 0px 16px;
padding: 25px;
@@ -394,8 +166,18 @@ export default {
overflow: hidden;
}
.app-wrapper.mobile #example-container {
display: none;
#example-container.light {
background-color: #ddd;
background-image:
-moz-linear-gradient(45deg, #eee 25%, transparent 25%),
-moz-linear-gradient(-45deg, #eee 25%, transparent 25%),
-moz-linear-gradient(45deg, transparent 75%, #eee 75%),
-moz-linear-gradient(-45deg, transparent 75%, #eee 75%);
background-image:
-webkit-gradient(linear, 0 100%, 100% 0, color-stop(.25, #eee), color-stop(.25, transparent)),
-webkit-gradient(linear, 0 0, 100% 100%, color-stop(.25, #eee), color-stop(.25, transparent)),
-webkit-gradient(linear, 0 100%, 100% 0, color-stop(.75, transparent), color-stop(.75, #eee)),
-webkit-gradient(linear, 0 0, 100% 100%, color-stop(.75, transparent), color-stop(.75, #eee));
}
#fakebody {

View File

@@ -1,430 +0,0 @@
import {mergeConfig} from '@/utils'
import * as fonts from './fonts'
export const DEFAULT_CONFIG = {
showOutlines: true,
outlineSize: 2,
outlineColor: '#000000',
showAvatars: true,
avatarSize: 24,
showUserNames: true,
userNameFont: 'Changa One',
userNameFontSize: 20,
userNameLineHeight: 0,
userNameColor: '#cccccc',
ownerUserNameColor: '#ffd600',
moderatorUserNameColor: '#5e84f1',
memberUserNameColor: '#0f9d58',
showBadges: true,
showColon: true,
messageFont: 'Imprima',
messageFontSize: 18,
messageLineHeight: 0,
messageColor: '#ffffff',
messageOnNewLine: false,
showTime: false,
timeFont: 'Imprima',
timeFontSize: 16,
timeLineHeight: 0,
timeColor: '#999999',
bgColor: 'rgba(0, 0, 0, 0)',
useBarsInsteadOfBg: false,
messageBgColor: 'rgba(204, 204, 204, 0)',
ownerMessageBgColor: 'rgba(255, 214, 0, 0)',
moderatorMessageBgColor: 'rgba(94, 132, 241, 0)',
memberMessageBgColor: 'rgba(15, 157, 88, 0)',
firstLineFont: 'Changa One',
firstLineFontSize: 20,
firstLineLineHeight: 0,
firstLineColor: '#ffffff',
secondLineFont: 'Imprima',
secondLineFontSize: 18,
secondLineLineHeight: 0,
secondLineColor: '#ffffff',
scContentFont: 'Imprima',
scContentFontSize: 18,
scContentLineHeight: 0,
scContentColor: '#ffffff',
showNewMemberBg: true,
showScTicker: false,
showOtherThings: true,
animateIn: false,
fadeInTime: 200, // ms
animateOut: false,
animateOutWaitTime: 30, // s
fadeOutTime: 200, // ms
slide: false,
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)
}
export function getLocalConfig () {
if (!window.localStorage.stylegenConfig) {
return DEFAULT_CONFIG
}
return mergeConfig(JSON.parse(window.localStorage.stylegenConfig), DEFAULT_CONFIG)
}
export function getStyle (config) {
config = mergeConfig(config, DEFAULT_CONFIG)
return `${getImports(config)}
/* Background colors */
body {
overflow: hidden;
${config.bgColor ? `background-color: ${config.bgColor};` : ''}
}
/* Transparent background. */
yt-live-chat-renderer {
background-color: transparent !important;
}
${getMessageColorStyle('', config.messageBgColor, config.useBarsInsteadOfBg)}
${getMessageColorStyle('owner', config.ownerMessageBgColor, config.useBarsInsteadOfBg)}
${getMessageColorStyle('moderator', config.moderatorMessageBgColor, config.useBarsInsteadOfBg)}
${getMessageColorStyle('member', config.memberMessageBgColor, config.useBarsInsteadOfBg)}
yt-live-chat-author-chip #author-name {
background-color: transparent !important;
}
/* Outlines */
yt-live-chat-renderer * {
${getShowOutlinesStyle(config)}
font-family: "${cssEscapeStr(config.messageFont)}"${FALLBACK_FONTS};
font-size: ${config.messageFontSize}px !important;
line-height: ${config.messageLineHeight || config.messageFontSize}px !important;
}
yt-live-chat-text-message-renderer #content,
yt-live-chat-membership-item-renderer #content {
overflow: initial !important;
}
/* Hide scrollbar. */
yt-live-chat-item-list-renderer #items {
overflow: hidden !important;
}
yt-live-chat-item-list-renderer #item-scroller {
overflow: hidden !important;
}
/* Hide header and input. */
yt-live-chat-header-renderer,
yt-live-chat-message-input-renderer {
display: none !important;
}
/* Reduce side padding. */
yt-live-chat-text-message-renderer {
${getPaddingStyle(config)}
}
/* Avatars. */
yt-live-chat-text-message-renderer #author-photo,
yt-live-chat-text-message-renderer #author-photo img,
yt-live-chat-paid-message-renderer #author-photo,
yt-live-chat-paid-message-renderer #author-photo img,
yt-live-chat-membership-item-renderer #author-photo,
yt-live-chat-membership-item-renderer #author-photo img {
${config.showAvatars ? '' : 'display: none !important;'}
width: ${config.avatarSize}px !important;
height: ${config.avatarSize}px !important;
border-radius: ${config.avatarSize}px !important;
margin-right: ${config.avatarSize / 4}px !important;
}
/* Hide badges. */
yt-live-chat-text-message-renderer #chat-badges {
${config.showBadges ? '' : 'display: none !important;'}
vertical-align: text-top !important;
}
/* Timestamps. */
yt-live-chat-text-message-renderer #timestamp {
display: ${config.showTime ? 'inline' : 'none'} !important;
${config.timeColor ? `color: ${config.timeColor} !important;` : ''}
font-family: "${cssEscapeStr(config.timeFont)}"${FALLBACK_FONTS};
font-size: ${config.timeFontSize}px !important;
line-height: ${config.timeLineHeight || config.timeFontSize}px !important;
}
/* Badges. */
yt-live-chat-text-message-renderer #author-name[type="owner"],
yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="owner"] {
${config.ownerUserNameColor ? `color: ${config.ownerUserNameColor} !important;` : ''}
}
yt-live-chat-text-message-renderer #author-name[type="moderator"],
yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="moderator"] {
${config.moderatorUserNameColor ? `color: ${config.moderatorUserNameColor} !important;` : ''}
}
yt-live-chat-text-message-renderer #author-name[type="member"],
yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="member"] {
${config.memberUserNameColor ? `color: ${config.memberUserNameColor} !important;` : ''}
}
/* Channel names. */
yt-live-chat-text-message-renderer #author-name {
${config.showUserNames ? '' : 'display: none !important;'}
${config.userNameColor ? `color: ${config.userNameColor} !important;` : ''}
font-family: "${cssEscapeStr(config.userNameFont)}"${FALLBACK_FONTS};
font-size: ${config.userNameFontSize}px !important;
line-height: ${config.userNameLineHeight || config.userNameFontSize}px !important;
}
${getShowColonStyle(config)}
/* Messages. */
yt-live-chat-text-message-renderer #message,
yt-live-chat-text-message-renderer #message * {
${config.messageColor ? `color: ${config.messageColor} !important;` : ''}
font-family: "${cssEscapeStr(config.messageFont)}"${FALLBACK_FONTS};
font-size: ${config.messageFontSize}px !important;
line-height: ${config.messageLineHeight || config.messageFontSize}px !important;
}
${!config.messageOnNewLine ? '' : `yt-live-chat-text-message-renderer #message {
display: block !important;
}`}
/* SuperChat/Fan Funding Messages. */
yt-live-chat-paid-message-renderer #author-name,
yt-live-chat-paid-message-renderer #author-name *,
yt-live-chat-membership-item-renderer #header-content-inner-column,
yt-live-chat-membership-item-renderer #header-content-inner-column * {
${config.firstLineColor ? `color: ${config.firstLineColor} !important;` : ''}
font-family: "${cssEscapeStr(config.firstLineFont)}"${FALLBACK_FONTS};
font-size: ${config.firstLineFontSize}px !important;
line-height: ${config.firstLineLineHeight || config.firstLineFontSize}px !important;
}
yt-live-chat-paid-message-renderer #purchase-amount,
yt-live-chat-paid-message-renderer #purchase-amount *,
yt-live-chat-membership-item-renderer #header-subtext,
yt-live-chat-membership-item-renderer #header-subtext * {
${config.secondLineColor ? `color: ${config.secondLineColor} !important;` : ''}
font-family: "${cssEscapeStr(config.secondLineFont)}"${FALLBACK_FONTS};
font-size: ${config.secondLineFontSize}px !important;
line-height: ${config.secondLineLineHeight || config.secondLineFontSize}px !important;
}
yt-live-chat-paid-message-renderer #content,
yt-live-chat-paid-message-renderer #content * {
${config.scContentColor ? `color: ${config.scContentColor} !important;` : ''}
font-family: "${cssEscapeStr(config.scContentFont)}"${FALLBACK_FONTS};
font-size: ${config.scContentFontSize}px !important;
line-height: ${config.scContentLineHeight || config.scContentFontSize}px !important;
}
yt-live-chat-paid-message-renderer {
margin: 4px 0 !important;
}
yt-live-chat-membership-item-renderer #card,
yt-live-chat-membership-item-renderer #header {
${getShowNewMemberBgStyle(config)}
}
yt-live-chat-text-message-renderer a,
yt-live-chat-membership-item-renderer a {
text-decoration: none !important;
}
yt-live-chat-text-message-renderer[is-deleted],
yt-live-chat-membership-item-renderer[is-deleted] {
display: none !important;
}
yt-live-chat-ticker-renderer {
background-color: transparent !important;
box-shadow: none !important;
}
${config.showScTicker ? '' : `yt-live-chat-ticker-renderer {
display: none !important;
}`}
${config.showOtherThings ? '' : `yt-live-chat-item-list-renderer {
display: none !important;
}`}
yt-live-chat-ticker-paid-message-item-renderer,
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: "${cssEscapeStr(config.secondLineFont)}"${FALLBACK_FONTS};
}
yt-live-chat-mode-change-message-renderer,
yt-live-chat-viewer-engagement-message-renderer,
yt-live-chat-restricted-participation-renderer {
display: none !important;
}
${getAnimationStyle(config)}
`
}
function getImports (config) {
let fontsNeedToImport = new Set()
for (let name of ['userNameFont', 'messageFont', 'timeFont', 'firstLineFont', 'secondLineFont', 'scContentFont']) {
let font = config[name]
if (fonts.NETWORK_FONTS.indexOf(font) !== -1) {
fontsNeedToImport.add(font)
}
}
let res = []
for (let font of fontsNeedToImport) {
res.push(`@import url("https://fonts.googleapis.com/css?family=${encodeURIComponent(font)}");`)
}
return res.join('\n')
}
function getMessageColorStyle (authorType, color, useBarsInsteadOfBg) {
let typeSelector = authorType ? `[author-type="${authorType}"]` : ''
if (!useBarsInsteadOfBg) {
return `yt-live-chat-text-message-renderer${typeSelector},
yt-live-chat-text-message-renderer${typeSelector}[is-highlighted] {
${color ? `background-color: ${color} !important;` : ''}
}`
} else {
return `yt-live-chat-text-message-renderer${typeSelector}::after {
${color ? `border: 2px solid ${color};` : ''}
content: "";
position: absolute;
display: block;
left: 8px;
top: 4px;
bottom: 4px;
width: 1px;
box-sizing: border-box;
border-radius: 2px;
}`
}
}
function getShowOutlinesStyle (config) {
if (!config.showOutlines || !config.outlineSize) {
return ''
}
let shadow = []
for (var x = -config.outlineSize; x <= config.outlineSize; x += Math.ceil(config.outlineSize / 4)) {
for (var y = -config.outlineSize; y <= config.outlineSize; y += Math.ceil(config.outlineSize / 4)) {
shadow.push(`${x}px ${y}px ${config.outlineColor}`)
}
}
return `text-shadow: ${shadow.join(', ')};`
}
function cssEscapeStr (str) {
let res = []
for (let char of str) {
res.push(cssEscapeChar(char))
}
return res.join('')
}
function cssEscapeChar (char) {
if (!needEscapeChar(char)) {
return char
}
let hexCode = char.codePointAt(0).toString(16)
// https://drafts.csswg.org/cssom/#escape-a-character-as-code-point
return `\\${hexCode} `
}
function needEscapeChar (char) {
let code = char.codePointAt(0)
if (0x20 <= code && code <= 0x7E) {
return char === '"'
}
return true
}
function getPaddingStyle (config) {
return `padding-left: ${config.useBarsInsteadOfBg ? 20 : 4}px !important;
padding-right: 4px !important;`
}
function getShowColonStyle (config) {
if (!config.showColon) {
return ''
}
return `yt-live-chat-text-message-renderer #author-name::after {
content: ":";
margin-left: ${config.outlineSize}px;
}`
}
function getShowNewMemberBgStyle (config) {
if (config.showNewMemberBg) {
return `background-color: ${config.memberUserNameColor} !important;
margin: 4px 0 !important;`
} else {
return `background-color: transparent !important;
box-shadow: none !important;
margin: 0 !important;`
}
}
function getAnimationStyle (config) {
if (!config.animateIn && !config.animateOut) {
return ''
}
let totalTime = 0
if (config.animateIn) {
totalTime += config.fadeInTime
}
if (config.animateOut) {
totalTime += config.animateOutWaitTime * 1000
totalTime += config.fadeOutTime
}
let keyframes = []
let curTime = 0
if (config.animateIn) {
keyframes.push(` 0% { opacity: 0;${!config.slide ? ''
: ` transform: translateX(${config.reverseSlide ? 16 : -16}px);`
} }`)
curTime += config.fadeInTime
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; }`)
curTime += config.fadeOutTime
keyframes.push(` ${(curTime / totalTime) * 100}% { opacity: 0;${!config.slide ? ''
: ` transform: translateX(${config.reverseSlide ? -16 : 16}px);`
} }`)
}
return `@keyframes anim {
${keyframes.join('\n')}
}
yt-live-chat-text-message-renderer,
yt-live-chat-membership-item-renderer,
yt-live-chat-paid-message-renderer {
animation: anim ${totalTime}ms;
animation-fill-mode: both;
}`
}

26
frontend/vue.config.js Normal file
View File

@@ -0,0 +1,26 @@
const API_BASE_URL = 'http://localhost:12450'
module.exports = {
devServer: {
proxy: {
'/api': {
target: API_BASE_URL,
ws: true
},
'/emoticons': {
target: API_BASE_URL
}
}
},
chainWebpack: config => {
const APP_VERSION = `v${process.env.npm_package_version}`
config.plugin('define')
.tap(args => {
let defineMap = args[0]
let env = defineMap['process.env']
env['APP_VERSION'] = JSON.stringify(APP_VERSION)
return args
})
}
}

28
main.py
View File

@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
import argparse
import logging
import logging.handlers
@@ -12,24 +11,24 @@ import tornado.web
import api.chat
import api.main
import config
import models.avatar
import models.database
import models.translate
import services.avatar
import services.chat
import services.translate
import update
logger = logging.getLogger(__name__)
BASE_PATH = os.path.dirname(os.path.realpath(__file__))
WEB_ROOT = os.path.join(BASE_PATH, 'frontend', 'dist')
LOG_FILE_NAME = os.path.join(BASE_PATH, 'log', 'blivechat.log')
routes = [
(r'/api/server_info', api.main.ServerInfoHandler),
(r'/api/emoticon', api.main.UploadEmoticonHandler),
(r'/api/chat', api.chat.ChatHandler),
(r'/api/room_info', api.chat.RoomInfoHandler),
(r'/api/avatar_url', api.chat.AvatarHandler),
(r'/(.*)', api.main.MainHandler, {'path': WEB_ROOT, 'default_filename': 'index.html'})
(rf'{api.main.EMOTICON_BASE_URL}/(.*)', tornado.web.StaticFileHandler, {'path': api.main.EMOTICON_UPLOAD_PATH}),
(r'/(.*)', api.main.MainHandler, {'path': config.WEB_ROOT, 'default_filename': 'index.html'})
]
@@ -39,9 +38,9 @@ def main():
init_logging(args.debug)
config.init()
models.database.init(args.debug)
models.avatar.init()
models.translate.init()
api.chat.init()
services.avatar.init()
services.translate.init()
services.chat.init()
update.check_update()
run_server(args.host, args.port, args.debug)
@@ -56,9 +55,10 @@ def parse_args():
def init_logging(debug):
filename = os.path.join(config.BASE_PATH, 'log', 'blivechat.log')
stream_handler = logging.StreamHandler()
file_handler = logging.handlers.TimedRotatingFileHandler(
LOG_FILE_NAME, encoding='utf-8', when='midnight', backupCount=7, delay=True
filename, encoding='utf-8', when='midnight', backupCount=7, delay=True
)
logging.basicConfig(
format='{asctime} {levelname} [{name}]: {message}',
@@ -84,7 +84,9 @@ def run_server(host, port, debug):
app.listen(
port,
host,
xheaders=cfg.tornado_xheaders
xheaders=cfg.tornado_xheaders,
max_body_size=1024 * 1024,
max_buffer_size=1024 * 1024
)
except OSError:
logger.warning('Address is used %s:%d', host, port)

11
models/bilibili.py Normal file
View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
import sqlalchemy
import models.database
class BilibiliUser(models.database.OrmBase):
__tablename__ = 'bilibili_users'
uid = sqlalchemy.Column(sqlalchemy.BigInteger, primary_key=True) # 创建表后最好手动改成unsigned
avatar_url = sqlalchemy.Column(sqlalchemy.String(100))
update_time = sqlalchemy.Column(sqlalchemy.DateTime)

View File

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

View File

@@ -1,432 +0,0 @@
# -*- coding: utf-8 -*-
import asyncio
import functools
import hashlib
import logging
import random
import re
import time
import yarl
from typing import *
import aiohttp
logger = logging.getLogger(__name__)
NO_TRANSLATE_TEXTS = {
'', '草草', '草草草', '草生', '大草原', '上手', '上手上手', '理解', '理解理解', '天才', '天才天才',
'', '余裕', '余裕余裕', '大丈夫', '再放送', '放送事故', '清楚', '清楚清楚'
}
_main_event_loop = asyncio.get_event_loop()
_http_session = aiohttp.ClientSession()
_translate_providers: List['TranslateProvider'] = []
# text -> res
_translate_cache: Dict[str, str] = {}
# 正在翻译的Futuretext -> Future
_text_future_map: Dict[str, asyncio.Future] = {}
def init():
asyncio.ensure_future(_do_init())
async def _do_init():
# 考虑优先级
providers = [
TencentTranslate(),
YoudaoTranslate(),
BilibiliTranslate()
]
await asyncio.gather(*(provider.init() for provider in providers))
global _translate_providers
_translate_providers = providers
def need_translate(text):
text = text.strip()
# 没有中文,平时打不出的字不管
if not any(0x4E00 <= ord(c) <= 0x9FFF for c in text):
return False
# 含有日文假名
if any(0x3040 <= ord(c) <= 0x30FF for c in text):
return False
# 弹幕同传
if text.startswith(''):
return False
# 中日双语
if text in NO_TRANSLATE_TEXTS:
return False
return True
def get_translation_from_cache(text):
key = text.strip().lower()
return _translate_cache.get(key, None)
def translate(text) -> Awaitable[Optional[str]]:
key = text.strip().lower()
# 如果已有正在翻译的future则返回防止重复翻译
future = _text_future_map.get(key, None)
if future is not None:
return future
# 否则创建一个翻译任务
future = _main_event_loop.create_future()
# 查缓存
res = _translate_cache.get(key, None)
if res is not None:
future.set_result(res)
return future
for provider in _translate_providers:
if provider.is_available:
_text_future_map[key] = future
future.add_done_callback(functools.partial(_on_translate_done, key))
provider.translate(text, future)
return future
future.set_result(None)
return future
def _on_translate_done(key, future):
_text_future_map.pop(key, None)
# 缓存
try:
res = future.result()
except Exception:
return
if res is None:
return
_translate_cache[key] = res
while len(_translate_cache) > 50000:
_translate_cache.pop(next(iter(_translate_cache)), None)
class TranslateProvider:
async def init(self):
return True
@property
def is_available(self):
return True
def translate(self, text, future):
raise NotImplementedError
class TencentTranslate(TranslateProvider):
def __init__(self):
# 过期时间1小时
self._qtv = ''
self._qtk = ''
self._reinit_future = None
# 连续失败的次数
self._fail_count = 0
self._cool_down_future = None
async def init(self):
self._reinit_future = asyncio.ensure_future(self._reinit_coroutine())
return await self._do_init()
async def _do_init(self):
try:
async with _http_session.get('https://fanyi.qq.com/') as r:
if r.status != 200:
logger.warning('TencentTranslate init request failed: status=%d %s', r.status, r.reason)
return False
html = await r.text()
m = re.search(r"""\breauthuri\s*=\s*['"](.+?)['"]""", html)
if m is None:
logger.exception('TencentTranslate init failed: reauthuri not found')
return False
reauthuri = m[1]
async with _http_session.post('https://fanyi.qq.com/api/' + reauthuri) as r:
if r.status != 200:
logger.warning('TencentTranslate init request failed: reauthuri=%s, status=%d %s',
reauthuri, r.status, r.reason)
return False
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
logger.exception('TencentTranslate init error:')
return False
qtv = data.get('qtv', None)
if qtv is None:
logger.warning('TencentTranslate init failed: qtv not found')
return False
qtk = data.get('qtk', None)
if qtk is None:
logger.warning('TencentTranslate init failed: qtk not found')
return False
self._qtv = qtv
self._qtk = qtk
return True
async def _reinit_coroutine(self):
try:
while True:
await asyncio.sleep(30)
while True:
logger.debug('TencentTranslate reinit')
try:
if await self._do_init():
break
except Exception:
logger.exception('TencentTranslate init error:')
await asyncio.sleep(3 * 60)
except asyncio.CancelledError:
pass
@property
def is_available(self):
return self._qtv != '' and self._qtk != ''
def translate(self, text, future):
asyncio.ensure_future(self._translate_coroutine(text, future))
async def _translate_coroutine(self, text, future):
try:
res = await self._do_translate(text)
except BaseException as e:
future.set_exception(e)
self._on_fail()
return
future.set_result(res)
if res is None:
self._on_fail()
else:
self._fail_count = 0
async def _do_translate(self, text):
try:
async with _http_session.post(
'https://fanyi.qq.com/api/translate',
headers={
'Referer': 'https://fanyi.qq.com/'
},
data={
'source': 'zh',
'target': 'jp',
'sourceText': text,
'qtv': self._qtv,
'qtk': self._qtk
}
) as r:
if r.status != 200:
logger.warning('TencentTranslate request failed: status=%d %s', r.status, r.reason)
return None
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
if data['errCode'] != 0:
logger.warning('TencentTranslate failed: %d %s', data['errCode'], data['errMsg'])
return None
res = ''.join(record['targetText'] for record in data['translate']['records'])
if res == '' and text.strip() != '':
# qtv、qtk过期
logger.warning('TencentTranslate result is empty %s', data)
return None
return res
def _on_fail(self):
self._fail_count += 1
# 目前没有测试出被ban的情况为了可靠性连续失败20次时冷却并重新init
if self._fail_count >= 20 and self._cool_down_future is None:
self._cool_down_future = asyncio.ensure_future(self._cool_down())
async def _cool_down(self):
logger.info('TencentTranslate is cooling down')
self._qtv = self._qtk = ''
try:
while True:
await asyncio.sleep(3 * 60)
logger.info('TencentTranslate reinit')
try:
if await self._do_init():
self._fail_count = 0
break
except Exception:
logger.exception('TencentTranslate init error:')
finally:
logger.info('TencentTranslate finished cooling down')
self._cool_down_future = None
class YoudaoTranslate(TranslateProvider):
def __init__(self):
self._has_init = False
self._cool_down_future = None
async def init(self):
# 获取cookie
try:
async with _http_session.get('http://fanyi.youdao.com/') as r:
if r.status >= 400:
logger.warning('YoudaoTranslate init request failed: status=%d %s', r.status, r.reason)
return False
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return False
cookies = _http_session.cookie_jar.filter_cookies(yarl.URL('http://fanyi.youdao.com/'))
res = 'JSESSIONID' in cookies and 'OUTFOX_SEARCH_USER_ID' in cookies
if res:
self._has_init = True
return res
@property
def is_available(self):
return self._has_init
def translate(self, text, future):
asyncio.ensure_future(self._translate_coroutine(text, future))
async def _translate_coroutine(self, text, future):
try:
res = await self._do_translate(text)
except BaseException as e:
future.set_exception(e)
else:
future.set_result(res)
async def _do_translate(self, text):
try:
async with _http_session.post(
'http://fanyi.youdao.com/translate_o?smartresult=dict&smartresult=rule',
headers={
'Referer': 'http://fanyi.youdao.com/'
},
data={
'i': text,
'from': 'zh-CHS',
'to': 'ja',
'smartresult': 'dict',
'client': 'fanyideskweb',
**self._generate_salt(text),
'doctype': 'json',
'version': '2.1',
'keyfrom': 'fanyi.web',
'action': 'FY_BY_REALTlME'
}
) as r:
if r.status != 200:
logger.warning('YoudaoTranslate request failed: status=%d %s', r.status, r.reason)
return None
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
except aiohttp.ContentTypeError:
# 被ban了
if self._cool_down_future is None:
self._cool_down_future = asyncio.ensure_future(self._cool_down())
return None
if data['errorCode'] != 0:
logger.warning('YoudaoTranslate failed: %d', data['errorCode'])
return None
res = []
for outer_result in data['translateResult']:
for inner_result in outer_result:
res.append(inner_result['tgt'])
return ''.join(res)
@staticmethod
def _generate_salt(text):
timestamp = int(time.time() * 1000)
salt = f'{timestamp}{random.randint(0, 9)}'
md5 = hashlib.md5()
md5.update(f'fanyideskweb{text}{salt}n%A-rKaT5fb[Gy?;N5@Tj'.encode())
sign = md5.hexdigest()
return {
'ts': timestamp,
'bv': '7bcd9ea3ff9b319782c2a557acee9179', # md5(navigator.appVersion)
'salt': salt,
'sign': sign
}
async def _cool_down(self):
logger.info('YoudaoTranslate is cooling down')
self._has_init = False
try:
while True:
await asyncio.sleep(3 * 60)
try:
is_success = await self.init()
except Exception:
logger.exception('YoudaoTranslate init error:')
continue
if is_success:
break
finally:
logger.info('YoudaoTranslate finished cooling down')
self._cool_down_future = None
# 目前B站后端是百度翻译
class BilibiliTranslate(TranslateProvider):
def __init__(self):
# 最长等待时间大约21秒(text, future)
self._text_queue = asyncio.Queue(7)
async def init(self):
asyncio.ensure_future(self._translate_consumer())
return True
@property
def is_available(self):
return not self._text_queue.full()
def translate(self, text, future):
try:
self._text_queue.put_nowait((text, future))
except asyncio.QueueFull:
future.set_result(None)
async def _translate_consumer(self):
while True:
try:
text, future = await self._text_queue.get()
asyncio.ensure_future(self._translate_coroutine(text, future))
# 频率限制一分钟20次
await asyncio.sleep(3.1)
except Exception:
logger.exception('BilibiliTranslate error:')
async def _translate_coroutine(self, text, future):
try:
res = await self._do_translate(text)
except BaseException as e:
future.set_exception(e)
else:
future.set_result(res)
@staticmethod
async def _do_translate(text):
try:
async with _http_session.get(
'https://api.live.bilibili.com/av/v1/SuperChat/messageTranslate',
params={
'room_id': '21396545',
'ruid': '407106379',
'parent_area_id': '9',
'area_id': '371',
'msg': text
}
) as r:
if r.status != 200:
logger.warning('BilibiliTranslate request failed: status=%d %s', r.status, r.reason)
return None
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
if data['code'] != 0:
logger.warning('BilibiliTranslate failed: %d %s', data['code'], data['msg'])
return None
return data['data']['message_trans']

View File

@@ -1,3 +1,5 @@
aiohttp==3.5.4
sqlalchemy==1.3.13
tornado==6.0.2
aiohttp==3.7.4
Brotli==1.0.9
pycryptodome==3.10.1
sqlalchemy==1.4.31
tornado==6.3.2

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 299 KiB

View File

@@ -1,16 +1,20 @@
# -*- coding: utf-8 -*-
import asyncio
import datetime
import hashlib
import logging
import re
import urllib.parse
from typing import *
import aiohttp
import sqlalchemy
import sqlalchemy.exc
import config
import models.bilibili as bl_models
import models.database
import utils.request
logger = logging.getLogger(__name__)
@@ -18,18 +22,28 @@ 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] = {}
# 正在获取头像的Futureuser_id -> Future
_uid_fetch_future_map: Dict[int, asyncio.Future] = {}
# 正在获取头像的user_id队列
_uid_queue_to_fetch = asyncio.Queue(15)
_uid_queue_to_fetch: Optional[asyncio.Queue] = None
# 上次被B站ban时间
_last_fetch_banned_time: Optional[datetime.datetime] = None
# wbi密码表
WBI_KEY_INDEX_TABLE = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13
]
# wbi鉴权口令
_wbi_key = ''
def init():
cfg = config.get_config()
global _uid_queue_to_fetch
_uid_queue_to_fetch = asyncio.Queue(cfg.fetch_avatar_max_queue_size)
asyncio.ensure_future(_get_avatar_url_from_web_consumer())
@@ -41,6 +55,9 @@ async def get_avatar_url(user_id):
async def get_avatar_url_or_none(user_id):
if user_id == 0:
return None
avatar_url = get_avatar_url_from_memory(user_id)
if avatar_url is not None:
return avatar_url
@@ -63,7 +80,9 @@ def get_avatar_url_from_database(user_id) -> Awaitable[Optional[str]]:
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()
user = session.query(bl_models.BilibiliUser).filter(
bl_models.BilibiliUser.uid == user_id
).one_or_none()
if user is None:
return None
avatar_url = user.avatar_url
@@ -124,8 +143,9 @@ async def _get_avatar_url_from_web_consumer():
asyncio.ensure_future(_get_avatar_url_from_web_coroutine(user_id, future))
# 限制频率防止被B站ban
await asyncio.sleep(0.2)
except Exception:
cfg = config.get_config()
await asyncio.sleep(cfg.fetch_avatar_interval)
except Exception: # noqa
logger.exception('_get_avatar_url_from_web_consumer error:')
@@ -139,9 +159,21 @@ async def _get_avatar_url_from_web_coroutine(user_id, future):
async def _do_get_avatar_url_from_web(user_id):
global _wbi_key
if _wbi_key == '':
# TODO 判断一下是否正在获取
_wbi_key = await _get_wbi_key()
try:
async with _http_session.get('https://api.bilibili.com/x/space/acc/info',
params={'mid': user_id}) as r:
async with utils.request.http_session.get(
'https://api.bilibili.com/x/space/wbi/acc/info',
headers={
**utils.request.BILIBILI_COMMON_HEADERS,
'Origin': 'https://space.bilibili.com',
'Referer': f'https://space.bilibili.com/{user_id}/'
},
params=_add_wbi_sign({'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:
@@ -153,11 +185,78 @@ async def _do_get_avatar_url_from_web(user_id):
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
if data['code'] != 0:
# 这里虽然失败但不会被ban一段时间
logger.info('Failed to fetch avatar: code=%d %s uid=%d', data['code'], data['message'], user_id)
if data['code'] == -403:
_wbi_key = ''
return None
avatar_url = process_avatar_url(data['data']['face'])
update_avatar_cache(user_id, avatar_url)
return avatar_url
async def _get_wbi_key():
try:
async with utils.request.http_session.get(
'https://api.bilibili.com/nav',
headers=utils.request.BILIBILI_COMMON_HEADERS,
) as r:
if r.status != 200:
logger.warning('Failed to get wbi key: status=%d %s', r.status, r.reason)
return ''
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
logger.exception('Failed to get wbi key:')
return ''
try:
wbi_img = data['data']['wbi_img']
img_key = wbi_img['img_url'].rpartition('/')[2].partition('.')[0]
sub_key = wbi_img['sub_url'].rpartition('/')[2].partition('.')[0]
except KeyError:
logger.warning('Failed to get wbi key: data=%s', data)
return ''
shuffled_key = img_key + sub_key
wbi_key = []
for index in WBI_KEY_INDEX_TABLE:
if index < len(shuffled_key):
wbi_key.append(shuffled_key[index])
return ''.join(wbi_key)
def _add_wbi_sign(params: dict):
if _wbi_key == '':
return params
wts = str(int(datetime.datetime.now().timestamp()))
params_to_sign = {**params, 'wts': wts}
# 按key字典序排序
params_to_sign = {
key: params_to_sign[key]
for key in sorted(params_to_sign.keys())
}
# 过滤一些字符
for key, value in params_to_sign.items():
value = ''.join(
ch
for ch in str(value)
if ch not in "!'()*"
)
params_to_sign[key] = value
str_to_sign = urllib.parse.urlencode(params_to_sign) + _wbi_key
w_rid = hashlib.md5(str_to_sign.encode('utf-8')).hexdigest()
return {
**params,
'wts': wts,
'w_rid': w_rid
}
def process_avatar_url(avatar_url):
# 去掉协议兼容HTTP、HTTPS
m = re.fullmatch(r'(?:https?:)?(.*)', avatar_url)
@@ -178,31 +277,27 @@ def update_avatar_cache(user_id, avatar_url):
def _update_avatar_cache_in_memory(user_id, avatar_url):
_avatar_url_cache[user_id] = avatar_url
while len(_avatar_url_cache) > 50000:
cfg = config.get_config()
while len(_avatar_url_cache) > cfg.avatar_cache_size:
_avatar_url_cache.pop(next(iter(_avatar_url_cache)), None)
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()
user = session.query(bl_models.BilibiliUser).filter(
bl_models.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())
user = bl_models.BilibiliUser(
uid=user_id
)
session.add(user)
else:
user.avatar_url = avatar_url
user.update_time = datetime.datetime.now()
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.String(100))
update_time = sqlalchemy.Column(sqlalchemy.DateTime)

456
services/chat.py Normal file
View File

@@ -0,0 +1,456 @@
# -*- coding: utf-8 -*-
import asyncio
import logging
import uuid
from typing import *
import api.chat
import blivedm.blivedm as blivedm
import config
import services.avatar
import services.translate
import utils.request
logger = logging.getLogger(__name__)
# 到B站的连接管理
_live_client_manager: Optional['LiveClientManager'] = None
# 到客户端的连接管理
client_room_manager: Optional['ClientRoomManager'] = None
# 直播消息处理器
_live_msg_handler: Optional['LiveMsgHandler'] = None
def init():
global _live_client_manager, client_room_manager, _live_msg_handler
_live_client_manager = LiveClientManager()
client_room_manager = ClientRoomManager()
_live_msg_handler = LiveMsgHandler()
class LiveClientManager:
"""管理到B站的连接"""
def __init__(self):
self._live_clients: Dict[int, LiveClient] = {}
def add_live_client(self, room_id):
if room_id in self._live_clients:
return
logger.info('room=%d creating live client', room_id)
self._live_clients[room_id] = live_client = LiveClient(room_id)
live_client.add_handler(_live_msg_handler)
asyncio.ensure_future(self._init_live_client(live_client))
logger.info('room=%d live client created, %d live clients', room_id, len(self._live_clients))
async def _init_live_client(self, live_client: 'LiveClient'):
if not await live_client.init_room():
logger.warning('room=%d live client init failed', live_client.tmp_room_id)
self.del_live_client(live_client.tmp_room_id)
return
logger.info('room=%d (%d) live client init succeeded', live_client.tmp_room_id, live_client.room_id)
live_client.start()
def del_live_client(self, room_id):
live_client = self._live_clients.pop(room_id, None)
if live_client is None:
return
logger.info('room=%d removing live client', room_id)
live_client.remove_handler(_live_msg_handler)
asyncio.ensure_future(live_client.stop_and_close())
logger.info('room=%d live client removed, %d live clients', room_id, len(self._live_clients))
client_room_manager.del_room(room_id)
class LiveClient(blivedm.BLiveClient):
HEARTBEAT_INTERVAL = 10
def __init__(self, room_id):
super().__init__(room_id, session=utils.request.http_session, heartbeat_interval=self.HEARTBEAT_INTERVAL)
@property
def tmp_room_id(self):
"""初始化参数传入的房间IDroom_id可能改变这个不会变"""
return self._tmp_room_id
async def init_room(self):
await super().init_room()
return True
class ClientRoomManager:
"""管理到客户端的连接"""
# 房间没有客户端后延迟多久删除房间,不立即删除防止短时间后重连
DELAY_DEL_ROOM_TIMEOUT = 10
def __init__(self):
self._rooms: Dict[int, ClientRoom] = {}
# room_id -> timer_handle
self._delay_del_timer_handles: Dict[int, asyncio.TimerHandle] = {}
def add_client(self, room_id, client: 'api.chat.ChatHandler'):
room = self._get_or_add_room(room_id)
room.add_client(client)
self._clear_delay_del_timer(room_id)
def del_client(self, room_id, client: 'api.chat.ChatHandler'):
room = self.get_room(room_id)
if room is None:
return
room.del_client(client)
if room.client_count == 0:
self.delay_del_room(room_id, self.DELAY_DEL_ROOM_TIMEOUT)
def get_room(self, room_id):
return self._rooms.get(room_id, None)
def _get_or_add_room(self, room_id):
room = self._rooms.get(room_id, None)
if room is None:
logger.info('room=%d creating client room', room_id)
self._rooms[room_id] = room = ClientRoom(room_id)
logger.info('room=%d client room created, %d client rooms', room_id, len(self._rooms))
_live_client_manager.add_live_client(room_id)
return room
def del_room(self, room_id):
self._clear_delay_del_timer(room_id)
room = self._rooms.pop(room_id, None)
if room is None:
return
logger.info('room=%d removing client room', room_id)
room.clear_clients()
logger.info('room=%d client room removed, %d client rooms', room_id, len(self._rooms))
_live_client_manager.del_live_client(room_id)
def delay_del_room(self, room_id, timeout):
self._clear_delay_del_timer(room_id)
self._delay_del_timer_handles[room_id] = asyncio.get_event_loop().call_later(
timeout, self._on_delay_del_room, room_id
)
def _clear_delay_del_timer(self, room_id):
timer_handle = self._delay_del_timer_handles.pop(room_id, None)
if timer_handle is not None:
timer_handle.cancel()
def _on_delay_del_room(self, room_id):
self._delay_del_timer_handles.pop(room_id, None)
self.del_room(room_id)
class ClientRoom:
def __init__(self, room_id):
self._room_id = room_id
self._clients: List[api.chat.ChatHandler] = []
self._auto_translate_count = 0
@property
def room_id(self):
return self._room_id
@property
def client_count(self):
return len(self._clients)
@property
def need_translate(self):
return self._auto_translate_count > 0
def add_client(self, client: 'api.chat.ChatHandler'):
logger.info('room=%d addding client %s', self._room_id, client.request.remote_ip)
self._clients.append(client)
if client.auto_translate:
self._auto_translate_count += 1
logger.info('room=%d added client %s, %d clients', self._room_id, client.request.remote_ip,
self.client_count)
def del_client(self, client: 'api.chat.ChatHandler'):
client.close()
try:
self._clients.remove(client)
except ValueError:
return
if client.auto_translate:
self._auto_translate_count -= 1
logger.info('room=%d removed client %s, %d clients', self._room_id, client.request.remote_ip,
self.client_count)
def clear_clients(self):
logger.info('room=%d clearing %d clients', self._room_id, self.client_count)
for client in self._clients:
client.close()
self._clients.clear()
self._auto_translate_count = 0
def send_cmd_data(self, cmd, data):
body = api.chat.make_message_body(cmd, data)
for client in self._clients:
client.send_body_no_raise(body)
def send_cmd_data_if(self, filterer: Callable[['api.chat.ChatHandler'], bool], cmd, data):
body = api.chat.make_message_body(cmd, data)
for client in filter(filterer, self._clients):
client.send_body_no_raise(body)
class LiveMsgHandler(blivedm.BaseHandler):
# 重新定义XXX_callback是为了减少对字段名的依赖防止B站改字段名
def __danmu_msg_callback(self, client: LiveClient, command: dict):
info = command['info']
if len(info[3]) != 0:
medal_level = info[3][0]
medal_room_id = info[3][3]
else:
medal_level = 0
medal_room_id = 0
message = blivedm.DanmakuMessage(
timestamp=info[0][4],
msg_type=info[0][9],
dm_type=info[0][12],
emoticon_options=info[0][13],
msg=info[1],
uid=info[2][0],
uname=info[2][1],
admin=info[2][2],
urank=info[2][5],
mobile_verify=info[2][6],
medal_level=medal_level,
medal_room_id=medal_room_id,
user_level=info[4][0],
privilege_type=info[7],
)
return self._on_danmaku(client, message)
def __send_gift_callback(self, client: LiveClient, command: dict):
data = command['data']
message = blivedm.GiftMessage(
gift_name=data['giftName'],
num=data['num'],
uname=data['uname'],
face=data['face'],
uid=data['uid'],
timestamp=data['timestamp'],
coin_type=data['coin_type'],
total_coin=data['total_coin'],
)
return self._on_gift(client, message)
def __guard_buy_callback(self, client: LiveClient, command: dict):
data = command['data']
message = blivedm.GuardBuyMessage(
uid=data['uid'],
username=data['username'],
guard_level=data['guard_level'],
start_time=data['start_time'],
)
return self._on_buy_guard(client, message)
def __super_chat_message_callback(self, client: LiveClient, command: dict):
data = command['data']
message = blivedm.SuperChatMessage(
price=data['price'],
message=data['message'],
start_time=data['start_time'],
id=data['id'],
uid=data['uid'],
uname=data['user_info']['uname'],
face=data['user_info']['face'],
)
return self._on_super_chat(client, message)
_CMD_CALLBACK_DICT = {
**blivedm.BaseHandler._CMD_CALLBACK_DICT,
'DANMU_MSG': __danmu_msg_callback,
'SEND_GIFT': __send_gift_callback,
'GUARD_BUY': __guard_buy_callback,
'SUPER_CHAT_MESSAGE': __super_chat_message_callback
}
async def _on_danmaku(self, client: LiveClient, message: blivedm.DanmakuMessage):
asyncio.ensure_future(self.__on_danmaku(client, message))
async def __on_danmaku(self, client: LiveClient, message: blivedm.DanmakuMessage):
# 先异步调用再获取房间,因为返回时房间可能已经不存在了
avatar_url = await services.avatar.get_avatar_url(message.uid)
room = client_room_manager.get_room(client.tmp_room_id)
if room is None:
return
if message.uid == client.room_owner_uid:
author_type = 3 # 主播
elif message.admin:
author_type = 2 # 房管
elif message.privilege_type != 0: # 1总督2提督3舰长
author_type = 1 # 舰队
else:
author_type = 0
if message.dm_type == 1:
content_type = api.chat.ContentType.EMOTICON
content_type_params = api.chat.make_emoticon_params(
message.emoticon_options_dict['url'],
)
else:
content_type = api.chat.ContentType.TEXT
content_type_params = None
need_translate = self._need_translate(message.msg, room)
if need_translate:
translation = services.translate.get_translation_from_cache(message.msg)
if translation is None:
# 没有缓存,需要后面异步翻译后通知
translation = ''
else:
need_translate = False
else:
translation = ''
msg_id = uuid.uuid4().hex
room.send_cmd_data(api.chat.Command.ADD_TEXT, api.chat.make_text_message_data(
avatar_url=avatar_url,
timestamp=int(message.timestamp / 1000),
author_name=message.uname,
author_type=author_type,
content=message.msg,
privilege_type=message.privilege_type,
is_gift_danmaku=bool(message.msg_type),
author_level=message.user_level,
is_newbie=message.urank < 10000,
is_mobile_verified=bool(message.mobile_verify),
medal_level=0 if message.medal_room_id != client.room_id else message.medal_level,
id_=msg_id,
translation=translation,
content_type=content_type,
content_type_params=content_type_params,
))
if need_translate:
await self._translate_and_response(message.msg, room.room_id, msg_id)
async def _on_gift(self, client: LiveClient, message: blivedm.GiftMessage):
avatar_url = services.avatar.process_avatar_url(message.face)
# 服务器白给的头像URL直接缓存
services.avatar.update_avatar_cache(message.uid, avatar_url)
# 丢人
if message.coin_type != 'gold':
return
room = client_room_manager.get_room(client.tmp_room_id)
if room is None:
return
room.send_cmd_data(api.chat.Command.ADD_GIFT, {
'id': uuid.uuid4().hex,
'avatarUrl': avatar_url,
'timestamp': message.timestamp,
'authorName': message.uname,
'totalCoin': message.total_coin,
'giftName': message.gift_name,
'num': message.num
})
async def _on_buy_guard(self, client: LiveClient, message: blivedm.GuardBuyMessage):
asyncio.ensure_future(self.__on_buy_guard(client, message))
@staticmethod
async def __on_buy_guard(client: LiveClient, message: blivedm.GuardBuyMessage):
# 先异步调用再获取房间,因为返回时房间可能已经不存在了
avatar_url = await services.avatar.get_avatar_url(message.uid)
room = client_room_manager.get_room(client.tmp_room_id)
if room is None:
return
room.send_cmd_data(api.chat.Command.ADD_MEMBER, {
'id': uuid.uuid4().hex,
'avatarUrl': avatar_url,
'timestamp': message.start_time,
'authorName': message.username,
'privilegeType': message.guard_level
})
async def _on_super_chat(self, client: LiveClient, message: blivedm.SuperChatMessage):
avatar_url = services.avatar.process_avatar_url(message.face)
# 服务器白给的头像URL直接缓存
services.avatar.update_avatar_cache(message.uid, avatar_url)
room = client_room_manager.get_room(client.tmp_room_id)
if room is None:
return
need_translate = self._need_translate(message.message, room)
if need_translate:
translation = services.translate.get_translation_from_cache(message.message)
if translation is None:
# 没有缓存,需要后面异步翻译后通知
translation = ''
else:
need_translate = False
else:
translation = ''
msg_id = str(message.id)
room.send_cmd_data(api.chat.Command.ADD_SUPER_CHAT, {
'id': msg_id,
'avatarUrl': avatar_url,
'timestamp': message.start_time,
'authorName': message.uname,
'price': message.price,
'content': message.message,
'translation': translation
})
if need_translate:
asyncio.ensure_future(self._translate_and_response(message.message, room.room_id, msg_id))
async def _on_super_chat_delete(self, client: LiveClient, message: blivedm.SuperChatDeleteMessage):
room = client_room_manager.get_room(client.tmp_room_id)
if room is None:
return
room.send_cmd_data(api.chat.Command.DEL_SUPER_CHAT, {
'ids': list(map(str, message.ids))
})
@staticmethod
def _need_translate(text, room: ClientRoom):
cfg = config.get_config()
return (
cfg.enable_translate
and room.need_translate
and (not cfg.allow_translate_rooms or room.room_id in cfg.allow_translate_rooms)
and services.translate.need_translate(text)
)
@staticmethod
async def _translate_and_response(text, room_id, msg_id):
translation = await services.translate.translate(text)
if translation is None:
return
room = client_room_manager.get_room(room_id)
if room is None:
return
room.send_cmd_data_if(
lambda client: client.auto_translate,
api.chat.Command.UPDATE_TRANSLATION,
api.chat.make_translation_message_data(
msg_id,
translation
)
)

585
services/translate.py Normal file
View File

@@ -0,0 +1,585 @@
# -*- coding: utf-8 -*-
import asyncio
import base64
import datetime
import functools
import hashlib
import hmac
import json
import logging
import random
import re
from typing import *
import Crypto.Cipher.AES as cry_aes # noqa
import Crypto.Util.Padding as cry_pad # noqa
import aiohttp
import config
import utils.request
logger = logging.getLogger(__name__)
NO_TRANSLATE_TEXTS = {
'', '草草', '草草草', '草生', '大草原', '上手', '上手上手', '理解', '理解理解', '天才', '天才天才',
'', '余裕', '余裕余裕', '大丈夫', '再放送', '放送事故', '清楚', '清楚清楚'
}
_translate_providers: List['TranslateProvider'] = []
# text -> res
_translate_cache: Dict[str, str] = {}
# 正在翻译的Futuretext -> Future
_text_future_map: Dict[str, asyncio.Future] = {}
def init():
asyncio.ensure_future(_do_init())
async def _do_init():
cfg = config.get_config()
if not cfg.enable_translate:
return
providers = []
for trans_cfg in cfg.translator_configs:
provider = create_translate_provider(trans_cfg)
if provider is not None:
providers.append(provider)
await asyncio.gather(*(provider.init() for provider in providers))
global _translate_providers
_translate_providers = providers
def create_translate_provider(cfg):
type_ = cfg['type']
if type_ == 'TencentTranslateFree':
return TencentTranslateFree(
cfg['query_interval'], cfg['max_queue_size'], cfg['source_language'],
cfg['target_language']
)
elif type_ == 'TencentTranslate':
return TencentTranslate(
cfg['query_interval'], cfg['max_queue_size'], cfg['source_language'],
cfg['target_language'], cfg['secret_id'], cfg['secret_key'],
cfg['region']
)
elif type_ == 'BaiduTranslate':
return BaiduTranslate(
cfg['query_interval'], cfg['max_queue_size'], cfg['source_language'],
cfg['target_language'], cfg['app_id'], cfg['secret']
)
return None
def need_translate(text):
text = text.strip()
# 没有中文,平时打不出的字不管
if not any(0x4E00 <= ord(c) <= 0x9FFF for c in text):
return False
# 含有日文假名
if any(0x3040 <= ord(c) <= 0x30FF for c in text):
return False
# 弹幕同传
if '' in text:
return False
# 中日双语
if text in NO_TRANSLATE_TEXTS:
return False
return True
def get_translation_from_cache(text):
key = text.strip().lower()
return _translate_cache.get(key, None)
def translate(text) -> Awaitable[Optional[str]]:
key = text.strip().lower()
# 如果已有正在翻译的future则返回防止重复翻译
future = _text_future_map.get(key, None)
if future is not None:
return future
# 否则创建一个翻译任务
future = asyncio.get_event_loop().create_future()
# 查缓存
res = _translate_cache.get(key, None)
if res is not None:
future.set_result(res)
return future
# 负载均衡找等待时间最少的provider
min_wait_time = None
min_wait_time_provider = None
for provider in _translate_providers:
if not provider.is_available:
continue
wait_time = provider.wait_time
if min_wait_time is None or wait_time < min_wait_time:
min_wait_time = wait_time
min_wait_time_provider = provider
# 没有可用的
if min_wait_time_provider is None:
future.set_result(None)
return future
_text_future_map[key] = future
future.add_done_callback(functools.partial(_on_translate_done, key))
min_wait_time_provider.translate(text, future)
return future
def _on_translate_done(key, future):
_text_future_map.pop(key, None)
# 缓存
try:
res = future.result()
except Exception: # noqa
return
if res is None:
return
_translate_cache[key] = res
cfg = config.get_config()
while len(_translate_cache) > cfg.translation_cache_size:
_translate_cache.pop(next(iter(_translate_cache)), None)
class TranslateProvider:
async def init(self):
return True
@property
def is_available(self):
return True
@property
def wait_time(self):
return 0
def translate(self, text, future):
raise NotImplementedError
class FlowControlTranslateProvider(TranslateProvider):
def __init__(self, query_interval, max_queue_size):
self._query_interval = query_interval
# (text, future)
self._text_queue = asyncio.Queue(max_queue_size)
async def init(self):
asyncio.ensure_future(self._translate_consumer())
return True
@property
def is_available(self):
return not self._text_queue.full()
@property
def wait_time(self):
return self._text_queue.qsize() * self._query_interval
def translate(self, text, future):
try:
self._text_queue.put_nowait((text, future))
except asyncio.QueueFull:
future.set_result(None)
async def _translate_consumer(self):
while True:
try:
text, future = await self._text_queue.get()
asyncio.ensure_future(self._translate_coroutine(text, future))
# 频率限制
await asyncio.sleep(self._query_interval)
except Exception: # noqa
logger.exception('FlowControlTranslateProvider error:')
async def _translate_coroutine(self, text, future):
try:
res = await self._do_translate(text)
except BaseException as e:
future.set_exception(e)
else:
future.set_result(res)
async def _do_translate(self, text):
raise NotImplementedError
class TencentTranslateFree(FlowControlTranslateProvider):
def __init__(self, query_interval, max_queue_size, source_language, target_language):
super().__init__(query_interval, max_queue_size)
self._source_language = source_language
self._target_language = target_language
self._server_time_delta = 0
self._uc_key = self._uc_iv = ''
self._qtv = self._qtk = ''
self._reinit_future = None
# 连续失败的次数
self._fail_count = 0
async def init(self):
if not await super().init():
return False
if not await self._do_init():
return False
self._reinit_future = asyncio.ensure_future(self._reinit_coroutine())
return True
async def _do_init(self):
try:
async with utils.request.http_session.get('https://fanyi.qq.com/') as r:
if r.status != 200:
logger.warning('TencentTranslateFree init request failed: status=%d %s', r.status, r.reason)
return False
html = await r.text()
try:
server_time = r.headers['Date']
server_time = datetime.datetime.strptime(server_time, '%a, %d %b %Y %H:%M:%S GMT')
server_time = server_time.replace(tzinfo=datetime.timezone.utc).timestamp()
self._server_time_delta = int((datetime.datetime.now().timestamp() - server_time) * 1000)
except (KeyError, ValueError):
self._server_time_delta = 0
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
logger.exception('TencentTranslateFree init error:')
return False
# 获取token URL
m = re.search(r"""\breauthuri\s*=\s*['"](.+?)['"]""", html)
if m is None:
logger.exception('TencentTranslateFree init failed: reauthuri not found')
return False
reauthuri = m[1]
# 获取验证用的key、iv
m = re.search(r"""\s*=\s*['"]((?:\w+\|\w+-)+\w+\|\w+)['"]""", html)
if m is None:
logger.exception('TencentTranslateFree init failed: initial global variables not found')
return False
uc_key = None
uc_iv = None
for item in m[1].split('-'):
key, _, value = item.partition('|')
if key == 'a137':
uc_key = value
elif key == 'E74':
uc_iv = value
if uc_key is not None and uc_iv is not None:
break
# 获取token
try:
async with utils.request.http_session.post('https://fanyi.qq.com/api/' + reauthuri) as r:
if r.status != 200:
logger.warning('TencentTranslateFree init request failed: reauthuri=%s, status=%d %s',
reauthuri, r.status, r.reason)
return False
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
logger.exception('TencentTranslateFree init error:')
return False
qtv = data.get('qtv', None)
if qtv is None:
logger.warning('TencentTranslateFree init failed: qtv not found')
return False
qtk = data.get('qtk', None)
if qtk is None:
logger.warning('TencentTranslateFree init failed: qtk not found')
return False
self._uc_key = uc_key
self._uc_iv = uc_iv
self._qtv = qtv
self._qtk = qtk
return True
async def _reinit_coroutine(self):
try:
while True:
await asyncio.sleep(30)
logger.debug('TencentTranslateFree reinit')
asyncio.ensure_future(self._do_init())
except asyncio.CancelledError:
pass
@property
def is_available(self):
return '' not in (self._uc_key, self._uc_iv, self._qtv, self._qtk) and super().is_available
async def _translate_coroutine(self, text, future):
try:
res = await self._do_translate(text)
except BaseException as e:
future.set_exception(e)
self._on_fail()
return
future.set_result(res)
if res is None:
self._on_fail()
else:
self._fail_count = 0
async def _do_translate(self, text):
try:
async with utils.request.http_session.post(
'https://fanyi.qq.com/api/translate',
headers={
'Referer': 'https://fanyi.qq.com/',
'uc': self._get_uc()
},
data={
'source': self._source_language,
'target': self._target_language,
'sourceText': text,
'qtv': self._qtv,
'qtk': self._qtk
}
) as r:
if r.status != 200:
logger.warning('TencentTranslateFree request failed: status=%d %s', r.status, r.reason)
return None
self._update_uc_key(r)
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
if data['errCode'] != 0:
logger.warning('TencentTranslateFree failed: %d %s', data['errCode'], data['errMsg'])
return None
res = ''.join(record['targetText'] for record in data['translate']['records'])
if res == '' and text.strip() != '':
# qtv、qtk过期
logger.warning('TencentTranslateFree result is empty %s', data)
return None
return res
def _get_uc(self):
user_actions = self._gen_user_actions()
cur_timestamp = str(int(datetime.datetime.now().timestamp() * 1000))
server_time_delta = str(self._server_time_delta)
uc = '|'.join([user_actions, cur_timestamp, server_time_delta])
aes = cry_aes.new(self._uc_key.encode('utf-8'), cry_aes.MODE_CBC, self._uc_iv.encode('utf-8'))
uc = cry_pad.pad(uc.encode('utf-8'), aes.block_size, 'pkcs7')
uc = aes.encrypt(uc)
uc = base64.b64encode(uc).decode('utf-8')
return uc
@staticmethod
def _gen_user_actions():
# 1点击翻译2源输入框聚焦或失去焦点3点击源语言列表4点击交换语言5点击目标语言列表6源输入框输入、粘贴
user_actions = []
if random.randint(1, 5) == 1:
for i in range(random.randint(1, 2)):
user_actions.append('2')
user_actions.append('6')
for i in range(random.randint(0, 6)):
user_actions.append(random.choice('26'))
if random.randint(1, 5) == 1:
user_actions.append('1')
return ''.join(user_actions)
def _update_uc_key(self, r):
try:
hf_f = r.headers['f']
hf_ts = int(r.headers['ts'])
except (KeyError, ValueError):
return
cur_timestamp = int(datetime.datetime.now().timestamp() * 1000)
hf_f = base64.b64decode(hf_f.encode('utf-8')).decode('utf-8')
pos = int(hf_f[72: 72 + 4])
uc_key = hf_f[pos: pos + 16]
uc_iv = hf_f[pos + 16: pos + 16 + 16]
self._server_time_delta = cur_timestamp - hf_ts
self._uc_key = uc_key
self._uc_iv = uc_iv
def _on_fail(self):
self._fail_count += 1
# 为了可靠性连续失败10次时冷却直到下次重新init
if self._fail_count >= 10:
self._cool_down()
def _cool_down(self):
logger.info('TencentTranslateFree is cooling down')
# 下次_do_init后恢复
self._uc_key = self._uc_iv = ''
self._qtv = self._qtk = ''
self._fail_count = 0
class TencentTranslate(FlowControlTranslateProvider):
def __init__(self, query_interval, max_queue_size, source_language, target_language,
secret_id, secret_key, region):
super().__init__(query_interval, max_queue_size)
self._source_language = source_language
self._target_language = target_language
self._secret_id = secret_id
self._secret_key = secret_key
self._region = region
self._cool_down_timer_handle = None
@property
def is_available(self):
return self._cool_down_timer_handle is None and super().is_available
async def _do_translate(self, text):
try:
async with self._request_tencent_cloud(
'TextTranslate',
'2018-03-21',
{
'SourceText': text,
'Source': self._source_language,
'Target': self._target_language,
'ProjectId': 0
}
) as r:
if r.status != 200:
logger.warning('TencentTranslate request failed: status=%d %s', r.status, r.reason)
return None
data = (await r.json())['Response']
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
error = data.get('Error', None)
if error is not None:
logger.warning('TencentTranslate failed: %s %s, RequestId=%s', error['Code'],
error['Message'], data['RequestId'])
self._on_fail(error['Code'])
return None
return data['TargetText']
def _request_tencent_cloud(self, action, version, body):
body_bytes = json.dumps(body).encode('utf-8')
canonical_headers = 'content-type:application/json; charset=utf-8\nhost:tmt.tencentcloudapi.com\n'
signed_headers = 'content-type;host'
hashed_request_payload = hashlib.sha256(body_bytes).hexdigest()
canonical_request = f'POST\n/\n\n{canonical_headers}\n{signed_headers}\n{hashed_request_payload}'
request_timestamp = int(datetime.datetime.now().timestamp())
date = datetime.datetime.utcfromtimestamp(request_timestamp).strftime('%Y-%m-%d')
credential_scope = f'{date}/tmt/tc3_request'
hashed_canonical_request = hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()
string_to_sign = f'TC3-HMAC-SHA256\n{request_timestamp}\n{credential_scope}\n{hashed_canonical_request}'
def sign(key, msg):
return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest()
secret_date = sign(('TC3' + self._secret_key).encode('utf-8'), date)
secret_service = sign(secret_date, 'tmt')
secret_signing = sign(secret_service, 'tc3_request')
signature = hmac.new(secret_signing, string_to_sign.encode('utf-8'), hashlib.sha256).hexdigest()
authorization = (
f'TC3-HMAC-SHA256 Credential={self._secret_id}/{credential_scope}, '
f'SignedHeaders={signed_headers}, Signature={signature}'
)
headers = {
'Authorization': authorization,
'Content-Type': 'application/json; charset=utf-8',
'X-TC-Action': action,
'X-TC-Version': version,
'X-TC-Timestamp': str(request_timestamp),
'X-TC-Region': self._region
}
return utils.request.http_session.post('https://tmt.tencentcloudapi.com/', headers=headers, data=body_bytes)
def _on_fail(self, code):
if self._cool_down_timer_handle is not None:
return
sleep_time = 0
if code == 'FailedOperation.NoFreeAmount':
# 下个月恢复免费额度
cur_time = datetime.datetime.now()
year = cur_time.year
month = cur_time.month + 1
if month > 12:
year += 1
month = 1
next_month_time = datetime.datetime(year, month, 1, minute=5)
sleep_time = (next_month_time - cur_time).total_seconds()
# Python 3.8之前不能超过一天
sleep_time = min(sleep_time, 24 * 60 * 60 - 1)
elif code in ('FailedOperation.ServiceIsolate', 'LimitExceeded'):
# 需要手动处理等5分钟
sleep_time = 5 * 60
if sleep_time != 0:
self._cool_down_timer_handle = asyncio.get_event_loop().call_later(
sleep_time, self._on_cool_down_timeout
)
def _on_cool_down_timeout(self):
self._cool_down_timer_handle = None
class BaiduTranslate(FlowControlTranslateProvider):
def __init__(self, query_interval, max_queue_size, source_language, target_language,
app_id, secret):
super().__init__(query_interval, max_queue_size)
self._source_language = source_language
self._target_language = target_language
self._app_id = app_id
self._secret = secret
self._cool_down_timer_handle = None
@property
def is_available(self):
return self._cool_down_timer_handle is None and super().is_available
async def _do_translate(self, text):
try:
async with utils.request.http_session.post(
'https://fanyi-api.baidu.com/api/trans/vip/translate',
data=self._add_sign({
'q': text,
'from': self._source_language,
'to': self._target_language,
'appid': self._app_id,
'salt': random.randint(1, 999999999)
})
) as r:
if r.status != 200:
logger.warning('BaiduTranslate request failed: status=%d %s', r.status, r.reason)
return None
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
error_code = data.get('error_code', None)
if error_code is not None:
logger.warning('BaiduTranslate failed: %s %s', error_code, data['error_msg'])
self._on_fail(error_code)
return None
return ''.join(result['dst'] for result in data['trans_result'])
def _add_sign(self, data):
str_to_sign = f"{self._app_id}{data['q']}{data['salt']}{self._secret}"
sign = hashlib.md5(str_to_sign.encode('utf-8')).hexdigest()
return {**data, 'sign': sign}
def _on_fail(self, code):
if self._cool_down_timer_handle is not None:
return
sleep_time = 0
if code == '54004':
# 账户余额不足需要手动处理等5分钟
sleep_time = 5 * 60
if sleep_time != 0:
self._cool_down_timer_handle = asyncio.get_event_loop().call_later(
sleep_time, self._on_cool_down_timeout
)
def _on_cool_down_timeout(self):
self._cool_down_timer_handle = None

View File

@@ -1,10 +1,11 @@
# -*- coding: utf-8 -*-
import asyncio
import aiohttp
VERSION = 'v1.5.1'
import utils.request
VERSION = 'v1.6.2'
def check_update():
@@ -13,15 +14,16 @@ def 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('---------------------------------------------')
print('New version available:', data['name'])
print(data['body'])
print('Download:', data['html_url'])
print('---------------------------------------------')
async with utils.request.http_session.get(
'https://api.github.com/repos/xfgryujk/blivechat/releases/latest'
) as r:
data = await r.json()
if data['name'] != VERSION:
print('---------------------------------------------')
print('New version available:', data['name'])
print(data['body'])
print('Download:', data['html_url'])
print('---------------------------------------------')
except aiohttp.ClientConnectionError:
print('Failed to check update: connection failed')
except asyncio.TimeoutError:

12
utils/request.py Normal file
View File

@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
import aiohttp
# 不带这堆头部有时候也能成功请求,但是带上后成功的概率更高
BILIBILI_COMMON_HEADERS = {
'Origin': 'https://www.bilibili.com',
'Referer': 'https://www.bilibili.com/',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)'
' Chrome/114.0.0.0 Safari/537.36'
}
http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))