Compare commits

..

54 Commits

Author SHA1 Message Date
John Smith
cbfcb01b17 更新版本号v1.10.0 2025-04-23 22:47:17 +08:00
John Smith
edd3636ece 优化docker镜像,前端不存到卷里,默认去掉加载器URL 2025-04-23 22:45:14 +08:00
John Smith
8dfb40662c 添加自定义模板帮助 2025-04-19 15:24:48 +08:00
John Smith
11234c8e29 添加测试消息 2025-04-16 22:21:47 +08:00
John Smith
9f7cdac674 前端测试房间支持更多消息和字段 2025-04-15 23:33:17 +08:00
John Smith
7ff3c9e646 自定义模板SDK添加枚举和消息类型注解 2025-04-14 00:33:31 +08:00
John Smith
d24946fc55 前端渲染器消息添加更多字段 2025-04-13 20:46:02 +08:00
John Smith
491dbb4fd9 前端richContent改名为contentParts 2025-04-13 17:31:10 +08:00
John Smith
d2990ba8d8 添加自定义模板SDK 2025-04-13 17:01:01 +08:00
John Smith
8c3dfa902c 添加自定义模板缩略图 2025-04-06 18:35:10 +08:00
John Smith
22d80e3af2 前端添加自定义模板配置 2025-04-06 16:23:52 +08:00
John Smith
7b91cbae3f 后端添加获取自定义模板列表的接口 2025-04-04 23:26:12 +08:00
John Smith
79f070fc26 web接口换一个初始化房间的接口 2025-04-03 19:58:45 +08:00
John Smith
2042a3fd4c 前端添加自定义模板接口 2025-03-23 11:54:50 +08:00
John Smith
069ff30f47 web接口添加礼物图标、勋章信息 2025-03-09 20:47:27 +08:00
John Smith
0065a47b78 Gemini翻译改为OpenAI API兼容接口 2025-02-24 21:09:58 +08:00
John Smith
3b343464ae 支持显示at人、开放平台接口支持显示房管 2025-01-19 21:58:19 +08:00
John Smith
87922e0809 更新依赖
close #252
2025-01-12 19:26:22 +08:00
John Smith
9bf8041860 添加私货 2024-12-29 23:54:18 +08:00
John Smith
096da47032 后端修复web接口弹幕解析错误 2024-12-25 23:15:51 +08:00
John Smith
3273fb4d34 开放平台接口修正礼物价格,web接口支持显示头像、修正上舰消息的单位 2024-12-23 21:21:52 +08:00
John Smith
7f77fdd889 接入开放平台服务器停止推送的消息,及时断开连接 2024-12-08 19:42:03 +08:00
John Smith
8538bc6abe 修复关闭页面时EndGame跨域 2024-12-07 23:50:35 +08:00
John Smith
b73608af79 改善刷新页面导致连接数超出限制的问题 2024-12-07 22:59:22 +08:00
John Smith
45db27d3c7 更新测试文本 2024-12-03 00:30:42 +08:00
John Smith
aae8bd2f15 SC固定栏支持滚动 2024-11-24 20:18:56 +08:00
John Smith
1244210bfe 优化样式生成器界面 2024-11-24 18:06:41 +08:00
John Smith
3976aa79ed 优化样式生成器预览的性能 2024-11-23 18:49:27 +08:00
John Smith
e27955b8b5 原版YouTube样式放入@layer,自定义样式不用加!important了 2024-11-23 17:33:37 +08:00
John Smith
faf272c8a7 整理前端部分组件CSS 2024-11-23 17:33:37 +08:00
dependabot[bot]
c45825922a Bump tornado from 6.4.1 to 6.4.2
Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.4.1 to 6.4.2.
- [Changelog](https://github.com/tornadoweb/tornado/blob/v6.4.2/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.4.1...v6.4.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-23 12:59:25 +08:00
John Smith
4b9c01b0c3 默认的Python版本升级到3.12 2024-11-17 22:55:16 +08:00
John Smith
928ec4a41e 更新README 2024-11-16 21:41:27 +08:00
John Smith
824e073ba2 更新版本号v1.9.3 2024-11-16 12:00:01 +08:00
John Smith
de3712a7ca 添加用于测速的接口 2024-11-12 20:09:42 +08:00
John Smith
fb9e0418c7 更新blivedm 2024-11-10 16:47:45 +08:00
John Smith
5a54f0cdaf 前端修复样式生成器中第一次加载房间后CSS不生效的问题 2024-11-10 16:45:31 +08:00
John Smith
cefca66d76 前端动态获取后端地址、支持故障转移 2024-11-08 00:18:16 +08:00
John Smith
99d3b567da 后端动态获取公共服务器地址、支持故障转移 2024-11-08 00:14:52 +08:00
John Smith
132d2a9f71 添加服务发现接口、支持跨域 2024-11-05 22:16:33 +08:00
John Smith
baa64f3dc5 支持信号重新加载配置 2024-11-05 22:16:33 +08:00
John Smith
48154b2be9 公共服务器前端不生成source map 2024-11-05 22:16:33 +08:00
John Smith
06e5481574 前端CDN改成Zstatic,添加开关 2024-11-05 22:16:33 +08:00
John Smith
8b91fa2b98 压缩前端的图片 2024-11-05 22:16:33 +08:00
John Smith
a0d1bf4742 添加Vercel配置 2024-11-05 22:16:25 +08:00
John Smith
75938141f0 docker build时NPM使用镜像 2024-07-30 00:16:36 +08:00
John Smith
187d45e6d9 更新版本号v1.9.2 2024-07-29 23:22:16 +08:00
John Smith
6d7c7b78d9 加载器改成本地文件 2024-07-29 23:21:02 +08:00
John Smith
b00ae596ef 移除腾讯翻译白嫖版 2024-07-29 22:41:38 +08:00
John Smith
e6299a03e8 前端暂时不使用CDN,因为BootCDN被uBlock Origin屏蔽了 2024-07-29 22:41:38 +08:00
dependabot[bot]
e77c26c792 Bump tornado from 6.3.3 to 6.4.1 (#207)
Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.3.3 to 6.4.1.
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.3.3...v6.4.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-07 09:57:37 +08:00
John Smith
a426adde65 补充注意事项链接 2024-05-26 13:14:26 +08:00
John Smith
df29586b7d 连接数限制的错误信息添加解决方法链接 2024-05-23 01:30:33 +08:00
John Smith
d5cd7f9f7e 抓一下刷请求的人 2024-05-21 20:35:46 +08:00
79 changed files with 3121 additions and 1346 deletions

View File

@@ -20,9 +20,12 @@ plugins/
# runtime data
data/*
!data/config.example.ini
!data/loader.html
!data/custom_public/
data/custom_public/*
!data/custom_public/README.txt
!data/custom_public/templates/
data/custom_public/templates/*
!data/emoticons/
data/emoticons/*
!data/plugins/

1
.gitignore vendored
View File

@@ -107,3 +107,4 @@ venv.bak/
.idea/
data/
log/
.vercel

View File

@@ -8,7 +8,7 @@ WORKDIR "${BASE_PATH}/frontend"
# 前端依赖
COPY frontend/package.json ./
RUN npm i
RUN npm i --registry=https://registry.npmmirror.com
# 编译前端
COPY frontend ./
@@ -18,7 +18,7 @@ RUN npm run build
# 准备后端
#
FROM python:3.8.12-bullseye
FROM python:3.12.10-bookworm
ARG BASE_PATH='/root/blivechat'
ARG EXT_DATA_PATH='/mnt/data'
WORKDIR "${BASE_PATH}"
@@ -30,18 +30,17 @@ RUN pip3 install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple -r req
# 数据目录
COPY . ./
RUN mkdir -p "${EXT_DATA_PATH}/frontend/dist" \
RUN sed 's/^host =.*$/host = 0.0.0.0/; s/^loader_url =.*$/loader_url =/' data/config.example.ini >> data/config.ini
RUN mkdir -p "${EXT_DATA_PATH}" \
&& 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
&& ln -s "${EXT_DATA_PATH}/log" log
# 编译好的前端
COPY --from=builder "${BASE_PATH}/frontend/dist" "${EXT_DATA_PATH}/frontend/dist/"
COPY --from=builder "${BASE_PATH}/frontend/dist" frontend/dist
# 运行
VOLUME "${EXT_DATA_PATH}"
EXPOSE 12450
ENTRYPOINT ["python3", "main.py"]
CMD ["--host", "0.0.0.0", "--port", "12450"]

View File

@@ -11,46 +11,44 @@
## 特性
* 兼容YouTube直播评论栏的样式
* 高亮舰队、 ~~房管~~ 、主播的用户名
* 高亮舰队、房管、主播的用户名
* 自带两种样式生成器经典YouTube风格和仿微信风格
* 支持屏蔽弹幕、合并礼物等设置
* 支持前端直连B站服务器或者通过后端转发
* 支持自动翻译弹幕、醒目留言到日语,可以在后台配置翻译目标语言
* 支持标注打赏用户名的读音,可选拼音或日文假名
* 支持配置自定义表情不需要开通B站官方表情
* 支持插件开发
* 支持[自定义HTML模板](https://github.com/xfgryujk/blivechat/wiki/%E8%87%AA%E5%AE%9A%E4%B9%89HTML%E6%A8%A1%E6%9D%BF)
* 支持[插件开发](https://github.com/xfgryujk/blivechat/wiki/%E6%8F%92%E4%BB%B6%E7%B3%BB%E7%BB%9F)
## 使用方法
以下几种方式任选一种即可
以下几种方式任选一种即可。**正式使用之前记得看[注意事项](https://github.com/xfgryujk/blivechat/wiki/%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9%E5%92%8C%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98)**
### 一、本地使用
推荐的方式:如果你需要使用插件、翻译等高级特性,则在本地使用;否则推荐直接通过公共服务器在线使用。因为本地使用时不会自动升级版本,有时候出了问题不能及时解决;但在线使用时会禁用部分高级特性,如果你有需要,只能本地使用了
### 一、在线使用
1. 这些是作者维护的公共服务器,根据情况随便选一个,直接用浏览器打开
* [blive.chat](https://blive.chat/)自动节点一般等于cn.blive.chat如果不可用了会进行切换但切换需要一段时间
* [cn.blive.chat](https://cn.blive.chat/):墙内专用节点,不容易被墙,但如果受到攻击会变得不可用
* [cloudflare.blive.chat](https://cloudflare.blive.chat/)Cloudflare美国节点不容易被攻击但容易被墙
* [vercel.blive.chat](https://vercel.blive.chat/)Vercel美国节点不容易被攻击但容易被墙
2. 输入主播在开始直播时获得的身份码复制房间URL
3. 用样式生成器生成样式复制CSS
4. 在OBS中添加浏览器源输入URL和自定义CSS
### 二、本地使用
1. 下载[本地分发版](https://github.com/xfgryujk/blivechat/releases)仅提供x64 Windows版。也可以在[B站商店](https://play-live.bilibili.com/details/1694397161340)下载
2. 双击`blivechat.exe`运行服务器。或者用命令行可以指定host和端口号
2. 双击`blivechat.exe`(或者`start.exe`运行服务器
3. 用浏览器打开[http://localhost:12450](http://localhost:12450),剩下的步骤和在线使用时是一样的
```sh
blivechat.exe --host 127.0.0.1 --port 12450
```
### 三、从源码运行
或者也可以在配置文件里指定host和端口号
3. 用浏览器打开[http://localhost:12450](http://localhost:12450)输入主播在开始直播时获得的身份码复制房间URL
4. 用样式生成器生成样式复制CSS
5. 在OBS中添加浏览器源输入URL和自定义CSS
**注意事项:**
* 本地使用时不要关闭blivechat.exe那个黑框否则不能继续获取弹幕
* 如果需要使用翻译功能,建议看[配置官方翻译接口教程](https://github.com/xfgryujk/blivechat/wiki/%E9%85%8D%E7%BD%AE%E5%AE%98%E6%96%B9%E7%BF%BB%E8%AF%91%E6%8E%A5%E5%8F%A3)
### 二、公共服务器
请优先在本地使用,因为公共服务器会禁用部分特性
* [公共服务器](http://chat.bilisc.com/)
### 三、源代码版自建服务器或在Windows以外平台
此方式适用于自建服务器或者在Windows以外的平台运行
0. 由于使用了git子模块clone时需要加上`--recursive`参数:
@@ -72,22 +70,29 @@
npm run build
```
2. 运行服务器需要Python3.8以上版本):
2. 安装服务器依赖需要Python 3.12以上版本):
```sh
pip3 install -r requirements.txt
python3 main.py
pip install -r requirements.txt
```
3. 运行服务器:
```sh
python main.py
```
或者可以指定host和端口号
```sh
python3 main.py --host 127.0.0.1 --port 12450
python main.py --host 127.0.0.1 --port 12450
```
3. 用浏览器打开[http://localhost:12450](http://localhost:12450),以下略
4. 用浏览器打开[http://localhost:12450](http://localhost:12450),以下略
### 四、Docker(自建服务器)
### 四、Docker
此方式适用于自建服务器。示例的运行参数只是最基本的,可以根据需要修改
1. ```sh
docker run --name blivechat -d -p 12450:12450 \
@@ -99,9 +104,9 @@
## 服务器配置
服务器配置在`data/config.ini`,可以配置数据库和允许自动翻译等,编辑后要重启生效
服务器配置文件在`data/config.ini`,可以配置数据库和允许自动翻译等,编辑后要重启生效
**自建服务器时强烈建议不使用加载器**否则可能因为混合HTTP和HTTPS等原因加载不出来
**自建服务器时注意要删除loader_url配置**,否则加载不了房间页面
## 常用链接

View File

@@ -4,18 +4,37 @@ from typing import *
import tornado.web
import config
class ApiHandler(tornado.web.RequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.json_args: Optional[dict] = None
def prepare(self):
def set_default_headers(self):
self.set_header('Cache-Control', 'no-cache')
self.add_header('Vary', 'Origin')
origin = self.request.headers.get('Origin', None)
if origin is None:
return
cfg = config.get_config()
if not cfg.is_allowed_cors_origin(origin):
return
self.set_header('Access-Control-Allow-Origin', origin)
self.set_header('Access-Control-Allow-Methods', '*')
self.set_header('Access-Control-Allow-Headers', '*')
self.set_header('Access-Control-Max-Age', '3600')
def prepare(self):
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
async def options(self, *_args, **_kwargs):
self.set_status(204)

View File

@@ -223,12 +223,13 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
self._refresh_receive_timeout_timer()
# 跨域测试用
def check_origin(self, origin):
cfg = config.get_config()
if cfg.debug:
return True
return super().check_origin(origin)
return (
cfg.debug # 开发时前端localhost直连
or cfg.is_allowed_cors_origin(origin)
or super().check_origin(origin) # 和Host相同
)
@property
def has_joined_room(self):
@@ -374,8 +375,8 @@ class RoomInfoHandler(api.base.ApiHandler):
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']
data = data['data']
return data['room_id'], data['uid']
async def _get_server_host_list_and_token(self, room_id) -> Tuple[dict, Optional[str]]:
try:

View File

@@ -1,10 +1,13 @@
# -*- coding: utf-8 -*-
import asyncio
import hashlib
import json
import logging
import os
import cachetools
import tornado.web
import yarl
import api.base
import config
@@ -15,9 +18,13 @@ logger = logging.getLogger(__name__)
EMOTICON_UPLOAD_PATH = os.path.join(config.DATA_PATH, 'emoticons')
EMOTICON_BASE_URL = '/emoticons'
CUSTOM_PUBLIC_PATH = os.path.join(config.DATA_PATH, 'custom_public')
TEMPLATE_PATH = os.path.join(CUSTOM_PUBLIC_PATH, 'templates')
TEMPLATE_BASE_URL = '/custom_public/templates'
_templates_cache = cachetools.TTLCache(1, 10)
class MainHandler(tornado.web.StaticFileHandler):
class StaticHandler(tornado.web.StaticFileHandler):
"""为了使用Vue Router的history模式把不存在的文件请求转发到index.html"""
async def get(self, path, include_body=True):
if path == '':
@@ -52,6 +59,19 @@ class ServerInfoHandler(api.base.ApiHandler):
})
class ServiceDiscoveryHandler(api.base.ApiHandler):
async def get(self):
cfg = config.get_config()
self.write({
'endpoints': cfg.registered_endpoints,
})
class PingHandler(api.base.ApiHandler):
async def get(self):
self.set_status(204)
class UploadEmoticonHandler(api.base.ApiHandler):
async def post(self):
cfg = config.get_config()
@@ -87,6 +107,75 @@ class UploadEmoticonHandler(api.base.ApiHandler):
return f'{EMOTICON_BASE_URL}/{filename}'
class TemplatesHandler(api.base.ApiHandler):
async def get(self):
templates = _templates_cache.get('templates', None)
if templates is None:
templates = await asyncio.get_running_loop().run_in_executor(None, self._get_templates)
_templates_cache['templates'] = templates
self.set_header('Cache-Control', 'private, max-age=10')
self.write({'templates': templates})
def _get_templates(self):
template_ids = []
try:
with os.scandir(TEMPLATE_PATH) as it:
for entry in it:
if entry.is_dir() and os.path.isfile(os.path.join(entry.path, 'template.json')):
template_ids.append(entry.name)
except OSError:
logger.exception('Failed to discover templates:')
return []
if not template_ids:
return []
templates = []
for template_id in template_ids:
try:
template = self._load_template_config(template_id)
templates.append(template)
except (OSError, json.JSONDecodeError, TypeError, ValueError):
logger.exception('template_id=%s failed to load config:', template_id)
return templates
@staticmethod
def _load_template_config(template_id):
config_path = os.path.join(TEMPLATE_PATH, template_id, 'template.json')
with open(config_path, encoding='utf-8') as f:
cfg = json.load(f)
if not isinstance(cfg, dict):
raise TypeError(f'Config type error, type={type(cfg)}')
# 相对于模板目录
base_url = yarl.URL(f'{TEMPLATE_BASE_URL}/{template_id}/')
def ensure_abs_url(url_str_):
url_ = yarl.URL(url_str_)
if not url_.absolute:
url_ = base_url.join(url_)
url_str_ = str(url_)
return url_str_
thumbnail_url_str = str(cfg.get('thumbnail', ''))
if thumbnail_url_str != '':
thumbnail_url_str = ensure_abs_url(thumbnail_url_str)
url_str = str(cfg.get('url', ''))
url_str = ensure_abs_url(url_str)
template = {
'id': template_id,
'name': str(cfg.get('name', '')),
'version': str(cfg.get('version', '')),
'author': str(cfg.get('author', '')),
'description': str(cfg.get('description', '')),
'thumbnail': thumbnail_url_str,
'url': url_str,
}
return template
class NoCacheStaticFileHandler(tornado.web.StaticFileHandler):
def set_extra_headers(self, path):
self.set_header('Cache-Control', 'no-cache')
@@ -94,12 +183,15 @@ class NoCacheStaticFileHandler(tornado.web.StaticFileHandler):
ROUTES = [
(r'/api/server_info', ServerInfoHandler),
(r'/api/endpoints', ServiceDiscoveryHandler),
(r'/api/ping', PingHandler),
(r'/api/emoticon', UploadEmoticonHandler),
(r'/api/templates', TemplatesHandler),
]
# 通配的放在最后
LAST_ROUTES = [
(rf'{EMOTICON_BASE_URL}/(.*)', tornado.web.StaticFileHandler, {'path': EMOTICON_UPLOAD_PATH}),
# 这个目录不保证文件内容不会变,还是不用缓存了
(r'/custom_public/(.*)', NoCacheStaticFileHandler, {'path': CUSTOM_PUBLIC_PATH}),
(r'/(.*)', MainHandler, {'path': config.WEB_ROOT}),
(r'/(.*)', StaticHandler, {'path': config.WEB_ROOT}),
]

View File

@@ -27,12 +27,13 @@ END_GAME_OPEN_LIVE_URL = OPEN_LIVE_BASE_URL + '/v2/app/end'
GAME_HEARTBEAT_OPEN_LIVE_URL = OPEN_LIVE_BASE_URL + '/v2/app/heartbeat'
GAME_BATCH_HEARTBEAT_OPEN_LIVE_URL = OPEN_LIVE_BASE_URL + '/v2/app/batchHeartbeat'
COMMON_SERVER_BASE_URL = 'https://chat.bilisc.com'
START_GAME_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/start_game'
END_GAME_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/end_game'
GAME_HEARTBEAT_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/game_heartbeat'
START_GAME_COMMON_SERVER_URL = '/api/internal/open_live/start_game'
END_GAME_COMMON_SERVER_URL = '/api/internal/open_live/end_game'
GAME_HEARTBEAT_COMMON_SERVER_URL = '/api/internal/open_live/game_heartbeat'
_error_auth_code_cache = cachetools.LRUCache(256)
# 应B站要求抓一下刷请求的人不会用于其他用途
auth_code_room_id_cache = cachetools.LRUCache(256)
# 用于限制请求开放平台的频率
_open_live_rate_limiter = utils.rate_limit.TokenBucket(8, 8)
@@ -52,24 +53,15 @@ class BusinessError(Exception):
return self.data['code']
async def request_open_live_or_common_server(open_live_url, common_server_url, body: dict) -> dict:
async def request_open_live_or_common_server(open_live_url, common_server_url, body: dict, **kwargs) -> dict:
"""如果配置了开放平台,则直接请求,否则转发请求到公共服务器的内部接口"""
cfg = config.get_config()
if cfg.is_open_live_configured:
return await request_open_live(open_live_url, body)
try:
req_ctx_mgr = utils.request.http_session.post(common_server_url, json=body)
return await _read_response(req_ctx_mgr)
except TransportError:
logger.exception('Request common server failed:')
raise
except BusinessError as e:
logger.warning('Request common server failed: %s', e)
raise
return await request_open_live(open_live_url, body, **kwargs)
return await request_common_server(common_server_url, body, **kwargs)
async def request_open_live(url, body: dict, *, ignore_rate_limit=False) -> dict:
async def request_open_live(url, body: dict, *, ignore_rate_limit=False, **kwargs) -> dict:
cfg = config.get_config()
assert cfg.is_open_live_configured
@@ -82,7 +74,7 @@ async def request_open_live(url, body: dict, *, ignore_rate_limit=False) -> dict
# 频率限制防止触发B站风控被下架
if not _open_live_rate_limiter.try_decrease_token() and not ignore_rate_limit:
raise BusinessError({'code': 4009, 'message': '接口访问限制', 'request_id': '0', 'data': None})
raise BusinessError({'code': 4009, 'message': 'BLC接口访问限制', 'request_id': '0', 'data': None})
body_bytes = json.dumps(body).encode('utf-8')
headers = {
@@ -107,25 +99,56 @@ async def request_open_live(url, body: dict, *, ignore_rate_limit=False) -> dict
headers['Accept'] = 'application/json'
try:
req_ctx_mgr = utils.request.http_session.post(url, headers=headers, data=body_bytes)
req_ctx_mgr = utils.request.http_session.post(url, headers=headers, data=body_bytes, **kwargs)
return await _read_response(req_ctx_mgr)
except TransportError:
logger.exception('Request open live failed:')
raise
except BusinessError as e:
logger.warning('Request open live failed: %s', e)
msg = str(e)
if e.code == 7010:
# 新版本日志可以截断,避免日志太长了
msg = msg[:30] + '...'
logger.warning('Request open live failed: %s', msg)
if e.code == 7007:
_error_auth_code_cache[auth_code] = True
raise
async def _read_response(req_ctx_mgr: AsyncContextManager[aiohttp.ClientResponse]) -> dict:
async def request_common_server(rel_url, body: dict, **kwargs) -> dict:
base_url, breaker = utils.request.get_common_server_base_url_and_circuit_breaker()
if base_url is None:
logger.error('No available common server endpoint')
raise TransportError('No available common server endpoint')
url = base_url + rel_url
with breaker:
try:
req_ctx_mgr = utils.request.http_session.post(url, json=body, **kwargs)
return await _read_response(req_ctx_mgr, is_common_server=True)
except TransportError:
logger.exception('Request common server failed:')
raise
except BusinessError as e:
logger.warning('Request common server failed: %s', e)
raise
async def _read_response(req_ctx_mgr: AsyncContextManager[aiohttp.ClientResponse], is_common_server=False) -> dict:
try:
async with req_ctx_mgr as r:
r.raise_for_status()
data = await r.json()
code = data['code']
if code != 0:
if code == 7010 and not is_common_server:
data['message'] += (
' 解决方法https://github.com/xfgryujk/blivechat/wiki/%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9%E5'
'%92%8C%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98#%E6%8A%A5%E9%94%997010-%E8%B6%85%E8%BF%87%E4%B8%8'
'A%E9%99%90%E5%90%8C%E4%B8%80%E4%B8%AA%E5%BA%94%E7%94%A8%E5%8D%95%E4%B8%AA%E7%9B%B4%E6%92%AD%'
'E9%97%B4%E6%9C%80%E5%A4%9A%E5%90%8C%E6%97%B6%E6%89%93%E5%BC%805%E4%B8%AA'
)
raise BusinessError(data)
return data
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
@@ -140,7 +163,7 @@ def _validate_auth_code(auth_code):
):
raise BusinessError({
'code': 7007,
'message': 'oioioi!你的身份码错误了!别再重试了!!!!!!!!!!',
'message': 'CNM!你的身份码错误了!别再重试了!!!!!!!!!!',
'request_id': '0',
'data': None
})
@@ -155,6 +178,9 @@ class _OpenLiveHandlerBase(api.base.ApiHandler):
def prepare(self):
super().prepare()
if self.request.method == 'OPTIONS':
return
if not isinstance(self.json_args, dict):
raise tornado.web.MissingArgumentError('body')
@@ -210,26 +236,31 @@ class _StartGameMixin(_OpenLiveHandlerBase):
if self.res is None:
return
auth_code = self.json_args.get('code', None)
try:
room_id = self.res['data']['anchor_info']['room_id']
except (TypeError, KeyError):
room_id = None
room_id = auth_code_room_id_cache.get(auth_code, None)
else:
auth_code_room_id_cache[auth_code] = room_id
try:
game_id = self.res['data']['game_info']['game_id']
except (TypeError, KeyError):
game_id = None
code = self.res['code']
msg = self.res['message']
if code == 7010:
# 新版本日志可以截断,避免日志太长了
msg = msg[:10] + '...'
logger.info(
'client=%s room_id=%s start game res: %s %s, game_id=%s', self.request.remote_ip, room_id,
code, self.res['message'], game_id
code, msg, game_id
)
if code == 7007:
# 身份码错误
# 让我看看是哪个混蛋把房间ID、UID当做身份码
logger.info(
'client=%s auth code error! auth_code=%s', self.request.remote_ip,
self.json_args.get('code', None)
)
logger.info('client=%s auth code error! auth_code=%s', self.request.remote_ip, auth_code)
class StartGamePublicHandler(_StartGameMixin, _PublicHandlerBase):
@@ -244,6 +275,17 @@ class EndGamePublicHandler(_PublicHandlerBase):
_OPEN_LIVE_URL = END_GAME_OPEN_LIVE_URL
_COMMON_SERVER_URL = END_GAME_COMMON_SERVER_URL
def set_default_headers(self):
super().set_default_headers()
if self._headers.get('Access-Control-Allow-Origin', None) is None:
return
# 前端sendBeacon跨域发送JSON时会带凭证
self.set_header('Access-Control-Allow-Credentials', 'true')
self.set_header('Access-Control-Allow-Methods', 'POST')
self.set_header('Access-Control-Allow-Headers', 'Content-Type')
class EndGamePrivateHandler(_PrivateHandlerBase):
_OPEN_LIVE_URL = END_GAME_OPEN_LIVE_URL
@@ -276,9 +318,8 @@ async def send_game_heartbeat_by_service_or_common_server(game_id):
cfg = config.get_config()
if cfg.is_open_live_configured:
return await services.open_live.send_game_heartbeat(game_id)
# 这里GAME_HEARTBEAT_OPEN_LIVE_URL没用因为一定是请求公共服务器
return await request_open_live_or_common_server(
GAME_HEARTBEAT_OPEN_LIVE_URL, GAME_HEARTBEAT_COMMON_SERVER_URL, {'game_id': game_id}
return await request_common_server(
GAME_HEARTBEAT_COMMON_SERVER_URL, {'game_id': game_id}, timeout=aiohttp.ClientTimeout(total=15)
)

View File

@@ -24,7 +24,8 @@ class _AdminHandlerBase(api.base.ApiHandler):
if not cfg.enable_admin_plugins:
raise tornado.web.HTTPError(403)
logger.info('client=%s requesting admin plugin, cls=%s', self.request.remote_ip, type(self).__name__)
if self.request.method != 'OPTIONS':
logger.info('client=%s requesting admin plugin, cls=%s', self.request.remote_ip, type(self).__name__)
super().prepare()

Submodule blivedm updated: 60ce01927a...a45ee8f677

View File

@@ -2,6 +2,7 @@
import configparser
import logging
import os
import re
from typing import *
logger = logging.getLogger(__name__)
@@ -30,7 +31,7 @@ def init(cmd_args):
_config = config
def reload(cmd_args):
def reload(cmd_args=None):
config_path = ''
for path in CONFIG_PATH_LIST:
if os.path.exists(path):
@@ -73,12 +74,15 @@ class AppConfig:
self.open_live_app_id = 0
self.enable_translate = True
self.allow_translate_rooms = set()
self.allow_translate_rooms: Set[int] = set()
self.translate_max_queue_size = 10
self.translation_cache_size = 50000
self.translator_configs = []
self.translator_configs: List[dict] = []
self.text_emoticons = []
self.text_emoticons: List[dict] = []
self.registered_endpoints: List[str] = []
self.cors_origins: List[re.Pattern[str]] = []
@property
def is_open_live_configured(self):
@@ -86,7 +90,9 @@ class AppConfig:
self.open_live_access_key_id != '' and self.open_live_access_key_secret != '' and self.open_live_app_id != 0
)
def load_cmd_args(self, args):
def load_cmd_args(self, args=None):
if args is None:
return
if args.host is not None:
self.host = args.host
if args.port is not None:
@@ -101,6 +107,8 @@ class AppConfig:
self._load_app_config(config)
self._load_translator_configs(config)
self._load_text_emoticons(config)
self._load_registered_endpoints(config)
self._load_cors_origins(config)
except Exception: # noqa
logger.exception('Failed to load config:')
return False
@@ -113,6 +121,8 @@ class AppConfig:
self.database_url = app_section.get('database_url', self.database_url)
self.tornado_xheaders = app_section.getboolean('tornado_xheaders', self.tornado_xheaders)
self.loader_url = app_section.get('loader_url', self.loader_url)
if self.loader_url == '{local_loader}':
self.loader_url = self._get_local_loader_url()
self.open_browser_at_startup = app_section.getboolean('open_browser_at_startup', self.open_browser_at_startup)
self.enable_upload_file = app_section.getboolean('enable_upload_file', self.enable_upload_file)
self.enable_admin_plugins = app_section.getboolean('enable_admin_plugins', self.enable_admin_plugins)
@@ -133,8 +143,20 @@ class AppConfig:
self.translate_max_queue_size = app_section.getint('translate_max_queue_size', self.translate_max_queue_size)
self.translation_cache_size = app_section.getint('translation_cache_size', self.translation_cache_size)
@staticmethod
def _get_local_loader_url():
url = os.path.abspath(os.path.join(DATA_PATH, 'loader.html'))
url = url.replace('\\', '/')
if not url.startswith('/'): # Windows
url = '/' + url
url = 'file://' + url
return url
def _load_translator_configs(self, config: configparser.ConfigParser):
app_section = config['app']
try:
app_section = config['app']
except KeyError:
return
section_names = _str_to_list(app_section.get('translator_configs', ''))
translator_configs = []
for section_name in section_names:
@@ -146,11 +168,12 @@ class AppConfig:
'type': type_,
'query_interval': section.getfloat('query_interval'),
}
if type_ == 'TencentTranslateFree':
translator_config['source_language'] = section['source_language']
translator_config['target_language'] = section['target_language']
elif type_ == 'BilibiliTranslateFree':
pass
if type_ in ('TencentTranslateFree', 'BilibiliTranslateFree'):
doc_url = (
'https://github.com/xfgryujk/blivechat/wiki/%E9%85%8D%E7%BD%AE%E5%AE%98%E6%96%B9'
'%E7%BF%BB%E8%AF%91%E6%8E%A5%E5%8F%A3'
)
logger.warning('%s is deprecated, please see %s', type_, doc_url)
elif type_ == 'TencentTranslate':
translator_config['source_language'] = section['source_language']
translator_config['target_language'] = section['target_language']
@@ -163,11 +186,16 @@ class AppConfig:
translator_config['app_id'] = section['app_id']
translator_config['secret'] = section['secret']
elif type_ == 'GeminiTranslate':
translator_config['proxy'] = section['proxy']
logger.warning('%s is deprecated, please migrate to OpenAiApi', type_)
elif type_ == 'OpenAiApi':
translator_config['api_key'] = section['api_key']
translator_config['model_code'] = section['model_code']
translator_config['base_url'] = section['base_url']
translator_config['proxy'] = section['proxy']
translator_config['model'] = section['model']
translator_config['prompt'] = section['prompt'].replace('\n', ' ').replace('\\n', '\n')
translator_config['max_tokens'] = section.getint('max_tokens')
translator_config['temperature'] = section.getfloat('temperature')
translator_config['top_p'] = section.getfloat('top_p')
else:
raise ValueError(f'Invalid translator type: {type_}')
except Exception: # noqa
@@ -178,13 +206,41 @@ class AppConfig:
self.translator_configs = translator_configs
def _load_text_emoticons(self, config: configparser.ConfigParser):
mappings_section = config['text_emoticon_mappings']
try:
mappings_section = config['text_emoticon_mappings']
except KeyError:
return
text_emoticons = []
for value in mappings_section.values():
keyword, _, url = value.partition(',')
text_emoticons.append({'keyword': keyword, 'url': url})
self.text_emoticons = text_emoticons
def _load_registered_endpoints(self, config: configparser.ConfigParser):
try:
registered_endpoints_section = config['registered_endpoints']
except KeyError:
return
registered_endpoints = list(registered_endpoints_section.values())
self.registered_endpoints = registered_endpoints
def _load_cors_origins(self, config: configparser.ConfigParser):
try:
cors_origins_section = config['cors_origins']
except KeyError:
return
cors_origins = [
re.compile(origin, re.IGNORECASE)
for origin in cors_origins_section.values()
]
self.cors_origins = cors_origins
def is_allowed_cors_origin(self, origin):
return any(
pattern.fullmatch(origin) is not None
for pattern in self.cors_origins
)
def _str_to_list(value, item_type: Type = str, container_type: Type = list):
value = value.strip()
@@ -193,5 +249,5 @@ def _str_to_list(value, item_type: Type = str, container_type: Type = list):
items = value.split(',')
items = map(lambda item: item.strip(), items)
if item_type is not str:
items = map(lambda item: item_type(item), items)
items = map(item_type, items)
return container_type(items)

View File

@@ -17,8 +17,9 @@ tornado_xheaders = false
# 加载器URL本地使用时加载器可以让你先运行OBS再运行blivechat。如果为空不使用加载器
# **自建服务器时强烈建议不使用加载器**否则可能因为混合HTTP和HTTPS等原因加载不出来
# “{local_loader}”表示使用本地加载器文件URL
# 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
loader_url = {local_loader}
# 启动时打开浏览器
# Open browser at startup
@@ -76,22 +77,8 @@ open_live_app_id = 0
# 翻译器配置,索引到下面的配置节。可以以逗号分隔配置多个翻译器,翻译时会自动负载均衡
# 配置多个翻译器可以增加额度、增加QPS、容灾
# 不同配置可以使用同一个类型,但要使用不同的账号,否则还是会遇到额度、调用频率限制
translator_configs = tencent_translate_free
[tencent_translate_free]
# 类型:腾讯翻译白嫖版。使用了网页版的接口,**将来可能失效**
type = TencentTranslateFree
# 请求间隔时间(秒),等于 1 / QPS
query_interval = 1
# 自动auto中文zh日语jp英语en韩语kr
# 完整语言列表见文档https://cloud.tencent.com/document/product/551/15619
# 源语言
source_language = zh
# 目标语言
target_language = jp
# Example: translator_configs = tencent_translate,baidu_translate
translator_configs =
[tencent_translate]
@@ -151,48 +138,36 @@ app_id =
secret =
[gemini_translate]
# 文档https://ai.google.dev/docs
# 定价https://ai.google.dev/pricing
# * 目前只有免费版最高QPS为1
# * 收费版 输入 $0.000125 / 1000 个字符;输出 $0.000375 / 1000 个字符
# 模型规格https://ai.google.dev/models/gemini
# * Gemini Pro 最高QPS为1
[openai_api]
# 大语言模型对话支持任何兼容OpenAI API的平台参考各平台的文档吧
# 类型:Gemini翻译
type = GeminiTranslate
# 类型:OpenAI API
type = OpenAiApi
# 请求间隔时间(秒),等于 1 / QPS
query_interval = 1.1
# 代理地址如果为空不使用代理。注意必须要在指定地区才能使用https://ai.google.dev/available_regions
proxy = http://127.0.0.1:7890
# API密钥
api_key =
# API端点“/chat/completions”之前的部分
base_url = https://api.deepseek.com/v1
# 代理地址,如果为空,不使用代理
# Example: proxy = http://127.0.0.1:7890
proxy =
# 模型代码
model_code = models/gemini-1.0-pro
# 提示词
prompt = As an advanced translation software, your role is to provide accurate translations that respect
the original content's meaning and tone.
Ensure that texts between square brackets "[]" are kept untranslated.
Aim to deliver the translated text in a polite and gentle tone, as per the preferences of the audience.\n\n
Below are some examples to guide you:\n\n
Input1:\n来拉(莱拉)设置一下吧\nOutput1:\nライラちゃん、設定してみましょうか\n\n
Input2:\n我来拉一下各位进队伍\nOutput2:\n皆さんをチームに入れさせていただきます\n\n
Input3:\n死了w\nOutput3:\n死んじゃったわw\n\n
Input4:\n每天来宝这里打个卡[比心]\nOutput4:\n毎日、ここに来て宝さんにチェックインしま[比心]\n\n
\n
Please keep in mind that accurate translation is not just about linguistic conversion;
it is about conveying the same sentiment and meaning as the original input.
Pay special attention to internet vernaculars and colloquial expressions to maintain the nuances of the
source language.\n\n
Now, proceed with the translation of the following Chinese comment text into Japanese, ensuring accuracy
and preservation of the original message:\n
Input:\n{original_text}\nOutput ONLY:\n
# 温度,用于控制令牌选择的随机性,较低的温度适合需要更具确定性或不够开放的回答的提示,而较高的温度可以产生更加多样化或更具创意的结果
temperature = 0.4
# 模型
model = deepseek-chat
# 提示词,换行符会替换为空格,“\n”会替换为换行符
prompt = 你是一个日本主播的翻译助手,精通中日文翻译。
用户会输入直播观众的聊天内容,请翻译成日文,并确保符合日文语言习惯。
你可以调整语气和风格,并考虑到某些词语的文化内涵和地区差异。
只回答翻译结果,不需要解释。
# 最大输出token数
max_tokens = 200
# 采样温度,用于控制令牌选择的随机性。较低的温度适合需要更具确定性或不够开放的回答的提示,而较高的温度可以产生更加多样化或更具创意的结果
temperature = 1.3
# 核采样概率阈值温度采样的替代方案。模型会考虑概率质量在top_p内的token结果
top_p = 1.0
# 傻逼B站获取表情都要登录开放平台也不发文本表情的URL我服了
@@ -279,3 +254,13 @@ temperature = 0.4
80 = [抱拳],http://i0.hdslb.com/bfs/live/3f170894dd08827ee293afcb5a3d2b60aecdb5b1.png
81 = [给力],http://i0.hdslb.com/bfs/live/d1ba5f4c54332a21ed2ca0dcecaedd2add587839.png
82 = [耶],http://i0.hdslb.com/bfs/live/eb2d84ba623e2335a48f73fb5bef87bcf53c1239.png
# 用于服务发现返回的后端端点
[registered_endpoints]
1 = http://localhost:12450
# 允许跨域的源,正则表达式
[cors_origins]
1 = http://localhost(:\d+)

View File

39
data/loader.html Normal file
View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>blivechat</title>
</head>
<body>
<p>Loading... Please run blivechat</p>
<script>
function main() {
let params = new URLSearchParams(window.location.search)
let url = params.get('url')
if (!url) {
let element = document.createElement('p')
element.innerText = 'No url specified'
document.body.appendChild(element)
return
}
let timerId = null
function poll() {
window.fetch(url, {mode: 'no-cors'}).then(
() => {
window.clearInterval(timerId)
window.location.href = url
},
() => {}
)
}
timerId = window.setInterval(poll, 1000)
poll()
}
main()
</script>
</body>
</html>

6
frontend/.env Normal file
View File

@@ -0,0 +1,6 @@
# 第三方库用CDN引入
LIB_USE_CDN=false
# production环境生成source map
PROD_SOURCE_MAP=true
# 动态发现后端endpoint
BACKEND_DISCOVERY=false

View File

@@ -0,0 +1,4 @@
NODE_ENV=production
LIB_USE_CDN=true
PROD_SOURCE_MAP=false
BACKEND_DISCOVERY=true

View File

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

1
frontend/.gitignore vendored
View File

@@ -5,6 +5,7 @@ node_modules
# local env files
.env.local
.env.*.local
!.env
# Log files
npm-debug.log*

View File

@@ -1,10 +1,11 @@
{
"name": "blivechat",
"version": "1.9.1",
"version": "1.10.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"build_common_server": "vue-cli-service build --mode common_server",
"lint": "vue-cli-service lint"
},
"dependencies": {
@@ -14,6 +15,7 @@
"downloadjs": "^1.4.7",
"element-ui": "^2.15.13",
"lodash": "^4.17.21",
"opossum": "^8.3.0",
"pako": "^2.1.0",
"vue": "^2.7.14",
"vue-i18n": "^8.28.2",

View File

@@ -11,23 +11,29 @@
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>blivechat</title>
<% if (process.env.NODE_ENV === 'production') { %>
<script src="https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.7.14/vue.runtime.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/vue-router/3.6.5/vue-router.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/vue-i18n/8.28.2/vue-i18n.min.js"></script>
<link href="https://cdn.bootcdn.net/ajax/libs/element-ui/2.15.13/theme-chalk/index.min.css" rel="stylesheet">
<script src="https://cdn.bootcdn.net/ajax/libs/element-ui/2.15.13/index.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/pako/2.1.0/pako_inflate.min.js"></script>
<%
if (process.env.LIB_USE_CDN) {
if (process.env.NODE_ENV === 'production') {
%>
<script src="https://s4.zstatic.net/ajax/libs/lodash.js/4.17.21/lodash.min.js" integrity="sha384-H6KKS1H1WwuERMSm+54dYLzjg0fKqRK5ZRyASdbrI/lwrCc6bXEmtGYr5SwvP1pZ" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/vue/2.7.14/vue.runtime.min.js" integrity="sha384-OuPzRFb9+YLr+ulVAQiUKWAYfQdbHQI3IvQ86+ApeLgPgTI2EJ/ySfV9UjvFFhxN" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/vue-router/3.6.5/vue-router.min.js" integrity="sha384-/NExOjjk+Jjk5ZvMGKDUq/QxHVEJxhJXBBEb9AVSjc1zeQUYhG9cLvz1pY1/QrQF" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/vue-i18n/8.28.2/vue-i18n.min.js" integrity="sha384-hcSOWoIpDyRLasgG/oDA+pDX1b7k6FX+8BkVxPvKXZ/w1ntszakmyGEJXgMeuWCU" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/element-ui/2.15.13/theme-chalk/index.min.css" integrity="sha384-I0kZgnELdQIeth/GMZEO2WQ1Po7jSQEkkqnh7bT+ofoJ9FVqFDhxeu7I10K5DyuV" crossorigin="anonymous">
<script src="https://s4.zstatic.net/ajax/libs/element-ui/2.15.13/index.min.js" integrity="sha384-Wzk9c33au7LnMJ233iNheTpEpVPeseLtoqnJElShswILwuk4JaiHjpj6fKD2yhoV" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/pako/2.1.0/pako_inflate.min.js" integrity="sha384-taEjHL+GUvC8IhXeCaTNUxz3O8ItsajGFLDFj4v/VJvM24HU36qP/6sRpuAGGfeT" crossorigin="anonymous"></script>
<% } else { %>
<script src="https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.21/lodash.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.7.14/vue.runtime.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/vue-router/3.6.5/vue-router.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/vue-i18n/8.28.2/vue-i18n.js"></script>
<link href="https://cdn.bootcdn.net/ajax/libs/element-ui/2.15.13/theme-chalk/index.css" rel="stylesheet">
<script src="https://cdn.bootcdn.net/ajax/libs/element-ui/2.15.13/index.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/pako/2.1.0/pako_inflate.js"></script>
<% } %>
<script src="https://s4.zstatic.net/ajax/libs/lodash.js/4.17.21/lodash.js" integrity="sha384-l3ZPesZ3gDMDOrzjEodAMRyQlQnAR6KFZN2hnIr+h8Y80fuKlD1jsjdxJpKr9XgP" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/vue/2.7.14/vue.runtime.js" integrity="sha384-7Eh7MYtBZnl23Wa8pdx0AXtu35xsvTaEW6ZXBaFGIND87X3ZvFyqUffgUXa2D+HM" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/vue-router/3.6.5/vue-router.js" integrity="sha384-IkMvPbEBTaZXtDP8XKR0vzQv6h12mn1Tsm0YRzlJ5yZQtQ0b0fI2upc3EB5erSWp" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/vue-i18n/8.28.2/vue-i18n.js" integrity="sha384-6JdClPjeS6N/byLmSMRUkfj8qIW6PR8InDu0hwcpUGithNg6/3+h6QpYNqB9LXXD" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://s4.zstatic.net/ajax/libs/element-ui/2.15.13/theme-chalk/index.css" integrity="sha384-i3Zi0wHs7p1SlIuNOWAJupwQHyh8Vg54HE50dXe6SiJwHglCpbLr5Nd9wiswohGS" crossorigin="anonymous">
<script src="https://s4.zstatic.net/ajax/libs/element-ui/2.15.13/index.js" integrity="sha384-xJNO3hLLbOGPVKrsu1WIty+KkzNuJKdRr4blB8iY29iXAqkeGJWBtHZ0DGCYi/OE" crossorigin="anonymous"></script>
<script src="https://s4.zstatic.net/ajax/libs/pako/2.1.0/pako_inflate.js" integrity="sha384-uHURdoyk6Dqn/gSq38WU6iUc3W0A1rnHIKEp5sjIz8w4yz1eKIM8N7WvNwKMzALi" crossorigin="anonymous"></script>
<%
}
}
%>
</head>
<body>
<noscript>

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

171
frontend/src/api/base.js Normal file
View File

@@ -0,0 +1,171 @@
import axios from 'axios'
import _ from 'lodash'
import CircuitBreaker from 'opossum'
axios.defaults.timeout = 10 * 1000
export const apiClient = axios.create({
timeout: 10 * 1000,
})
export let init
export let getBaseUrl
if (!process.env.BACKEND_DISCOVERY) {
init = async function() {}
const onRequest = config => {
config.baseURL = getBaseUrl()
return config
}
const onRequestError = e => {
throw e
}
apiClient.interceptors.request.use(onRequest, onRequestError, { synchronous: true })
getBaseUrl = function() {
return window.location.origin
}
} else {
init = async function() {
return updateBaseUrls()
}
const onRequest = config => {
let baseUrl = getBaseUrl()
if (baseUrl === null) {
throw new Error('No available endpoint')
}
config.baseURL = baseUrl
return config
}
const onRequestError = e => {
throw e
}
const onResponse = response => {
let promise = Promise.resolve(response)
let baseUrl = response.config.baseURL
let breaker = getOrAddCircuitBreaker(baseUrl)
breaker.fire(promise).catch(() => {})
return response
}
const onResponseError = e => {
let promise = Promise.reject(e)
if (!e.response || (500 <= e.response.status && e.response.status < 600)) {
let baseUrl = e.config.baseURL
let breaker = getOrAddCircuitBreaker(baseUrl)
breaker.fire(promise).catch(() => {})
}
return promise
}
apiClient.interceptors.request.use(onRequest, onRequestError, { synchronous: true })
apiClient.interceptors.response.use(onResponse, onResponseError)
const DISCOVERY_URLS = process.env.NODE_ENV === 'production' ? [
// 只有公共服务器会开BACKEND_DISCOVERY这里可以直接跨域访问
'https://api1.blive.chat/api/endpoints',
'https://api2.blive.chat/api/endpoints',
] : [
`${window.location.origin}/api/endpoints`,
'http://localhost:12450/api/endpoints',
]
let baseUrls = process.env.NODE_ENV === 'production' ? [
'https://api1.blive.chat',
'https://api2.blive.chat',
] : [
window.location.origin,
'http://localhost:12450',
]
let curBaseUrl = null
let baseUrlToCircuitBreaker = new Map()
const doUpdateBaseUrls = async() => {
async function requestGetUrls(discoveryUrl) {
try {
return (await axios.get(discoveryUrl)).data.endpoints
} catch (e) {
console.warn('Failed to discover server endpoints from one source:', e)
throw e
}
}
let _baseUrls = []
try {
let promises = DISCOVERY_URLS.map(requestGetUrls)
_baseUrls = await Promise.any(promises)
} catch {
}
if (_baseUrls.length === 0) {
console.error('Failed to discover server endpoints from any source')
return
}
// 按响应时间排序
let sortedBaseUrls = []
let errorBaseUrls = []
async function testEndpoint(baseUrl) {
try {
let url = `${baseUrl}/api/ping`
await axios.get(url, { timeout: 3 * 1000 })
sortedBaseUrls.push(baseUrl)
} catch {
errorBaseUrls.push(baseUrl)
}
}
await Promise.all(_baseUrls.map(testEndpoint))
sortedBaseUrls = sortedBaseUrls.concat(errorBaseUrls)
baseUrls = sortedBaseUrls
if (baseUrls.indexOf(curBaseUrl) === -1) {
curBaseUrl = null
}
console.log('Found server endpoints:', baseUrls)
}
const updateBaseUrls = _.throttle(doUpdateBaseUrls, 3 * 60 * 1000)
getBaseUrl = function() {
updateBaseUrls()
if (curBaseUrl !== null) {
let breaker = getOrAddCircuitBreaker(curBaseUrl)
if (!breaker.opened) {
return curBaseUrl
}
curBaseUrl = null
}
// 找第一个未熔断的
for (let baseUrl of baseUrls) {
let breaker = getOrAddCircuitBreaker(baseUrl)
if (!breaker.opened) {
curBaseUrl = baseUrl
console.log('Switch server endpoint to', curBaseUrl)
return curBaseUrl
}
}
return null
}
const getOrAddCircuitBreaker = baseUrl => {
let breaker = baseUrlToCircuitBreaker.get(baseUrl)
if (breaker === undefined) {
breaker = new CircuitBreaker(promise => promise, {
timeout: false,
rollingCountTimeout: 60 * 1000,
errorThresholdPercentage: 70,
resetTimeout: 60 * 1000,
})
baseUrlToCircuitBreaker.set(baseUrl, breaker)
}
return breaker
}
}

View File

@@ -1,5 +1,4 @@
import axios from 'axios'
import { apiClient as axios, getBaseUrl } from '@/api/base'
import * as chat from '.'
import * as chatModels from './models'
import * as base from './ChatClientOfficialBase'
@@ -14,6 +13,8 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
this.roomOwnerAuthCode = roomOwnerAuthCode
this.boundEndGameBeforeUnload = this.endGameBeforeUnload.bind(this)
// 调用initRoom后初始化
this.roomOwnerOpenId = null
this.hostServerUrlList = []
@@ -23,7 +24,15 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
this.gameHeartbeatTimerId = null
}
start() {
super.start()
window.addEventListener('beforeunload', this.boundEndGameBeforeUnload)
}
stop() {
window.removeEventListener('beforeunload', this.boundEndGameBeforeUnload)
this.endGame()
super.stop()
@@ -100,6 +109,29 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
return true
}
endGameBeforeUnload() {
let baseUrl = getBaseUrl()
if (baseUrl === null) {
return
}
this.needInitRoom = true
if (!this.gameId) {
return
}
let gameId = this.gameId
// 直接丢弃将要关闭的gameId
this.gameId = null
let url = `${baseUrl}/api/open_live/end_game`
let body = {
app_id: 0,
game_id: gameId
}
body = new Blob([JSON.stringify(body)], { type: 'application/json' })
window.navigator.sendBeacon(url, body)
}
onSendGameHeartbeat() {
// 加上随机延迟,减少同时请求的概率
let sleepTime = GAME_HEARTBEAT_INTERVAL - (2 * 1000) + (Math.random() * 3 * 1000)
@@ -182,18 +214,38 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
super.delayReconnect()
}
async dmCallback(command) {
interactionEndCallback(command) {
if (command.data.game_id !== this.gameId) {
return
}
// 服务器主动停止推送,可能是心跳超时,需要重新开启项目
console.error(`Open Live session end by server, gameId=${this.gameId}`)
this.addDebugMsg('Open Live session end by server')
this.gameId = null
this.needInitRoom = true
this.discardWebsocket()
}
dmCallback(command) {
let data = command.data
let authorType
if (data.open_id === this.roomOwnerOpenId) {
authorType = 3
} else if (data.is_admin) {
authorType = 2
} else if (data.guard_level !== 0) {
authorType = 1
} else {
authorType = 0
}
let showContent = data.msg
if (data.reply_uname !== '') {
showContent = `@${data.reply_uname} ${showContent}`
}
let emoticon = null
if (data.dm_type === 1) {
emoticon = data.emoji_img_url
@@ -204,19 +256,22 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
timestamp: data.timestamp,
authorName: data.uname,
authorType: authorType,
content: data.msg,
content: showContent,
privilegeType: data.guard_level,
isGiftDanmaku: chat.isGiftDanmakuByContent(data.msg),
medalLevel: data.fans_medal_wearing_status ? data.fans_medal_level : 0,
id: data.msg_id,
emoticon: emoticon,
// 给模板用的字段
uid: data.open_id,
medalName: data.fans_medal_wearing_status ? data.fans_medal_name : '',
})
this.msgHandler.onAddText(data)
}
sendGiftCallback(command) {
let data = command.data
let totalCoin = data.price * data.gift_num
let totalCoin = data.r_price * data.gift_num
data = new chatModels.AddGiftMsg({
id: data.msg_id,
avatarUrl: chat.processAvatarUrl(data.uface),
@@ -225,19 +280,33 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
totalCoin: data.paid ? totalCoin : 0,
totalFreeCoin: !data.paid ? totalCoin : 0,
giftName: data.gift_name,
num: data.gift_num
num: data.gift_num,
// 给模板用的字段
giftId: data.gift_id,
giftIconUrl: data.gift_icon,
uid: data.open_id,
privilegeType: data.guard_level,
medalLevel: data.fans_medal_wearing_status ? data.fans_medal_level : 0,
medalName: data.fans_medal_wearing_status ? data.fans_medal_name : '',
})
this.msgHandler.onAddGift(data)
}
async guardCallback(command) {
guardCallback(command) {
let data = command.data
data = new chatModels.AddMemberMsg({
id: data.msg_id,
avatarUrl: chat.processAvatarUrl(data.user_info.uface),
timestamp: data.timestamp,
authorName: data.user_info.uname,
privilegeType: data.guard_level
privilegeType: data.guard_level,
// 给模板用的字段
num: data.guard_num,
unit: data.guard_unit,
total_coin: data.price * data.guard_num,
uid: data.user_info.open_id,
medalLevel: data.fans_medal_wearing_status ? data.fans_medal_level : 0,
medalName: data.fans_medal_wearing_status ? data.fans_medal_name : '',
})
this.msgHandler.onAddMember(data)
}
@@ -251,6 +320,11 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
authorName: data.uname,
price: data.rmb,
content: data.message,
// 给模板用的字段
uid: data.open_id,
privilegeType: data.guard_level,
medalLevel: data.fans_medal_wearing_status ? data.fans_medal_level : 0,
medalName: data.fans_medal_wearing_status ? data.fans_medal_name : '',
})
this.msgHandler.onAddSuperChat(data)
}
@@ -266,6 +340,7 @@ export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
}
const CMD_CALLBACK_MAP = {
LIVE_OPEN_PLATFORM_INTERACTION_END: ChatClientDirectOpenLive.prototype.interactionEndCallback,
LIVE_OPEN_PLATFORM_DM: ChatClientDirectOpenLive.prototype.dmCallback,
LIVE_OPEN_PLATFORM_SEND_GIFT: ChatClientDirectOpenLive.prototype.sendGiftCallback,
LIVE_OPEN_PLATFORM_GUARD: ChatClientDirectOpenLive.prototype.guardCallback,

View File

@@ -1,5 +1,4 @@
import axios from 'axios'
import { apiClient as axios } from '@/api/base'
import * as chat from '.'
import * as chatModels from './models'
import * as base from './ChatClientOfficialBase'
@@ -71,12 +70,20 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase {
async danmuMsgCallback(command) {
let info = command.info
let roomId, medalLevel
let modeInfo = info[0][15]
let avatarUrl = modeInfo?.user?.base?.face ?? ''
if (!avatarUrl) {
avatarUrl = await chat.getAvatarUrl(uid, authorName)
}
let medalRoomId, medalLevel, medalName
if (info[3]) {
roomId = info[3][3]
medalRoomId = info[3][3]
medalLevel = info[3][0]
medalName = info[3][1]
} else {
roomId = medalLevel = 0
medalRoomId = medalLevel = 0
medalName = ''
}
let uid = info[2][0]
@@ -93,21 +100,40 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase {
authorType = 0
}
let authorName = info[2][1]
let extra
try {
extra = modeInfo.extra
if (typeof extra !== 'object') {
extra = JSON.parse(extra)
}
} catch {
extra = {}
}
let content = info[1]
let replyUname = extra.reply_uname ?? ''
let showContent = content
if (replyUname !== '') {
showContent = `@${replyUname} ${showContent}`
}
let authorName = info[2][1]
let data = new chatModels.AddTextMsg({
avatarUrl: await chat.getAvatarUrl(uid, authorName),
avatarUrl: avatarUrl,
timestamp: info[0][4] / 1000,
authorName: authorName,
authorType: authorType,
content: content,
content: showContent,
privilegeType: privilegeType,
isGiftDanmaku: Boolean(info[0][9]) || chat.isGiftDanmakuByContent(content),
authorLevel: info[4][0],
isNewbie: info[2][5] < 10000,
isMobileVerified: Boolean(info[2][6]),
medalLevel: roomId === this.roomId ? medalLevel : 0,
medalLevel: medalRoomId === this.roomId ? medalLevel : 0,
emoticon: info[0][13].url || null,
// 给模板用的字段
uid: info[2][0] ? info[2][0].toString() : authorName,
medalName: medalRoomId === this.roomId ? medalName : '',
})
this.msgHandler.onAddText(data)
}
@@ -115,6 +141,17 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase {
sendGiftCallback(command) {
let data = command.data
let isPaidGift = data.coin_type === 'gold'
let medalRuid, medalLevel, medalName
if (data.medal_info) {
medalRuid = data.medal_info.target_id
medalLevel = data.medal_info.medal_level
medalName = data.medal_info.medal_name
} else {
medalRuid = medalLevel = 0
medalName = ''
}
data = new chatModels.AddGiftMsg({
avatarUrl: chat.processAvatarUrl(data.face),
timestamp: data.timestamp,
@@ -122,24 +159,66 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase {
totalCoin: isPaidGift ? data.total_coin : 0,
totalFreeCoin: !isPaidGift ? data.total_coin : 0,
giftName: data.giftName,
num: data.num
num: data.num,
// 给模板用的字段
giftId: data.giftId,
giftIconUrl: data.gift_info.img_basic,
uid: data.uid ? data.uid.toString() : data.uname,
privilegeType: data.guard_level,
medalLevel: medalRuid === this.roomOwnerUid ? medalLevel : 0,
medalName: medalRuid === this.roomOwnerUid ? medalName : '',
})
this.msgHandler.onAddGift(data)
}
async guardBuyCallback(command) {
async userToastV2Callback(command) {
let data = command.data
let {
sender_uinfo: {
uid,
base: {
name: username,
},
},
guard_info: {
start_time: timestamp,
guard_level: guardLevel,
},
pay_info: {
num,
unit,
price,
},
} = data
data = new chatModels.AddMemberMsg({
avatarUrl: await chat.getAvatarUrl(data.uid, data.username),
timestamp: data.start_time,
authorName: data.username,
privilegeType: data.guard_level
avatarUrl: await chat.getAvatarUrl(uid, username),
timestamp: timestamp,
authorName: username,
privilegeType: guardLevel,
// 给模板用的字段
num: num,
unit: unit,
total_coin: price * num,
uid: uid ? uid.toString() : username,
medalLevel: 0,
medalName: '',
})
this.msgHandler.onAddMember(data)
}
superChatMessageCallback(command) {
let data = command.data
let medalRoomId, medalLevel, medalName
if (data.medal_info) {
medalRoomId = data.medal_info.anchor_roomid
medalLevel = data.medal_info.medal_level
medalName = data.medal_info.medal_name
} else {
medalRoomId = medalLevel = 0
medalName = ''
}
data = new chatModels.AddSuperChatMsg({
id: data.id.toString(),
avatarUrl: chat.processAvatarUrl(data.user_info.face),
@@ -147,6 +226,11 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase {
authorName: data.user_info.uname,
price: data.price,
content: data.message,
// 给模板用的字段
uid: data.uid ? data.uid.toString() : data.user_info.uname,
privilegeType: data.user_info.guard_level,
medalLevel: medalRoomId === this.roomId ? medalLevel : 0,
medalName: medalRoomId === this.roomId ? medalName : '',
})
this.msgHandler.onAddSuperChat(data)
}
@@ -164,7 +248,7 @@ export default class ChatClientDirectWeb extends ChatClientOfficialBase {
const CMD_CALLBACK_MAP = {
DANMU_MSG: ChatClientDirectWeb.prototype.danmuMsgCallback,
SEND_GIFT: ChatClientDirectWeb.prototype.sendGiftCallback,
GUARD_BUY: ChatClientDirectWeb.prototype.guardBuyCallback,
USER_TOAST_MSG_V2: ChatClientDirectWeb.prototype.userToastV2Callback,
SUPER_CHAT_MESSAGE: ChatClientDirectWeb.prototype.superChatMessageCallback,
SUPER_CHAT_MESSAGE_DELETE: ChatClientDirectWeb.prototype.superChatMessageDeleteCallback
}

View File

@@ -1,3 +1,4 @@
import { getBaseUrl } from '@/api/base'
import * as chat from '.'
import * as chatModels from './models'
@@ -52,8 +53,15 @@ export default class ChatClientRelay {
this.addDebugMsg('Connecting')
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const url = `${protocol}://${window.location.host}/api/chat`
let baseUrl = getBaseUrl()
if (baseUrl === null) {
this.addDebugMsg('No available endpoint')
window.setTimeout(() => this.onWsClose(), 0)
return
}
let url = baseUrl.replace(/^http(s?):/, 'ws$1:')
url += '/api/chat'
this.websocket = new WebSocket(url)
this.websocket.onopen = this.onWsOpen.bind(this)
this.websocket.onclose = this.onWsClose.bind(this)
@@ -172,7 +180,9 @@ export default class ChatClientRelay {
medalLevel: data[10],
id: data[11],
translation: data[12],
emoticon: emoticon
emoticon: emoticon,
uid: data[16],
medalName: data[17],
})
this.msgHandler.onAddText(data)
break

View File

@@ -3,59 +3,61 @@ import * as chat from '.'
import * as chatModels from './models'
const NAMES = [
'光羊',
'黑炎',
'孤梦星影',
'成龙',
'杨戬',
'孙悟空',
'哈基米',
'大张伟',
'周冠宇',
'五条悟',
'博丽灵梦',
'雾雨魔理沙',
'御剑侍伶',
'田所浩二',
'小岛秀夫',
'長崎そよ',
'柚木つばめ',
'空條承太郎',
'みもりあいの',
'ディオ・ブランドー',
'ジョセフ・ジョースター',
'ジョナサン・ジョースター',
'Simon',
'Misty',
'Kinori',
'shugen',
'3Shain',
'yuyuyzl',
'Dante',
'xQcOW',
'Makarov',
'xfgryujk',
'Il Harper',
'Jim Hacker',
'Rick Astley',
'Tifa Lockhart',
'Arthur Morgan',
]
const CONTENTS = [
'草',
'会赢的',
'让我看看',
'不要停下来啊',
'卑鄙的外乡人',
'我不做人了JOJO',
'想吃广东菜✋😭✋',
'已经没有什么好怕的了',
'我柜子动了,我不玩了',
'老板大气,老板身体健康',
'我醉提酒游寒山,爽滑慢舔',
'你这猴子,真令我欢喜',
'[dog]文本[比心]表情[喝彩]',
'阿祖,投降吧,外面全是警察',
'無駄無駄無駄無駄無駄無駄無駄無駄',
'欧啦欧啦欧啦欧啦欧啦欧啦欧啦欧啦',
'所有没好全部康复呀,我的癌也全部康复呀',
'嚯,朝我走过来了吗,没有选择逃跑而是主动接近我么',
'有一说一,这件事大家懂的都懂,不懂的,说了你也不明白,不如不说',
'如来来了吗?如来嘛~他真来了吗?如~来~到底来没来?如来~如来他真来了吗?如来~你看看,来没来?如~来~',
'我衰咗三年,我等緊個機會,爭番口氣',
'因为你的缘故,我的心中萌生了多余的情感',
'迷えば、敗れる',
'逃げるんだよォ!',
'竜神の剣を喰らえ!',
'竜が我が敌を喰らう!',
'言いたいことがあるんだよ!',
'知らず知らず隠してた 本当の声を響かせてよほら',
'届かない恋をしていても',
'なんで春日影やったの!?',
'kksk',
'Y.M.C.A.',
'8888888888',
'text[吃瓜]emoticon',
'Remember... no Russian',
'Never gonna give you up',
'Never gonna let you down',
'888888888888888888888888888888',
'I am the storm that is approaching',
"I can eat glass, it doesn't hurt me",
'The quick brown fox jumps over the lazy dog',
'DU↗DU→DU↗DU↓ Max Verstappen',
'Farewell, ashen one. May the flame guide thee',
'I am the bone of my sword. Steel is my body, and fire is my blood.',
'Hey Vergil, your portal opening days are over. Give me the Yamato',
'<script>alert("CHECK YOUR CODE")</script>',
'<img src=1 onerror="alert(\'CHECK YOUR CODE\')">',
]
const EMOTICONS = [
@@ -64,7 +66,13 @@ const EMOTICONS = [
'lipu',
'huangdou_xihuan',
'sakaban_jiayu_yutou',
].map(name => `/static/img/emoticons/${name}.png`)
].map(name => `${window.location.origin}/static/img/emoticons/${name}.png`)
const TRANSLATIONS = [
'这是翻译',
'これは翻訳です',
'blah blah blah',
]
const AUTHOR_TYPES = [
{ weight: 10, value: constants.AUTHOR_TYPE_NORMAL },
@@ -73,6 +81,10 @@ const AUTHOR_TYPES = [
{ weight: 1, value: constants.AUTHOR_TYPE_OWNER }
]
const GUARD_LEVEL_TO_PRICE = [
0, 19998, 1998, 198
]
function randGuardInfo() {
let authorType = randomChoose(AUTHOR_TYPES)
let privilegeType
@@ -86,14 +98,28 @@ function randGuardInfo() {
return { authorType, privilegeType }
}
const MEDAL_NAMES = [
'ikun',
'K学姐',
'小孤独',
'Go学长',
'不登校',
]
function randMedalInfo() {
let medalLevel = randInt(1, 4) <= 1 ? 0 : randInt(1, 40)
let medalName = medalLevel === 0 ? '' : randomChoose(MEDAL_NAMES)
return { medalLevel, medalName }
}
const GIFT_INFO_LIST = [
{ giftName: '辣条', totalFreeCoin: 1000, num: 10 },
{ giftName: 'B坷垃', totalCoin: 9900 },
{ giftName: '花', totalCoin: 28000 },
{ giftName: '花式夸夸', totalCoin: 39000 },
{ giftName: '天空之翼', totalCoin: 100000 },
{ giftName: '摩天大楼', totalCoin: 450000 },
{ giftName: '小电视飞船', totalCoin: 1245000 }
{ giftId: 1, giftName: '粉丝团灯牌', totalFreeCoin: 200, num: 10, giftIconUrl: '//s1.hdslb.com/bfs/live/e051dfd4557678f8edcac4993ed00a0935cbd9cc.png' },
{ giftId: 2, giftName: '可爱捏', totalCoin: 9900, giftIconUrl: '//s1.hdslb.com/bfs/live/6dab14826b531c731521345e00d6b56a6708a449.png' },
{ giftId: 3, giftName: '花式夸夸', totalCoin: 29900, giftIconUrl: '//s1.hdslb.com/bfs/live/28186596880db45a7b843f17d6ebb70feeac06f9.png' },
{ giftId: 4, giftName: '情书', totalCoin: 52000, num: 10, giftIconUrl: '//s1.hdslb.com/bfs/live/14dafbf217618f0931c08897e0b3eefc00d0da22.png' },
{ giftId: 5, giftName: '极速超跑', totalCoin: 100000, giftIconUrl: '//s1.hdslb.com/bfs/live/27b9734d1a5f77ea6fc94957e3fcbeb55505c6b9.png' },
{ giftId: 6, giftName: '为你摘星', totalCoin: 520000, giftIconUrl: '//s1.hdslb.com/bfs/live/5bd584b6fdfb03d66de56102e775582fb29ceab7.png' },
{ giftId: 7, giftName: '次元之城', totalCoin: 1245000, giftIconUrl: '//s1.hdslb.com/bfs/live/cdae8136b1ee767609aeec688bca8124651d4d01.png' }
]
const SC_PRICES = [
@@ -109,13 +135,14 @@ const MESSAGE_GENERATORS = [
type: constants.MESSAGE_TYPE_TEXT,
message: new chatModels.AddTextMsg({
...randGuardInfo(),
...randMedalInfo(),
authorName: randomChoose(NAMES),
content: randomChoose(CONTENTS),
isGiftDanmaku: randInt(1, 10) <= 1,
authorLevel: randInt(1, 60),
isNewbie: randInt(1, 10) <= 1,
isMobileVerified: randInt(1, 10) <= 9,
medalLevel: randInt(0, 40),
translation: randInt(1, 10) <= 1 ? randomChoose(TRANSLATIONS) : '',
})
}
}
@@ -128,11 +155,11 @@ const MESSAGE_GENERATORS = [
type: constants.MESSAGE_TYPE_TEXT,
message: new chatModels.AddTextMsg({
...randGuardInfo(),
...randMedalInfo(),
authorName: randomChoose(NAMES),
authorLevel: randInt(1, 60),
isNewbie: randInt(1, 10) <= 1,
isMobileVerified: randInt(1, 10) <= 9,
medalLevel: randInt(0, 40),
emoticon: randomChoose(EMOTICONS),
})
}
@@ -145,8 +172,10 @@ const MESSAGE_GENERATORS = [
return {
type: constants.MESSAGE_TYPE_GIFT,
message: new chatModels.AddGiftMsg({
...randMedalInfo(),
...randomChoose(GIFT_INFO_LIST),
authorName: randomChoose(NAMES),
privilegeType: randInt(0, 3),
})
}
}
@@ -158,9 +187,12 @@ const MESSAGE_GENERATORS = [
return {
type: constants.MESSAGE_TYPE_SUPER_CHAT,
message: new chatModels.AddSuperChatMsg({
...randMedalInfo(),
authorName: randomChoose(NAMES),
price: randomChoose(SC_PRICES),
content: randomChoose(CONTENTS),
translation: randInt(1, 10) <= 1 ? randomChoose(TRANSLATIONS) : '',
privilegeType: randInt(0, 3),
})
}
}
@@ -169,11 +201,14 @@ const MESSAGE_GENERATORS = [
{
weight: 1,
value() {
let privilegeType = randInt(1, 3)
return {
type: constants.MESSAGE_TYPE_MEMBER,
message: new chatModels.AddMemberMsg({
...randMedalInfo(),
authorName: randomChoose(NAMES),
privilegeType: randInt(1, 3)
privilegeType: privilegeType,
total_coin: GUARD_LEVEL_TO_PRICE[privilegeType] * 1000,
})
}
}
@@ -251,6 +286,7 @@ export default class ChatClientTest {
switch (type) {
case constants.MESSAGE_TYPE_TEXT:
this.msgHandler.onAddText(message)
this.maybeTranslate(message)
break
case constants.MESSAGE_TYPE_GIFT:
this.msgHandler.onAddGift(message)
@@ -260,7 +296,34 @@ export default class ChatClientTest {
break
case constants.MESSAGE_TYPE_SUPER_CHAT:
this.msgHandler.onAddSuperChat(message)
this.maybeTranslate(message)
this.maybeDeleteSc(message)
break
}
}
maybeTranslate(message) {
if (message.translation || randInt(1, 4) <= 1) {
return
}
window.setTimeout(() => {
let translateMessage = new chatModels.UpdateTranslationMsg({
id: message.id,
translation: randomChoose(TRANSLATIONS),
})
this.msgHandler.onUpdateTranslation(translateMessage)
}, randInt(1000, 3000))
}
maybeDeleteSc(message) {
if (randInt(1, 5) <= 4) {
return
}
window.setTimeout(() => {
let deleteMessage = new chatModels.DelSuperChatMsg({
ids: [message.id],
})
this.msgHandler.onDelSuperChat(deleteMessage)
}, randInt(1000, 3000))
}
}

View File

@@ -1,6 +1,7 @@
import axios from 'axios'
import MD5 from 'crypto-js/md5'
import { apiClient as axios } from '@/api/base'
export function getDefaultMsgHandler() {
let dummyFunc = () => {}
return {

View File

@@ -18,6 +18,9 @@ export class AddTextMsg {
id = getUuid4Hex(),
translation = '',
emoticon = null,
// 给模板用的字段
uid = '',
medalName = '',
} = {}) {
this.avatarUrl = avatarUrl
this.timestamp = timestamp
@@ -33,6 +36,9 @@ export class AddTextMsg {
this.id = id
this.translation = translation
this.emoticon = emoticon
// 给模板用的字段
this.uid = uid
this.medalName = medalName
}
}
@@ -46,6 +52,13 @@ export class AddGiftMsg {
totalFreeCoin = 0,
giftName = '',
num = 1,
// 给模板用的字段
giftId = 0,
giftIconUrl = '',
uid = '',
privilegeType = 0,
medalLevel = 0,
medalName = '',
} = {}) {
this.id = id
this.avatarUrl = avatarUrl
@@ -55,6 +68,13 @@ export class AddGiftMsg {
this.totalFreeCoin = totalFreeCoin
this.giftName = giftName
this.num = num
// 给模板用的字段
this.giftId = giftId
this.giftIconUrl = giftIconUrl
this.uid = uid
this.privilegeType = privilegeType
this.medalLevel = medalLevel
this.medalName = medalName
}
}
@@ -65,12 +85,26 @@ export class AddMemberMsg {
timestamp = new Date().getTime() / 1000,
authorName = '',
privilegeType = 1,
// 给模板用的字段
num = 1,
unit = '月',
total_coin = 0,
uid = '',
medalLevel = 0,
medalName = '',
} = {}) {
this.id = id
this.avatarUrl = avatarUrl
this.timestamp = timestamp
this.authorName = authorName
this.privilegeType = privilegeType
// 给模板用的字段
this.num = num
this.unit = unit
this.totalCoin = total_coin
this.uid = uid
this.medalLevel = medalLevel
this.medalName = medalName
}
}
@@ -83,6 +117,11 @@ export class AddSuperChatMsg {
price = 0,
content = '',
translation = '',
// 给模板用的字段
uid = '',
privilegeType = 0,
medalLevel = 0,
medalName = '',
} = {}) {
this.id = id
this.avatarUrl = avatarUrl
@@ -91,6 +130,11 @@ export class AddSuperChatMsg {
this.price = price
this.content = content
this.translation = translation
// 给模板用的字段
this.uid = uid
this.privilegeType = privilegeType
this.medalLevel = medalLevel
this.medalName = medalName
}
}

View File

@@ -25,7 +25,9 @@ export const DEFAULT_CONFIG = {
giftUsernamePronunciation: '',
importPresetCss: false,
emoticons: [] // [{ keyword: '', url: '' }, ...]
emoticons: [], // [{ keyword: '', url: '' }, ...]
templateUrl: '',
}
export function deepCloneDefaultConfig() {

View File

@@ -1,4 +1,4 @@
import axios from 'axios'
import { apiClient as axios } from './base'
export async function getServerInfo() {
return (await axios.get('/api/server_info')).data
@@ -9,3 +9,7 @@ export async function uploadEmoticon(file) {
body.set('file', file)
return (await axios.post('/api/emoticon', body)).data
}
export async function getTemplates() {
return (await axios.get('/api/templates')).data
}

View File

@@ -1,4 +1,4 @@
import axios from 'axios'
import { apiClient as axios } from './base'
export async function getPlugins() {
return (await axios.get('/api/plugin/plugins')).data

View File

@@ -1,3 +1,5 @@
@layer yt {
html:not(.style-scope) {
--yt-live-chat-background-color: hsl(0, 0%, 100%);
--yt-live-chat-action-panel-background-color: hsla(0, 0%, 93.3%, .4);
@@ -360,3 +362,5 @@ html:not(.style-scope) {
--layout-fixed-left_-_left: 0;
;
}
}

View File

@@ -1,3 +1,5 @@
@layer yt {
canvas.yt-icon, caption.yt-icon, center.yt-icon, cite.yt-icon, code.yt-icon, dd.yt-icon, del.yt-icon, dfn.yt-icon, div.yt-icon, dl.yt-icon, dt.yt-icon, em.yt-icon, embed.yt-icon, fieldset.yt-icon, font.yt-icon, form.yt-icon, h1.yt-icon, h2.yt-icon, h3.yt-icon, h4.yt-icon, h5.yt-icon, h6.yt-icon, hr.yt-icon, i.yt-icon, iframe.yt-icon, img.yt-icon, ins.yt-icon, kbd.yt-icon, label.yt-icon, legend.yt-icon, li.yt-icon, menu.yt-icon, object.yt-icon, ol.yt-icon, p.yt-icon, pre.yt-icon, q.yt-icon, s.yt-icon, samp.yt-icon, small.yt-icon, span.yt-icon, strike.yt-icon, strong.yt-icon, sub.yt-icon, sup.yt-icon, table.yt-icon, tbody.yt-icon, td.yt-icon, tfoot.yt-icon, th.yt-icon, thead.yt-icon, tr.yt-icon, tt.yt-icon, u.yt-icon, ul.yt-icon, var.yt-icon {
margin: 0;
padding: 0;
@@ -28,3 +30,5 @@ yt-icon, .yt-icon-container.yt-icon {
yt-icon.external-container {
display: none !important;
}
}

View File

@@ -1,3 +1,5 @@
@layer yt {
canvas.yt-img-shadow, caption.yt-img-shadow, center.yt-img-shadow, cite.yt-img-shadow, code.yt-img-shadow, dd.yt-img-shadow, del.yt-img-shadow, dfn.yt-img-shadow, div.yt-img-shadow, dl.yt-img-shadow, dt.yt-img-shadow, em.yt-img-shadow, embed.yt-img-shadow, fieldset.yt-img-shadow, font.yt-img-shadow, form.yt-img-shadow, h1.yt-img-shadow, h2.yt-img-shadow, h3.yt-img-shadow, h4.yt-img-shadow, h5.yt-img-shadow, h6.yt-img-shadow, hr.yt-img-shadow, i.yt-img-shadow, iframe.yt-img-shadow, img.yt-img-shadow, ins.yt-img-shadow, kbd.yt-img-shadow, label.yt-img-shadow, legend.yt-img-shadow, li.yt-img-shadow, menu.yt-img-shadow, object.yt-img-shadow, ol.yt-img-shadow, p.yt-img-shadow, pre.yt-img-shadow, q.yt-img-shadow, s.yt-img-shadow, samp.yt-img-shadow, small.yt-img-shadow, span.yt-img-shadow, strike.yt-img-shadow, strong.yt-img-shadow, sub.yt-img-shadow, sup.yt-img-shadow, table.yt-img-shadow, tbody.yt-img-shadow, td.yt-img-shadow, tfoot.yt-img-shadow, th.yt-img-shadow, thead.yt-img-shadow, tr.yt-img-shadow, tt.yt-img-shadow, u.yt-img-shadow, ul.yt-img-shadow, var.yt-img-shadow {
margin: 0;
padding: 0;
@@ -66,3 +68,5 @@ img.yt-img-shadow {
max-width: 100%;
border-radius: none;
}
}

View File

@@ -1,3 +1,5 @@
@layer yt {
canvas.yt-live-chat-author-badge-renderer, caption.yt-live-chat-author-badge-renderer, center.yt-live-chat-author-badge-renderer, cite.yt-live-chat-author-badge-renderer, code.yt-live-chat-author-badge-renderer, dd.yt-live-chat-author-badge-renderer, del.yt-live-chat-author-badge-renderer, dfn.yt-live-chat-author-badge-renderer, div.yt-live-chat-author-badge-renderer, dl.yt-live-chat-author-badge-renderer, dt.yt-live-chat-author-badge-renderer, em.yt-live-chat-author-badge-renderer, embed.yt-live-chat-author-badge-renderer, fieldset.yt-live-chat-author-badge-renderer, font.yt-live-chat-author-badge-renderer, form.yt-live-chat-author-badge-renderer, h1.yt-live-chat-author-badge-renderer, h2.yt-live-chat-author-badge-renderer, h3.yt-live-chat-author-badge-renderer, h4.yt-live-chat-author-badge-renderer, h5.yt-live-chat-author-badge-renderer, h6.yt-live-chat-author-badge-renderer, hr.yt-live-chat-author-badge-renderer, i.yt-live-chat-author-badge-renderer, iframe.yt-live-chat-author-badge-renderer, img.yt-live-chat-author-badge-renderer, ins.yt-live-chat-author-badge-renderer, kbd.yt-live-chat-author-badge-renderer, label.yt-live-chat-author-badge-renderer, legend.yt-live-chat-author-badge-renderer, li.yt-live-chat-author-badge-renderer, menu.yt-live-chat-author-badge-renderer, object.yt-live-chat-author-badge-renderer, ol.yt-live-chat-author-badge-renderer, p.yt-live-chat-author-badge-renderer, pre.yt-live-chat-author-badge-renderer, q.yt-live-chat-author-badge-renderer, s.yt-live-chat-author-badge-renderer, samp.yt-live-chat-author-badge-renderer, small.yt-live-chat-author-badge-renderer, span.yt-live-chat-author-badge-renderer, strike.yt-live-chat-author-badge-renderer, strong.yt-live-chat-author-badge-renderer, sub.yt-live-chat-author-badge-renderer, sup.yt-live-chat-author-badge-renderer, table.yt-live-chat-author-badge-renderer, tbody.yt-live-chat-author-badge-renderer, td.yt-live-chat-author-badge-renderer, tfoot.yt-live-chat-author-badge-renderer, th.yt-live-chat-author-badge-renderer, thead.yt-live-chat-author-badge-renderer, tr.yt-live-chat-author-badge-renderer, tt.yt-live-chat-author-badge-renderer, u.yt-live-chat-author-badge-renderer, ul.yt-live-chat-author-badge-renderer, var.yt-live-chat-author-badge-renderer {
margin: 0;
padding: 0;
@@ -34,3 +36,5 @@ img.yt-live-chat-author-badge-renderer, yt-icon.yt-live-chat-author-badge-render
width: 16px;
height: 16px;
}
}

View File

@@ -1,3 +1,5 @@
@layer yt {
canvas.yt-live-chat-author-chip, caption.yt-live-chat-author-chip, center.yt-live-chat-author-chip, cite.yt-live-chat-author-chip, code.yt-live-chat-author-chip, dd.yt-live-chat-author-chip, del.yt-live-chat-author-chip, dfn.yt-live-chat-author-chip, div.yt-live-chat-author-chip, dl.yt-live-chat-author-chip, dt.yt-live-chat-author-chip, em.yt-live-chat-author-chip, embed.yt-live-chat-author-chip, fieldset.yt-live-chat-author-chip, font.yt-live-chat-author-chip, form.yt-live-chat-author-chip, h1.yt-live-chat-author-chip, h2.yt-live-chat-author-chip, h3.yt-live-chat-author-chip, h4.yt-live-chat-author-chip, h5.yt-live-chat-author-chip, h6.yt-live-chat-author-chip, hr.yt-live-chat-author-chip, i.yt-live-chat-author-chip, iframe.yt-live-chat-author-chip, img.yt-live-chat-author-chip, ins.yt-live-chat-author-chip, kbd.yt-live-chat-author-chip, label.yt-live-chat-author-chip, legend.yt-live-chat-author-chip, li.yt-live-chat-author-chip, menu.yt-live-chat-author-chip, object.yt-live-chat-author-chip, ol.yt-live-chat-author-chip, p.yt-live-chat-author-chip, pre.yt-live-chat-author-chip, q.yt-live-chat-author-chip, s.yt-live-chat-author-chip, samp.yt-live-chat-author-chip, small.yt-live-chat-author-chip, span.yt-live-chat-author-chip, strike.yt-live-chat-author-chip, strong.yt-live-chat-author-chip, sub.yt-live-chat-author-chip, sup.yt-live-chat-author-chip, table.yt-live-chat-author-chip, tbody.yt-live-chat-author-chip, td.yt-live-chat-author-chip, tfoot.yt-live-chat-author-chip, th.yt-live-chat-author-chip, thead.yt-live-chat-author-chip, tr.yt-live-chat-author-chip, tt.yt-live-chat-author-chip, u.yt-live-chat-author-chip, ul.yt-live-chat-author-chip, var.yt-live-chat-author-chip {
margin: 0;
padding: 0;
@@ -62,3 +64,5 @@ yt-live-chat-author-chip[is-highlighted] #chip-badges.yt-live-chat-author-chip y
#chip-badges.yt-live-chat-author-chip yt-live-chat-author-badge-renderer.yt-live-chat-author-chip:last-of-type {
margin-right: -2px;
}
}

View File

@@ -1,3 +1,5 @@
@layer yt {
canvas.yt-live-chat-item-list-renderer, caption.yt-live-chat-item-list-renderer, center.yt-live-chat-item-list-renderer, cite.yt-live-chat-item-list-renderer, code.yt-live-chat-item-list-renderer, dd.yt-live-chat-item-list-renderer, del.yt-live-chat-item-list-renderer, dfn.yt-live-chat-item-list-renderer, div.yt-live-chat-item-list-renderer, dl.yt-live-chat-item-list-renderer, dt.yt-live-chat-item-list-renderer, em.yt-live-chat-item-list-renderer, embed.yt-live-chat-item-list-renderer, fieldset.yt-live-chat-item-list-renderer, font.yt-live-chat-item-list-renderer, form.yt-live-chat-item-list-renderer, h1.yt-live-chat-item-list-renderer, h2.yt-live-chat-item-list-renderer, h3.yt-live-chat-item-list-renderer, h4.yt-live-chat-item-list-renderer, h5.yt-live-chat-item-list-renderer, h6.yt-live-chat-item-list-renderer, hr.yt-live-chat-item-list-renderer, i.yt-live-chat-item-list-renderer, iframe.yt-live-chat-item-list-renderer, img.yt-live-chat-item-list-renderer, ins.yt-live-chat-item-list-renderer, kbd.yt-live-chat-item-list-renderer, label.yt-live-chat-item-list-renderer, legend.yt-live-chat-item-list-renderer, li.yt-live-chat-item-list-renderer, menu.yt-live-chat-item-list-renderer, object.yt-live-chat-item-list-renderer, ol.yt-live-chat-item-list-renderer, p.yt-live-chat-item-list-renderer, pre.yt-live-chat-item-list-renderer, q.yt-live-chat-item-list-renderer, s.yt-live-chat-item-list-renderer, samp.yt-live-chat-item-list-renderer, small.yt-live-chat-item-list-renderer, span.yt-live-chat-item-list-renderer, strike.yt-live-chat-item-list-renderer, strong.yt-live-chat-item-list-renderer, sub.yt-live-chat-item-list-renderer, sup.yt-live-chat-item-list-renderer, table.yt-live-chat-item-list-renderer, tbody.yt-live-chat-item-list-renderer, td.yt-live-chat-item-list-renderer, tfoot.yt-live-chat-item-list-renderer, th.yt-live-chat-item-list-renderer, thead.yt-live-chat-item-list-renderer, tr.yt-live-chat-item-list-renderer, tt.yt-live-chat-item-list-renderer, u.yt-live-chat-item-list-renderer, ul.yt-live-chat-item-list-renderer, var.yt-live-chat-item-list-renderer {
margin: 0;
padding: 0;
@@ -138,3 +140,5 @@ yt-live-chat-paid-sticker-renderer.yt-live-chat-item-list-renderer {
yt-live-chat-paid-sticker-renderer.yt-live-chat-item-list-renderer[dashboard-money-feed] {
padding: 8px 16px;
}
}

View File

@@ -1,3 +1,5 @@
@layer yt {
#timestamp.yt-live-chat-membership-item-renderer {
display: var(--yt-live-chat-item-timestamp-display, inline);
margin: var(--yt-live-chat-item-timestamp-margin, 0 8px 0 0);
@@ -360,3 +362,5 @@ yt-live-chat-membership-item-renderer[dashboard-money-feed] #menu.yt-live-chat-m
margin-top: 8px;
background: linear-gradient(to right, transparent, var(--yt-live-chat-background-color) 40%);
}
}

View File

@@ -1,3 +1,5 @@
@layer yt {
canvas.yt-live-chat-paid-message-renderer, caption.yt-live-chat-paid-message-renderer, center.yt-live-chat-paid-message-renderer, cite.yt-live-chat-paid-message-renderer, code.yt-live-chat-paid-message-renderer, dd.yt-live-chat-paid-message-renderer, del.yt-live-chat-paid-message-renderer, dfn.yt-live-chat-paid-message-renderer, div.yt-live-chat-paid-message-renderer, dl.yt-live-chat-paid-message-renderer, dt.yt-live-chat-paid-message-renderer, em.yt-live-chat-paid-message-renderer, embed.yt-live-chat-paid-message-renderer, fieldset.yt-live-chat-paid-message-renderer, font.yt-live-chat-paid-message-renderer, form.yt-live-chat-paid-message-renderer, h1.yt-live-chat-paid-message-renderer, h2.yt-live-chat-paid-message-renderer, h3.yt-live-chat-paid-message-renderer, h4.yt-live-chat-paid-message-renderer, h5.yt-live-chat-paid-message-renderer, h6.yt-live-chat-paid-message-renderer, hr.yt-live-chat-paid-message-renderer, i.yt-live-chat-paid-message-renderer, iframe.yt-live-chat-paid-message-renderer, img.yt-live-chat-paid-message-renderer, ins.yt-live-chat-paid-message-renderer, kbd.yt-live-chat-paid-message-renderer, label.yt-live-chat-paid-message-renderer, legend.yt-live-chat-paid-message-renderer, li.yt-live-chat-paid-message-renderer, menu.yt-live-chat-paid-message-renderer, object.yt-live-chat-paid-message-renderer, ol.yt-live-chat-paid-message-renderer, p.yt-live-chat-paid-message-renderer, pre.yt-live-chat-paid-message-renderer, q.yt-live-chat-paid-message-renderer, s.yt-live-chat-paid-message-renderer, samp.yt-live-chat-paid-message-renderer, small.yt-live-chat-paid-message-renderer, span.yt-live-chat-paid-message-renderer, strike.yt-live-chat-paid-message-renderer, strong.yt-live-chat-paid-message-renderer, sub.yt-live-chat-paid-message-renderer, sup.yt-live-chat-paid-message-renderer, table.yt-live-chat-paid-message-renderer, tbody.yt-live-chat-paid-message-renderer, td.yt-live-chat-paid-message-renderer, tfoot.yt-live-chat-paid-message-renderer, th.yt-live-chat-paid-message-renderer, thead.yt-live-chat-paid-message-renderer, tr.yt-live-chat-paid-message-renderer, tt.yt-live-chat-paid-message-renderer, u.yt-live-chat-paid-message-renderer, ul.yt-live-chat-paid-message-renderer, var.yt-live-chat-paid-message-renderer {
margin: 0;
padding: 0;
@@ -378,3 +380,5 @@ yt-live-chat-paid-message-renderer[show-footer-divider] #footer.yt-live-chat-pai
yt-live-chat-paid-message-renderer[is-user-editable] #footer.yt-live-chat-paid-message-renderer:not(:empty) {
padding-top: 8px;
}
}

View File

@@ -1,3 +1,5 @@
@layer yt {
yt-live-chat-renderer, yt-live-chat-item-list-renderer #item-scroller {
height: 100%;
}
@@ -250,3 +252,5 @@ yt-live-chat-renderer[collapsed] .hide-on-collapse.yt-live-chat-renderer {
background-color: rgba(0, 0, 0, 0.60);
}
}
}

View File

@@ -1,3 +1,5 @@
@layer yt {
canvas.yt-live-chat-text-message-renderer, caption.yt-live-chat-text-message-renderer, center.yt-live-chat-text-message-renderer, cite.yt-live-chat-text-message-renderer, code.yt-live-chat-text-message-renderer, dd.yt-live-chat-text-message-renderer, del.yt-live-chat-text-message-renderer, dfn.yt-live-chat-text-message-renderer, div.yt-live-chat-text-message-renderer, dl.yt-live-chat-text-message-renderer, dt.yt-live-chat-text-message-renderer, em.yt-live-chat-text-message-renderer, embed.yt-live-chat-text-message-renderer, fieldset.yt-live-chat-text-message-renderer, font.yt-live-chat-text-message-renderer, form.yt-live-chat-text-message-renderer, h1.yt-live-chat-text-message-renderer, h2.yt-live-chat-text-message-renderer, h3.yt-live-chat-text-message-renderer, h4.yt-live-chat-text-message-renderer, h5.yt-live-chat-text-message-renderer, h6.yt-live-chat-text-message-renderer, hr.yt-live-chat-text-message-renderer, i.yt-live-chat-text-message-renderer, iframe.yt-live-chat-text-message-renderer, img.yt-live-chat-text-message-renderer, ins.yt-live-chat-text-message-renderer, kbd.yt-live-chat-text-message-renderer, label.yt-live-chat-text-message-renderer, legend.yt-live-chat-text-message-renderer, li.yt-live-chat-text-message-renderer, menu.yt-live-chat-text-message-renderer, object.yt-live-chat-text-message-renderer, ol.yt-live-chat-text-message-renderer, p.yt-live-chat-text-message-renderer, pre.yt-live-chat-text-message-renderer, q.yt-live-chat-text-message-renderer, s.yt-live-chat-text-message-renderer, samp.yt-live-chat-text-message-renderer, small.yt-live-chat-text-message-renderer, span.yt-live-chat-text-message-renderer, strike.yt-live-chat-text-message-renderer, strong.yt-live-chat-text-message-renderer, sub.yt-live-chat-text-message-renderer, sup.yt-live-chat-text-message-renderer, table.yt-live-chat-text-message-renderer, tbody.yt-live-chat-text-message-renderer, td.yt-live-chat-text-message-renderer, tfoot.yt-live-chat-text-message-renderer, th.yt-live-chat-text-message-renderer, thead.yt-live-chat-text-message-renderer, tr.yt-live-chat-text-message-renderer, tt.yt-live-chat-text-message-renderer, u.yt-live-chat-text-message-renderer, ul.yt-live-chat-text-message-renderer, var.yt-live-chat-text-message-renderer {
margin: 0;
padding: 0;
@@ -230,3 +232,5 @@ yt-live-chat-text-message-renderer[is-dimmed] #message.yt-live-chat-text-message
yt-live-chat-text-message-renderer[is-dimmed]::before {
background: var(--yt-live-chat-error-message-color, #f44336);
}
}

View File

@@ -1,3 +1,5 @@
@layer yt {
canvas.yt-live-chat-ticker-paid-message-item-renderer, caption.yt-live-chat-ticker-paid-message-item-renderer, center.yt-live-chat-ticker-paid-message-item-renderer, cite.yt-live-chat-ticker-paid-message-item-renderer, code.yt-live-chat-ticker-paid-message-item-renderer, dd.yt-live-chat-ticker-paid-message-item-renderer, del.yt-live-chat-ticker-paid-message-item-renderer, dfn.yt-live-chat-ticker-paid-message-item-renderer, div.yt-live-chat-ticker-paid-message-item-renderer, dl.yt-live-chat-ticker-paid-message-item-renderer, dt.yt-live-chat-ticker-paid-message-item-renderer, em.yt-live-chat-ticker-paid-message-item-renderer, embed.yt-live-chat-ticker-paid-message-item-renderer, fieldset.yt-live-chat-ticker-paid-message-item-renderer, font.yt-live-chat-ticker-paid-message-item-renderer, form.yt-live-chat-ticker-paid-message-item-renderer, h1.yt-live-chat-ticker-paid-message-item-renderer, h2.yt-live-chat-ticker-paid-message-item-renderer, h3.yt-live-chat-ticker-paid-message-item-renderer, h4.yt-live-chat-ticker-paid-message-item-renderer, h5.yt-live-chat-ticker-paid-message-item-renderer, h6.yt-live-chat-ticker-paid-message-item-renderer, hr.yt-live-chat-ticker-paid-message-item-renderer, i.yt-live-chat-ticker-paid-message-item-renderer, iframe.yt-live-chat-ticker-paid-message-item-renderer, img.yt-live-chat-ticker-paid-message-item-renderer, ins.yt-live-chat-ticker-paid-message-item-renderer, kbd.yt-live-chat-ticker-paid-message-item-renderer, label.yt-live-chat-ticker-paid-message-item-renderer, legend.yt-live-chat-ticker-paid-message-item-renderer, li.yt-live-chat-ticker-paid-message-item-renderer, menu.yt-live-chat-ticker-paid-message-item-renderer, object.yt-live-chat-ticker-paid-message-item-renderer, ol.yt-live-chat-ticker-paid-message-item-renderer, p.yt-live-chat-ticker-paid-message-item-renderer, pre.yt-live-chat-ticker-paid-message-item-renderer, q.yt-live-chat-ticker-paid-message-item-renderer, s.yt-live-chat-ticker-paid-message-item-renderer, samp.yt-live-chat-ticker-paid-message-item-renderer, small.yt-live-chat-ticker-paid-message-item-renderer, span.yt-live-chat-ticker-paid-message-item-renderer, strike.yt-live-chat-ticker-paid-message-item-renderer, strong.yt-live-chat-ticker-paid-message-item-renderer, sub.yt-live-chat-ticker-paid-message-item-renderer, sup.yt-live-chat-ticker-paid-message-item-renderer, table.yt-live-chat-ticker-paid-message-item-renderer, tbody.yt-live-chat-ticker-paid-message-item-renderer, td.yt-live-chat-ticker-paid-message-item-renderer, tfoot.yt-live-chat-ticker-paid-message-item-renderer, th.yt-live-chat-ticker-paid-message-item-renderer, thead.yt-live-chat-ticker-paid-message-item-renderer, tr.yt-live-chat-ticker-paid-message-item-renderer, tt.yt-live-chat-ticker-paid-message-item-renderer, u.yt-live-chat-ticker-paid-message-item-renderer, ul.yt-live-chat-ticker-paid-message-item-renderer, var.yt-live-chat-ticker-paid-message-item-renderer {
margin: 0;
padding: 0;
@@ -78,3 +80,5 @@ yt-img-shadow.yt-live-chat-ticker-paid-message-item-renderer {
yt-live-chat-ticker-paid-message-item-renderer[is-deleted] #author-photo.yt-live-chat-ticker-paid-message-item-renderer {
display: none;
}
}

View File

@@ -1,3 +1,5 @@
@layer yt {
canvas.yt-live-chat-ticker-renderer, caption.yt-live-chat-ticker-renderer, center.yt-live-chat-ticker-renderer, cite.yt-live-chat-ticker-renderer, code.yt-live-chat-ticker-renderer, dd.yt-live-chat-ticker-renderer, del.yt-live-chat-ticker-renderer, dfn.yt-live-chat-ticker-renderer, div.yt-live-chat-ticker-renderer, dl.yt-live-chat-ticker-renderer, dt.yt-live-chat-ticker-renderer, em.yt-live-chat-ticker-renderer, embed.yt-live-chat-ticker-renderer, fieldset.yt-live-chat-ticker-renderer, font.yt-live-chat-ticker-renderer, form.yt-live-chat-ticker-renderer, h1.yt-live-chat-ticker-renderer, h2.yt-live-chat-ticker-renderer, h3.yt-live-chat-ticker-renderer, h4.yt-live-chat-ticker-renderer, h5.yt-live-chat-ticker-renderer, h6.yt-live-chat-ticker-renderer, hr.yt-live-chat-ticker-renderer, i.yt-live-chat-ticker-renderer, iframe.yt-live-chat-ticker-renderer, img.yt-live-chat-ticker-renderer, ins.yt-live-chat-ticker-renderer, kbd.yt-live-chat-ticker-renderer, label.yt-live-chat-ticker-renderer, legend.yt-live-chat-ticker-renderer, li.yt-live-chat-ticker-renderer, menu.yt-live-chat-ticker-renderer, object.yt-live-chat-ticker-renderer, ol.yt-live-chat-ticker-renderer, p.yt-live-chat-ticker-renderer, pre.yt-live-chat-ticker-renderer, q.yt-live-chat-ticker-renderer, s.yt-live-chat-ticker-renderer, samp.yt-live-chat-ticker-renderer, small.yt-live-chat-ticker-renderer, span.yt-live-chat-ticker-renderer, strike.yt-live-chat-ticker-renderer, strong.yt-live-chat-ticker-renderer, sub.yt-live-chat-ticker-renderer, sup.yt-live-chat-ticker-renderer, table.yt-live-chat-ticker-renderer, tbody.yt-live-chat-ticker-renderer, td.yt-live-chat-ticker-renderer, tfoot.yt-live-chat-ticker-renderer, th.yt-live-chat-ticker-renderer, thead.yt-live-chat-ticker-renderer, tr.yt-live-chat-ticker-renderer, tt.yt-live-chat-ticker-renderer, u.yt-live-chat-ticker-renderer, ul.yt-live-chat-ticker-renderer, var.yt-live-chat-ticker-renderer {
margin: 0;
padding: 0;
@@ -20,7 +22,9 @@ yt-live-chat-ticker-renderer {
#items.yt-live-chat-ticker-renderer {
height: 32px;
overflow: hidden;
/* 为了支持滚动 */
/* overflow: hidden; */
overflow: visible;
white-space: nowrap;
padding: 0 24px 8px 24px;
}
@@ -63,3 +67,5 @@ yt-icon.yt-live-chat-ticker-renderer {
padding: 4px;
width: 24px;
}
}

483
frontend/src/blcsdk.js Normal file
View File

@@ -0,0 +1,483 @@
/** @module blcsdk */
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
define([], factory)
} else {
root.blcsdk = factory()
}
}(typeof self !== 'undefined' ? self : this, function() {
const exports = {}
const VERSION = '1.0.0'
/**
* 取SDK版本
* @returns {string} "1.0.0"
*/
function getVersion() {
return VERSION
}
exports.getVersion = getVersion
// 初始化消息的Promise {promise, resolve, reject}
let initPromise = null
// 初始化消息,包含版本、配置等信息
let initMsg = null
/**
* 消息处理器
* @type {MsgHandler}
*/
let msgHandler = null
/**
* 用户设置的消息处理器
* @type {MsgHandler}
*/
let rawMsgHandler = null
/**
* @typedef InitOptions
* @property {boolean} noMsgDelay 去掉消息延迟,但会导致消息不平滑
*/
/**
* 初始化SDK
*
* 在调用除了{@link setMsgHandler}以外的其他接口之前必须先调用这个
*/
async function init(
/** @type {?InitOptions} */
{noMsgDelay = false} = {}
) {
if (initPromise) {
throw new Error('Cannot call init() again')
}
// initPromise = Promise.withResolvers()
initPromise = {}
initPromise.promise = new Promise((resolve, reject) => {
initPromise.resolve = resolve
initPromise.reject = reject
})
if (window.parent === window) {
initPromise.reject(new Error('No parent window'))
return initPromise.promise
}
msgHandler = noMsgDelay ? new SdkMsgHandler() : new SmoothedSdkMsgHandler()
window.addEventListener('message', onWindowMessage)
// 连接blivechat
blcSendMsg('blcTemplateConnect')
setTimeout(() => initPromise.reject(new Error('Timed out waiting for blcInit message')), 10 * 1000)
// 等待初始化消息
initMsg = await initPromise.promise
console.debug('blcsdk initialized, init_msg=', initMsg)
}
exports.init = init
/**
* 设置消息处理器
* @param {?MsgHandler} handler 消息处理器
*/
function setMsgHandler(handler) {
rawMsgHandler = handler
}
exports.setMsgHandler = setMsgHandler
/**
* 取blivechat前端版本
* @returns {string} "v1.10.0-dev"
*/
function getBlcVersion() {
if (!initMsg) {
throw new Error('Please call init() first')
}
return initMsg.blcVersion
}
exports.getBlcVersion = getBlcVersion
/**
* 取blivechat前端用的SDK版本。是父窗口用的版本不是这个包的{@link getVersion}返回值
* @returns {string} "1.0.0"
*/
function getBlcSdkVersion() {
if (!initMsg) {
throw new Error('Please call init() first')
}
return initMsg.sdkVersion
}
exports.getBlcSdkVersion = getBlcSdkVersion
/**
* @typedef Config
* @property {boolean} showGiftName 显示礼物名
* @property {boolean} mergeSimilarDanmaku 合并相似弹幕
* @property {boolean} mergeGift 合并礼物
* @property {number} maxNumber 最大弹幕数
*/
/**
* 取blivechat前端房间部分配置
* @returns {Config}
*/
function getConfig() {
if (!initMsg) {
throw new Error('Please call init() first')
}
return Object.freeze(initMsg.config)
}
exports.getConfig = getConfig
function blcSendMsg(type, data = null) {
if (window.parent === window) {
return
}
let msg = { type, data }
window.parent.postMessage(msg, '*')
}
function onWindowMessage(event) {
if (event.source !== window.parent) {
return
}
let { type, data } = event.data
switch (type) {
case 'blcInit':
initPromise.resolve(data)
break
case 'blcAddMsg':
msgHandler.addMsg(data)
break
case 'blcDelMsgs':
msgHandler.delMsgs(data.ids)
break
case 'blcUpdateMsg':
msgHandler.updateMsg(data.id, data.newValuesObj)
break
}
}
/** 模板消息处理器接口 */
class MsgHandler {
/**
* 添加消息
* @param {AnyDisplayMsg} msg
*/
addMsg(msg) {}
/**
* 删除消息,主要用于撤回醒目留言
* @param {string[]} ids 要删除的消息ID
*/
delMsgs(ids) {}
/**
* 更新消息字段,主要用于更新翻译结果
* @param {string} id 要更新的消息ID
* @param {Object} newValuesObj 字段和对应的新值
*/
updateMsg(id, newValuesObj) {}
}
exports.MsgHandler = MsgHandler
class SdkMsgHandler extends MsgHandler {
addMsg(msg) { this._callRawHandler('addMsg', msg) }
delMsgs(ids) { this._callRawHandler('delMsgs', ids) }
updateMsg(id, newValuesObj) { this._callRawHandler('updateMsg', id, newValuesObj) }
_callRawHandler(...args) { doCallRawHandler(...args) }
}
function doCallRawHandler(funcName, ...args) {
if (!rawMsgHandler) {
return
}
try {
let func = rawMsgHandler[funcName]
return func.call(rawMsgHandler, ...args)
} catch (e) {
console.error(e)
}
}
// 发送消息时间间隔范围
const MSG_MIN_INTERVAL = 80
const MSG_MAX_INTERVAL = 1000
class SmoothedSdkMsgHandler extends SdkMsgHandler {
constructor() {
super()
// 消息队列
this._queue = []
// 消费消息队列的定时器ID
this._emitSmoothedMsgTimerId = null
// 最近进队列的时间间隔,用来估计下次进队列的时间
this._enqueueIntervals = []
// 上次进队列的时间
this._lastEnqueueTime = null
// 估计的下次进队列时间间隔
this._estimatedEnqueueInterval = null
this._boundEmitSmoothedMsgs = this._emitSmoothedMsgs.bind(this)
}
_callRawHandler(funcName, ...args) {
let msg = {funcName, args}
this._enqueueMsg(msg)
}
_enqueueMsg(msg) {
// 估计进队列时间间隔
if (!this._lastEnqueueTime) {
this._lastEnqueueTime = new Date()
} else {
let curTime = new Date()
let interval = curTime - this._lastEnqueueTime
// 真实的进队列时间间隔模式大概是这样2500, 300, 300, 300, 2500, 300, ...
// B站消息有缓冲会一次发多条消息。这里把波峰视为发送了一次真实的WS消息所以要过滤掉间隔太小的
if (interval > 1000 || this._enqueueIntervals.length < 5) {
this._enqueueIntervals.push(interval)
if (this._enqueueIntervals.length > 5) {
this._enqueueIntervals.splice(0, this._enqueueIntervals.length - 5)
}
// 这边估计得尽量大只要不太早把消息缓冲发完就是平滑的。有MESSAGE_MAX_INTERVAL保底不会让消息延迟太大
// 其实可以用单调队列求最大值,偷懒不写了
this._estimatedEnqueueInterval = Math.max(...this._enqueueIntervals)
}
// 上次入队时间还是要设置,否则会太早把消息缓冲发完,然后较长时间没有新消息
this._lastEnqueueTime = curTime
}
this._queue.push(msg)
if (!this._emitSmoothedMsgTimerId) {
this._emitSmoothedMsgTimerId = setTimeout(this._boundEmitSmoothedMsgs)
}
}
_emitSmoothedMsgs() {
this._emitSmoothedMsgTimerId = null
if (this._queue.length <= 0) {
return
}
// 估计的下次进队列剩余时间
let estimatedNextEnqueueRemainTime = 10 * 1000
if (this._estimatedEnqueueInterval) {
estimatedNextEnqueueRemainTime = Math.max(this._lastEnqueueTime - new Date() + this._estimatedEnqueueInterval, 1)
}
// 计算发送的消息数,保证在下次进队列之前发完
// 下次进队列之前应该发多少条消息
let shouldEmitNum = Math.max(this._queue.length, 0)
// 下次进队列之前最多能发多少次
let maxCanEmitCount = estimatedNextEnqueueRemainTime / MSG_MIN_INTERVAL
// 这次发多少条消息
let numToEmit
if (shouldEmitNum < maxCanEmitCount) {
// 队列中消息数很少每次发1条也能发完
numToEmit = 1
} else {
// 每次发1条以上保证按最快速度能发完
numToEmit = Math.ceil(shouldEmitNum / maxCanEmitCount)
}
// 发消息
let msgs = this._queue.splice(0, numToEmit)
for (let msg of msgs) {
doCallRawHandler(msg.funcName, ...msg.args)
}
if (this._queue.length <= 0) {
return
}
// 消息没发完,计算下次发消息时间
let sleepTime
if (numToEmit === 1) {
// 队列中消息数很少,随便定个[MESSAGE_MIN_INTERVAL, MESSAGE_MAX_INTERVAL]的时间
sleepTime = estimatedNextEnqueueRemainTime / this._queue.length
sleepTime *= 0.5 + Math.random()
if (sleepTime > MSG_MAX_INTERVAL) {
sleepTime = MSG_MAX_INTERVAL
} else if (sleepTime < MSG_MIN_INTERVAL) {
sleepTime = MSG_MIN_INTERVAL
}
} else {
// 按最快速度发
sleepTime = MSG_MIN_INTERVAL
}
this._emitSmoothedMsgTimerId = window.setTimeout(this._boundEmitSmoothedMsgs, sleepTime)
}
}
/**
* 消息类型
* @enum {number}
*/
const MsgType = Object.freeze({
/** 评论 @see TextMsg */
TEXT: 0,
/** 礼物 @see GiftMsg */
GIFT: 1,
/** 上舰 @see MemberMsg */
MEMBER: 2,
/** 醒目留言 @see SuperChatMsg */
SUPER_CHAT: 3,
})
exports.MsgType = MsgType
/**
* 作者类型
* @enum {number}
*/
const AuthorType = Object.freeze({
NORMAL: 0,
/** 舰队 */
MEMBER: 1,
/** 房管 */
ADMIN: 2,
/** 主播 */
OWNER: 3,
})
exports.AuthorType = AuthorType
/**
* 舰队等级。因为历史原因,消息里的字段名叫`privilegeType`
* @enum {number}
*/
const GuardLevel = Object.freeze({
NONE: 0,
/** 总督 */
LV3: 1,
/** 提督 */
LV2: 2,
/** 舰长 */
LV1: 3,
})
exports.GuardLevel = GuardLevel
/**
* 一段内容的类型
* @enum {number}
*/
const ContentPartType = Object.freeze({
/** 文本 */
TEXT: 0,
/** 图片 */
IMAGE: 1,
})
exports.ContentPartType = ContentPartType
//
// 以下只用于类型注解,运行时没什么用
//
/**
* 用于显示的消息
* @typedef {TextMsg | GiftMsg | MemberMsg | SuperChatMsg} AnyDisplayMsg
*/
/**
* 评论消息。因为历史原因叫TextMsg实际上会包含表情图片
* @typedef TextMsg
* @property {string} id 消息ID
* @property {MsgType} type 消息类型
* @property {string} avatarUrl 用户头像URL
* @property {Date} time 时间
* @property {string} authorName 用户名
* @property {AuthorType} authorType 用户类型
* @property {string} content 纯文本表示的内容
* @property {AnyContentPart[]} contentParts 解析后的内容,包括文本、图片
* @property {GuardLevel} privilegeType 舰队等级
* @property {string} translation 内容的翻译,刚添加时一般是空的,之后通过更新消息赋值
* @property {string} uid 用户Open ID或ID使用房间ID连接时不保证是唯一的
* @property {number} medalLevel 勋章等级如果没戴当前房间勋章则为0
* @property {string} medalName 勋章名,如果没戴当前房间勋章则为空字符串
*/
exports.TextMsg = /** @type {TextMsg} */ (undefined)
/**
* 一段内容
* @typedef {TextContentPart | ImageContentPart} AnyContentPart
*/
/**
* 一段文本内容
* @typedef TextContentPart
* @property {ContentPartType} type 内容类型
* @property {string} text 内容
*/
/**
* 一段图片内容
* @typedef ImageContentPart
* @property {ContentPartType} type 内容类型
* @property {string} text 纯文本表示的内容
* @property {string} url 图片URL
* @property {number} width 宽度加载失败则为0
* @property {number} height 高度加载失败则为0
*/
/**
* 礼物消息
* @typedef GiftMsg
* @property {string} id 消息ID
* @property {MsgType} type 消息类型
* @property {string} avatarUrl 用户头像URL
* @property {Date} time 时间
* @property {string} authorName 用户名
* @property {string} authorNamePronunciation 用户名读音
* @property {number} price 总价免费礼物则为0
* @property {string} giftName 礼物名
* @property {number} num 数量
* @property {number} totalFreeCoin 免费礼物总价银瓜子数付费礼物则为0
* @property {number} giftId 礼物ID
* @property {string} giftIconUrl 礼物图标URL
* @property {string} uid 用户Open ID或ID使用房间ID连接时不保证是唯一的
* @property {GuardLevel} privilegeType 舰队等级
* @property {number} medalLevel 勋章等级如果没戴当前房间勋章则为0
* @property {string} medalName 勋章名,如果没戴当前房间勋章则为空字符串
*/
exports.GiftMsg = /** @type {GiftMsg} */ (undefined)
/**
* 上舰消息
* @typedef MemberMsg
* @property {string} id 消息ID
* @property {MsgType} type 消息类型
* @property {string} avatarUrl 用户头像URL
* @property {Date} time 时间
* @property {string} authorName 用户名
* @property {string} authorNamePronunciation 用户名读音
* @property {GuardLevel} privilegeType 舰队等级
* @property {number} num 数量
* @property {string} unit 单位("月"
* @property {number} price 总价(元)
* @property {string} uid 用户Open ID或ID使用房间ID连接时不保证是唯一的
* @property {number} medalLevel 勋章等级如果没戴当前房间勋章则为0
* @property {string} medalName 勋章名,如果没戴当前房间勋章则为空字符串
*/
exports.MemberMsg = /** @type {MemberMsg} */ (undefined)
/**
* 醒目留言消息
* @typedef SuperChatMsg
* @property {string} id 消息ID
* @property {MsgType} type 消息类型
* @property {string} avatarUrl 用户头像URL
* @property {Date} time 时间
* @property {string} authorName 用户名
* @property {string} authorNamePronunciation 用户名读音
* @property {number} price 价格(元)
* @property {string} content 内容
* @property {string} translation 内容的翻译,刚添加时一般是空的,之后通过更新消息赋值
* @property {string} uid 用户Open ID或ID使用房间ID连接时不保证是唯一的
* @property {GuardLevel} privilegeType 舰队等级
* @property {number} medalLevel 勋章等级如果没戴当前房间勋章则为0
* @property {string} medalName 勋章名,如果没戴当前房间勋章则为空字符串
*/
exports.SuperChatMsg = /** @type {SuperChatMsg} */ (undefined)
return exports
}))

View File

@@ -9,10 +9,10 @@
: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>
<template v-for="(content, index) in contentParts">
<span :key="index" v-if="content.type === CONTENT_PART_TYPE_TEXT">{{ content.text }}</span>
<!-- 如果CSS设置的尺寸比属性设置的尺寸还大在图片加载完后布局会变化可能导致滚动卡住没什么好的解决方法 -->
<img :key="index" v-else-if="content.type === CONTENT_TYPE_IMAGE"
<img :key="index" v-else-if="content.type === CONTENT_PART_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}`"
:width="content.width" :height="content.height"
@@ -48,14 +48,14 @@ export default {
time: Date,
authorName: String,
authorType: Number,
richContent: Array,
contentParts: Array,
privilegeType: Number,
repeated: Number
},
data() {
return {
CONTENT_TYPE_TEXT: constants.CONTENT_TYPE_TEXT,
CONTENT_TYPE_IMAGE: constants.CONTENT_TYPE_IMAGE
CONTENT_PART_TYPE_TEXT: constants.CONTENT_PART_TYPE_TEXT,
CONTENT_PART_TYPE_IMAGE: constants.CONTENT_PART_TYPE_IMAGE
}
},
computed: {
@@ -84,12 +84,12 @@ export default {
}
</script>
<style>
yt-live-chat-text-message-renderer>#content>#message>.el-badge {
<style scoped>
#message >>> .el-badge {
margin-left: 10px;
}
yt-live-chat-text-message-renderer>#content>#message>.el-badge .el-badge__content {
#message >>> .el-badge__content {
font-size: 12px !important;
line-height: 18px !important;
text-shadow: none !important;

View File

@@ -1,6 +1,6 @@
<template>
<yt-live-chat-ticker-renderer :hidden="showMessages.length === 0">
<div id="container" dir="ltr" class="style-scope yt-live-chat-ticker-renderer">
<el-scrollbar ref="scrollbar" id="container" dir="ltr" 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"
>
@@ -22,7 +22,7 @@
</div>
</yt-live-chat-ticker-paid-message-item-renderer>
</transition-group>
</div>
</el-scrollbar>
<template v-if="pinnedMessage">
<membership-item :key="pinnedMessage.id" v-if="pinnedMessage.type === MESSAGE_TYPE_MEMBER"
class="style-scope yt-live-chat-ticker-renderer"
@@ -110,7 +110,11 @@ export default {
el.style.width = 0
await this.$nextTick()
el.style.width = `${width}px`
window.setTimeout(done, 200)
window.setTimeout(() => {
done()
// 因为el-scrollbar监听不到事件这里必须手动调update
this.$refs.scrollbar.update()
}, 200)
},
onTickerItemLeave(el, done) {
el.classList.add('sliding-down')
@@ -122,6 +126,8 @@ export default {
el.classList.remove('collapsing')
el.style.width = 'auto'
done()
// 因为el-scrollbar监听不到事件这里必须手动调update
this.$refs.scrollbar.update()
}, 200)
}, 200)
},

View File

@@ -34,8 +34,8 @@ 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
export const CONTENT_PART_TYPE_TEXT = 0
export const CONTENT_PART_TYPE_IMAGE = 1
// 美元 -> 人民币 汇率
const EXCHANGE_RATE = 7
@@ -173,15 +173,15 @@ export function getShowContent(message) {
return message.content
}
export function getShowRichContent(message) {
let richContent = [...message.richContent]
export function getShowContentParts(message) {
let contentParts = [...message.contentParts]
if (message.translation) {
richContent.push({
type: CONTENT_TYPE_TEXT,
contentParts.push({
type: CONTENT_PART_TYPE_TEXT,
text: `${message.translation}`
})
}
return richContent
return contentParts
}
export function getGiftShowContent(message, showGiftName) {

View File

@@ -17,7 +17,7 @@
:authorName="message.authorName"
:authorType="message.authorType"
:privilegeType="message.privilegeType"
:richContent="getShowRichContent(message)"
:contentParts="getShowContentParts(message)"
:repeated="message.repeated"
></text-message>
<paid-message :key="message.id" v-else-if="message.type === MESSAGE_TYPE_GIFT"
@@ -153,7 +153,7 @@ export default {
},
getGiftShowNameAndNum: constants.getGiftShowNameAndNum,
getShowContent: constants.getShowContent,
getShowRichContent: constants.getShowRichContent,
getShowContentParts: constants.getShowContentParts,
getShowAuthorName: constants.getShowAuthorName,
addMessage(message) {
@@ -192,7 +192,7 @@ export default {
}
return false
},
mergeSimilarGift(authorName, price, _freePrice, giftName, num) {
mergeSimilarGift(authorName, price, _totalFreeCoin, giftName, num) {
for (let message of this.iterRecentMessages(5)) {
if (
message.type === constants.MESSAGE_TYPE_GIFT
@@ -201,7 +201,7 @@ export default {
) {
this.updateMessage(message.id, { $add: {
price: price,
// freePrice: freePrice, // 暂时没用到
// totalFreeCoin: totalFreeCoin, // 暂时没用到
num: num
} })
return true

View File

@@ -1,3 +1,4 @@
import Vue from 'vue'
import VueI18n from 'vue-i18n'
import zh from '@/lang/zh'
@@ -5,6 +6,10 @@ import zh from '@/lang/zh'
let lastSetLocale = 'zh'
let loadedLocales = ['zh']
if (!process.env.LIB_USE_CDN) {
Vue.use(VueI18n)
}
export async function setLocale(locale) {
lastSetLocale = locale
if (loadedLocales.indexOf(locale) === -1) {

View File

@@ -65,6 +65,25 @@ export default {
addEmoticon: 'Add emote',
emoticonFileTooLarge: 'File size is too large. Max size is 1MB',
template: 'Custom HTML Templates',
templateHelp: 'Help',
templateHelpContent: `\
<p>Custom HTML templates allow complete customization of the room page, including DOM structure and CSS styles.
Templates may be developed by third-party authors, and their security and quality are the responsibility of the template author.
You can find some published templates on <a target="_blank" href="https://github.com/xfgryujk/blivechat/discussions/categories/%E8%87%AA%E5%AE%9A%E4%B9%89html%E6%A8%A1%E6%9D%BF"
>GitHub Discussions</a></p>
<p>To install a template: Put the extracted template directory into the "data/custom_public/templates" directory, then wait
about 10 seconds and refresh the webpage. Alternatively, you can directly enter the template URL to use an online template</p>
<p>Note: After modifying the template setting, the custom CSS in OBS will not take effect</p>
<p><a target="_blank" href="https://github.com/xfgryujk/blivechat/wiki/%E8%87%AA%E5%AE%9A%E4%B9%89HTML%E6%A8%A1%E6%9D%BF"
>Template Development Documentation</a></p>
`,
templateDefaultTitle: 'Default',
templateDefaultDescription: 'A YouTube-style template that can be styled with custom CSS. Choose this if you are unfamiliar with custom HTML templates, otherwise your custom CSS in OBS will not take effect',
templateCustomUrlTitle: 'Enter template URL',
templateCustomUrlDescription: 'For templates not listed, you can manually enter the URL provided by the author here',
author: 'Author: ',
urlTooLong: 'The room URL is too long, and will be truncated by Livehime (but not by OBS)',
roomUrlUpdated: 'The room URL is updated. Remember to copy it again',
roomUrl: 'Room URL',
@@ -186,12 +205,12 @@ export default {
be developed by third-party authors, and the security and quality are the responsibility of the plugin author. You can
find some published plugins in <a target="_blank" href="https://github.com/xfgryujk/blivechat/discussions/categories/%E6%8F%92%E4%BB%B6"
>GitHub Discussions</a></p>
<p>Plugin installation method: Put the extracted plugin directory into the "data/plugins" directory, then restart blivechat</p>
<p>To install a plugin: Put the extracted plugin directory into the "data/plugins" directory, then restart blivechat</p>
<p>Notes: Most plugins require enabling the "Relay messages by the server" option and connecting to the room
in order to receive messages</p>
<p><a target="_blank" href="https://www.bilibili.com/video/BV1nZ42187TX/">Introducing video</a></p>
<p><a target="_blank" href="https://github.com/xfgryujk/blivechat/wiki/%E6%8F%92%E4%BB%B6%E7%B3%BB%E7%BB%9F">
Plugin development documentation</a></p>
<p><a target="_blank" href="https://www.bilibili.com/video/BV1nZ42187TX/">Introducing Video</a></p>
<p><a target="_blank" href="https://github.com/xfgryujk/blivechat/wiki/%E6%8F%92%E4%BB%B6%E7%B3%BB%E7%BB%9F"
>Plugin Development Documentation</a></p>
`,
author: 'Author: ',
disabledByServer: 'Administration for plugins is disabled by the server',

View File

@@ -65,6 +65,25 @@ export default {
addEmoticon: 'スタンプを追加',
emoticonFileTooLarge: 'ファイルサイズが大きすぎます。最大サイズは1MBです',
template: 'カスタムHTMLテンプレート',
templateHelp: 'ヘルプ',
templateHelpContent: `\
<p>カスタムHTMLテンプレートを使用すると、DOM構造やCSSスタイルを含むフロントエンドページを完全にカスタマイズできます。
テンプレートはサードパーティの作者によって開発される場合があり、そのセキュリティと品質はテンプレート作者の責任となります。
公開されているテンプレートは<a target="_blank" href="https://github.com/xfgryujk/blivechat/discussions/categories/%E8%87%AA%E5%AE%9A%E4%B9%89html%E6%A8%A1%E6%9D%BF"
>GitHub Discussions</a>で入手できます</p>
<p>テンプレートのインストール方法解凍したテンプレートディレクトリを「data/custom_public/templates」フォルダに配置し、
約10秒待ってからウェブページを更新してください。また、テンプレートURLを直接入力してオンラインテンプレートを使用することもできます</p>
<p>注意カスタムテンプレートを変更した場合、OBS内のカスタムCSSは反映されません</p>
<p><a target="_blank" href="https://github.com/xfgryujk/blivechat/wiki/%E8%87%AA%E5%AE%9A%E4%B9%89HTML%E6%A8%A1%E6%9D%BF"
>テンプレート開発ドキュメント</a></p>
`,
templateDefaultTitle: 'デフォルト',
templateDefaultDescription: 'YouTube風のテンプレートで、カスタムCSSでスタイルを変更できます。カスタムHTMLテンプレートの機能に慣れていない場合はこれを選択してください。そうでない場合、OBS内のカスタムCSSは反映されません',
templateCustomUrlTitle: 'テンプレートURLを入力',
templateCustomUrlDescription: 'リストにないテンプレートは、作者が提供するURLをここで手動で入力できます',
author: '作者:',
urlTooLong: 'ルームのURLが長すぎて、直播姬によって切り詰められますただし、OBSでは切り詰められません',
roomUrlUpdated: 'ルームのURLが更新されました。再度コピーすることをお忘れなく',
roomUrl: 'ルームのURL',

View File

@@ -65,6 +65,23 @@ export default {
addEmoticon: '添加表情',
emoticonFileTooLarge: '文件尺寸太大最大1MB',
template: '自定义HTML模板',
templateHelp: '帮助',
templateHelpContent: `\
<p>自定义HTML模板可以完全自定义房间页面包括DOM结构、CSS样式。模板可能由第三方作者开发其安全性和质量由模板作者负责。
你可以在<a target="_blank" href="https://github.com/xfgryujk/blivechat/discussions/categories/%E8%87%AA%E5%AE%9A%E4%B9%89html%E6%A8%A1%E6%9D%BF"
>GitHub Discussions</a>获取一些已发布的模板</p>
<p>模板安装方法把解压后的模板目录放到“data/custom_public/templates”目录然后等待约10秒后刷新网页。
另外你也可以直接输入模板URL来使用在线模板</p>
<p>注意修改自定义模板设置后OBS中的自定义CSS将不会生效</p>
<p><a target="_blank" href="https://github.com/xfgryujk/blivechat/wiki/%E8%87%AA%E5%AE%9A%E4%B9%89HTML%E6%A8%A1%E6%9D%BF">模板开发文档</a></p>
`,
templateDefaultTitle: '默认',
templateDefaultDescription: '仿YouTube风格的模板可以用自定义CSS修改样式。如果你不了解自定义HTML模板的功能就选择这个否则OBS中的自定义CSS不会生效',
templateCustomUrlTitle: '输入模板URL',
templateCustomUrlDescription: '没有列出的模板也可以在这里手动输入作者提供的URL',
author: '作者:',
urlTooLong: '房间URL太长了会被直播姬截断OBS不会',
roomUrlUpdated: '房间URL已更新记得重新复制',
roomUrl: '房间URL',
@@ -79,8 +96,8 @@ export default {
legacy: '经典',
lineLike: '仿微信',
light: '亮',
dark: '暗',
light: '亮',
dark: '暗',
outlines: '描边',
showOutlines: '显示描边',
@@ -150,7 +167,7 @@ export default {
fadeOutTime: '淡出时间(毫秒)',
slide: '滑动',
reverseSlide: '反向滑动',
playAnimation: '播放动画',
playAnimation: '生成消息',
result: '结果',
copy: '复制',
@@ -186,7 +203,7 @@ export default {
你可以在<a target="_blank" href="https://github.com/xfgryujk/blivechat/discussions/categories/%E6%8F%92%E4%BB%B6"
>GitHub Discussions</a>获取一些已发布的插件</p>
<p>插件安装方法把解压后的插件目录放到“data/plugins”目录然后重启blivechat</p>
<p>注意事项:大部分插件需要开启“通过服务器转发消息”,并且连接到房间,才能接收消息</p>
<p>注意:大部分插件需要开启“通过服务器转发消息”,并且连接到房间,才能接收消息</p>
<p><a target="_blank" href="https://www.bilibili.com/video/BV1nZ42187TX/">介绍视频</a></p>
<p><a target="_blank" href="https://github.com/xfgryujk/blivechat/wiki/%E6%8F%92%E4%BB%B6%E7%B3%BB%E7%BB%9F">插件开发文档</a></p>
`,

View File

@@ -83,16 +83,20 @@ export default {
}
</script>
<style>
<style scoped>
.el-scrollbar {
height: 100%;
}
.scrollbar-wrapper {
.el-scrollbar >>> .scrollbar-wrapper {
overflow-x: hidden !important;
}
.scrollbar-wrapper .el-menu {
.el-scrollbar >>> .el-menu {
border: none;
}
.el-scrollbar >>> .is-horizontal {
display: none;
}
</style>

View File

@@ -12,7 +12,7 @@
<sidebar></sidebar>
</el-aside>
<el-main>
<el-button v-show="isMobile" class="menu-button" icon="el-icon-s-unfold" @click="hideSidebar = false"></el-button>
<el-button v-show="isMobile" icon="el-icon-s-unfold" @click="hideSidebar = false"></el-button>
<keep-alive>
<router-view></router-view>
</keep-alive>
@@ -51,8 +51,8 @@ export default {
}
</script>
<style>
.app-wrapper, .sidebar-container {
<style scoped>
.app-wrapper {
height: 100%;
}
@@ -67,11 +67,12 @@ export default {
}
.sidebar-container {
height: 100%;
background-color: #304156;
overflow: hidden;
}
.app-wrapper.mobile .sidebar-container {
.mobile .sidebar-container {
position: fixed;
top: 0;
left: 0;
@@ -79,13 +80,13 @@ export default {
z-index: 1001;
}
.app-wrapper.mobile .sidebar-container.hide-sidebar {
.mobile .sidebar-container.hide-sidebar {
pointer-events: none;
transition-duration: 0.3s;
transform: translate3d(-230px, 0, 0);
}
.sidebar-container .logo-container {
.logo-container {
width: 100%;
height: 50px;
line-height: 50px;
@@ -93,14 +94,14 @@ export default {
text-align: center;
}
.sidebar-container .logo-container .sidebar-logo {
.sidebar-logo {
width: 32px;
height: 32px;
vertical-align: middle;
margin-right: 12px;
}
.sidebar-container .logo-container .sidebar-title {
.sidebar-title {
display: inline-block;
margin: 0;
color: #fff;
@@ -111,7 +112,7 @@ export default {
vertical-align: middle;
}
.sidebar-container .version {
.version {
height: 30px;
background: #2b2f3a;
color: #aaa;
@@ -121,8 +122,4 @@ export default {
vertical-align: middle;
text-align: center;
}
.sidebar-container .is-horizontal {
display: none;
}
</style>

View File

@@ -1,12 +1,19 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import axios from 'axios'
import ElementUI from 'element-ui'
if (!process.env.LIB_USE_CDN) {
import('element-ui/lib/theme-chalk/index.css')
}
import * as apiBase from './api/base'
import * as i18n from './i18n'
import App from './App'
import NotFound from './views/NotFound'
axios.defaults.timeout = 10 * 1000
if (!process.env.LIB_USE_CDN) {
Vue.use(VueRouter)
Vue.use(ElementUI)
}
Vue.config.ignoredElements = [
/^yt-/
@@ -54,6 +61,8 @@ const router = new VueRouter({
]
})
await apiBase.init()
new Vue({
render: h => h(App),
router,

View File

@@ -2,18 +2,18 @@
<div>
<h1>{{ $t('help.help') }}</h1>
<p>{{ $t('help.p1_1') }} <a href="https://play-live.bilibili.com/" target="_blank">https://play-live.bilibili.com/</a> {{ $t('help.p1_2') }}</p>
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-1.png"></el-image></p>
<p><el-image lazy src="/static/img/tutorial/tutorial-1.jpg"></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><el-image lazy src="/static/img/tutorial/tutorial-2.jpg"></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><el-image lazy src="/static/img/tutorial/tutorial-3.jpg"></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><el-image lazy src="/static/img/tutorial/tutorial-4.jpg"></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><el-image lazy src="/static/img/tutorial/tutorial-5.jpg"></el-image></p>
<p><br><br><br><br><br><br><br><br>--------------------------------------------------------------------------------------------------------</p>
<p>使用前必看<a href="https://github.com/xfgryujk/blivechat/wiki/%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9%E5%92%8C%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98" target="_blank">注意事项和常见问题</a></p>
<p>喜欢的话可以推荐给别人 _(:з)_</p>
<p>如果需要使用翻译功能建议看<a href="https://www.bilibili.com/read/cv14663633" target="_blank">配置官方翻译接口傻瓜式教程</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>
@@ -22,13 +22,3 @@ export default {
name: 'Help'
}
</script>
<style scoped>
.img-container {
text-align: center;
}
.img-container.large-img .el-image {
height: 80vh;
}
</style>

View File

@@ -0,0 +1,222 @@
<template>
<div>
<p>
<el-button round icon="el-icon-question" @click="isHelpVisible = true">{{$t('home.templateHelp')}}</el-button>
<el-dialog :title="$t('home.templateHelp')" :visible.sync="isHelpVisible">
<div style="word-break: initial;" v-html="$t('home.templateHelpContent')"></div>
</el-dialog>
</p>
<el-radio-group :value="selectedIndex" @input="onSelect">
<el-radio :label="INDEX_DEFAULT" border>
<div class="card-body">
<div class="title-line">
<h3 class="name">{{ $t('home.templateDefaultTitle') }}</h3>
</div>
<div class="description-line">
<div class="description">
<p>{{ $t('home.templateDefaultDescription') }}</p>
</div>
</div>
</div>
</el-radio>
<el-radio :label="INDEX_CUSTOM" border>
<div class="card-body">
<div class="title-line">
<h3 class="name">{{ $t('home.templateCustomUrlTitle') }}</h3>
</div>
<div class="description-line">
<div class="description">
<p>{{ $t('home.templateCustomUrlDescription') }}</p>
</div>
</div>
<div>
<el-input v-model="customUrl" placeholder="https://example.com/path/to/you/template/index.html"></el-input>
</div>
</div>
</el-radio>
<el-radio v-for="(template, index) in templates" :key="template.id" :label="index" border>
<div class="card-body">
<div class="title-line">
<h3 class="name">{{ template.name }}</h3>
<span class="version">{{ template.version }}</span>
<span class="author push-inline-end">{{ $t('home.author') }}{{ template.author }}</span>
</div>
<div class="description-line">
<el-image v-if="template.thumbnail !== ''" class="thumbnail" lazy fit="cover" :src="template.thumbnail"></el-image>
<div class="description">
<p v-if="template.description !== ''">{{ template.description }}</p>
<p>URL: {{ template.url }}</p>
</div>
</div>
</div>
</el-radio>
</el-radio-group>
</div>
</template>
<script>
import * as mainApi from '@/api/main'
const INDEX_DEFAULT = 'default'
const INDEX_CUSTOM = 'custom'
export default {
name: 'TemplateSelect',
props: {
value: String
},
data() {
return {
INDEX_DEFAULT,
INDEX_CUSTOM,
isHelpVisible: false,
templates: [],
customUrl: '',
}
},
computed: {
selectedIndex() {
if (this.value == '') {
return INDEX_DEFAULT
}
let index = this.templates.findIndex(template => template.url === this.value)
if (index !== -1) {
return index
}
return INDEX_CUSTOM
}
},
watch: {
value: {
immediate: true,
handler(val) {
if (this.selectedIndex === INDEX_CUSTOM) {
this.customUrl = val
}
}
},
customUrl(val) {
if (this.selectedIndex === INDEX_CUSTOM) {
this.$emit('input', val)
}
}
},
mounted() {
this.updateTemplates()
},
methods: {
async updateTemplates() {
try {
this.templates = (await mainApi.getTemplates()).templates
} catch (e) {
this.$message.error(`Failed to fetch templates: ${e}`)
throw e
} finally {
if (this.selectedIndex !== INDEX_CUSTOM) {
this.customUrl = ''
}
}
},
onSelect(selectedIndex) {
let url
if (selectedIndex === INDEX_DEFAULT) {
url = ''
} else if (selectedIndex === INDEX_CUSTOM) {
url = this.customUrl
} else {
url = this.templates[selectedIndex].url
}
this.$emit('input', url)
}
}
}
</script>
<style scoped>
.el-radio-group {
display: block;
}
.el-radio {
display: block;
height: unset;
margin: 0 0 16px 0 !important;
padding: 20px;
}
.el-radio >>> .el-radio__input {
display: none;
}
.el-radio >>> .el-radio__label {
padding-left: unset;
}
.push-inline-end {
margin-inline-start: auto;
}
.card-body {
display: flex;
flex-flow: column nowrap;
gap: 1em;
}
.title-line {
flex: none;
display: flex;
flex-flow: row nowrap;
align-items: baseline;
text-wrap: nowrap;
}
.name {
margin-block: 0 0;
margin-inline-end: 8px;
display: inline;
}
.version {
color: #909399;
}
.author {
color: #606266;
}
.description-line {
flex: none;
display: flex;
flex-flow: row nowrap;
gap: 1em;
max-height: 300px;
}
.thumbnail {
flex: none;
width: 190px;
height: 105px;
}
.description {
flex: auto;
overflow-y: auto;
text-wrap: wrap;
word-wrap: break-word;
}
@media only screen and (max-width: 992px) {
.description-line {
flex-wrap: wrap;
max-height: unset;
}
.thumbnail {
max-width: 100%;
}
}
</style>

View File

@@ -217,6 +217,10 @@
<el-button type="primary" icon="el-icon-plus" @click="addEmoticon">{{$t('home.addEmoticon')}}</el-button>
</p>
</el-tab-pane>
<el-tab-pane :label="$t('home.template')" lazy>
<template-select v-model="form.templateUrl"></template-select>
</el-tab-pane>
</el-tabs>
</el-form>
</p>
@@ -249,12 +253,14 @@
import _ from 'lodash'
import download from 'downloadjs'
import TemplateSelect from './TemplateSelect'
import { mergeConfig } from '@/utils'
import * as mainApi from '@/api/main'
import * as chatConfig from '@/api/chatConfig'
export default {
name: 'Home',
components: { TemplateSelect },
data() {
return {
serverConfig: {
@@ -384,7 +390,8 @@ export default {
getUnvalidatedRoomUrl(isTestRoom) {
//
let frontFields = {
roomKeyType: this.form.roomKeyType
roomKeyType: this.form.roomKeyType,
templateUrl: this.form.templateUrl,
}
let backFields = {
lang: this.$i18n.locale,

View File

@@ -1,5 +1,8 @@
<template>
<chat-renderer ref="renderer" :maxNumber="config.maxNumber" :showGiftName="config.showGiftName"></chat-renderer>
<chat-renderer v-if="!useCustomTemplate" ref="renderer" :maxNumber="config.maxNumber" :showGiftName="config.showGiftName"></chat-renderer>
<div v-else class="template-container">
<iframe ref="templateIframe" :src="config.templateUrl" class="template-iframe" frameborder="0"></iframe>
</div>
</template>
<script>
@@ -12,6 +15,101 @@ import * as chat from '@/api/chat'
import * as chatModels from '@/api/chat/models'
import ChatRenderer from '@/components/ChatRenderer'
import * as constants from '@/components/ChatRenderer/constants'
/** @import * as blcsdk from '@/blcsdk' */
class DefaultRenderer {
constructor(rendererVm) {
this.addMessage = rendererVm.addMessage
this.delMessages = rendererVm.delMessages
this.updateMessage = rendererVm.updateMessage
this.mergeSimilarText = rendererVm.mergeSimilarText
this.mergeSimilarGift = rendererVm.mergeSimilarGift
}
destroy() {
let dummyFunc = () => {}
this.addMessage = dummyFunc
this.delMessages = dummyFunc
this.updateMessage = dummyFunc
this.mergeSimilarText = dummyFunc
this.mergeSimilarGift = dummyFunc
}
}
const BLC_SDK_VERSION = '1.0.0'
class CustomTemplateRenderer {
constructor(templateIframe, config) {
this._templateIframe = templateIframe
this._config = config
this._enabledSendMessageToTemplate = (type, data) => {
let msg = { type, data }
templateIframe.contentWindow.postMessage(msg, '*')
}
this._sendMessageToTemplate = () => {}
this._boundOnWindowMessage = this._onWindowMessage.bind(this)
window.addEventListener('message', this._boundOnWindowMessage)
}
destroy() {
window.removeEventListener('message', this._boundOnWindowMessage)
let dummyFunc = () => {}
this._enabledSendMessageToTemplate = dummyFunc
this._sendMessageToTemplate = dummyFunc
}
addMessage(message) {
this._sendMessageToTemplate('blcAddMsg', message)
}
delMessages(ids) {
let data = { ids }
this._sendMessageToTemplate('blcDelMsgs', data)
}
updateMessage(id, newValuesObj) {
let data = { id, newValuesObj }
this._sendMessageToTemplate('blcUpdateMsg', data)
}
mergeSimilarText() {
return false
}
mergeSimilarGift() {
return false
}
_onWindowMessage(event) {
if (event.source !== this._templateIframe.contentWindow) {
return
}
let { type } = event.data
switch (type) {
case 'blcTemplateConnect': {
this._sendMessageToTemplate = this._enabledSendMessageToTemplate
// 发送初始化消息
let initData = {
blcVersion: process.env.APP_VERSION,
sdkVersion: BLC_SDK_VERSION,
config: {
showGiftName: this._config.showGiftName,
mergeSimilarDanmaku: this._config.mergeSimilarDanmaku,
mergeGift: this._config.mergeGift,
maxNumber: this._config.maxNumber,
}
}
this._sendMessageToTemplate('blcInit', initData)
break
}
}
}
}
export default {
name: 'Room',
@@ -42,8 +140,10 @@ export default {
textEmoticons: [], // 官方的文本表情(后端配置的)
pronunciationConverter: null,
customStyleElement, // 仅用于样式生成器中预览样式
customStyleElement, // 仅用于样式生成器中预览样式和使用自定义模板时
presetCssLinkElement: null,
renderer: null,
}
},
computed: {
@@ -72,9 +172,26 @@ export default {
}
}
return res
},
useCustomTemplate() {
return this.config.templateUrl !== ''
},
},
beforeMount() {
this.initConfig()
// 主框架改成透明背景,防止影响到模板
if (this.useCustomTemplate) {
this.customStyleElement.textContent = 'body { background-color: transparent; }'
}
},
mounted() {
if (this.useCustomTemplate) {
this.renderer = new CustomTemplateRenderer(this.$refs.templateIframe, this.config)
} else {
this.renderer = new DefaultRenderer(this.$refs.renderer)
}
if (document.visibilityState === 'visible') {
if (this.roomKeyValue === null) {
this.init()
@@ -101,6 +218,10 @@ export default {
if (this.presetCssLinkElement) {
document.head.removeChild(this.presetCssLinkElement)
}
if (this.renderer) {
this.renderer.destroy()
}
},
methods: {
onVisibilityChange() {
@@ -111,8 +232,6 @@ export default {
this.init()
},
async init() {
this.initConfig()
let initChatClientPromise = this.initChatClient()
this.initTextEmoticons()
if (this.config.giftUsernamePronunciation !== '') {
@@ -142,6 +261,8 @@ export default {
message: 'Loaded',
duration: 500
})
this.sendMessageToStylegen('stylegenExampleRoomLoad')
},
initConfig() {
let locale = this.strConfig.lang
@@ -220,8 +341,18 @@ export default {
this.textEmoticons = await chat.getTextEmoticons()
},
sendMessageToStylegen(type, data = null) {
if (window.parent === window) {
return
}
let msg = { type, data }
window.parent.postMessage(msg, window.location.origin)
},
// 处理样式生成器发送的消息
onWindowMessage(event) {
if (event.source !== window.parent) {
return
}
if (event.origin !== window.location.origin) {
console.warn(`消息origin错误${event.origin} != ${window.location.origin}`)
return
@@ -230,7 +361,7 @@ export default {
let { type, data } = event.data
switch (type) {
case 'roomSetCustomStyle':
this.customStyleElement.innerText = data.css
this.customStyleElement.textContent = data.css
break
case 'roomStartClient':
if (this.chatClient) {
@@ -250,11 +381,12 @@ export default {
if (!this.config.showDanmaku || !this.filterTextMessage(data)) {
return
}
let richContent = await this.getRichContent(data)
let contentParts = await this.parseContentParts(data)
// 合并要放在异步调用后面,因为异步调用后可能有新的消息,会漏合并
if (this.mergeSimilarText(data.content)) {
return
}
/** @type {typeof blcsdk.TextMsg} */
let message = {
id: data.id,
type: constants.MESSAGE_TYPE_TEXT,
@@ -263,12 +395,16 @@ export default {
authorName: data.authorName,
authorType: data.authorType,
content: data.content,
richContent: richContent,
contentParts: contentParts,
privilegeType: data.privilegeType,
repeated: 1,
translation: data.translation
translation: this.config.autoTranslate ? data.translation : '',
// 给模板用的字段
uid: data.uid,
medalLevel: data.medalLevel,
medalName: data.medalName,
}
this.$refs.renderer.addMessage(message)
this.renderer.addMessage(message)
},
/** @param {chatModels.AddGiftMsg} data */
onAddGift(data) {
@@ -282,6 +418,7 @@ export default {
if (price < this.config.minGiftPrice) { // 丢人
return
}
/** @type {typeof blcsdk.GiftMsg} */
let message = {
id: data.id,
type: constants.MESSAGE_TYPE_GIFT,
@@ -290,17 +427,25 @@ export default {
authorName: data.authorName,
authorNamePronunciation: this.getPronunciation(data.authorName),
price: price,
// freePrice: data.totalFreeCoin, // 暂时没用到
giftName: data.giftName,
num: data.num
num: data.num,
// 给模板用的字段
totalFreeCoin: data.totalFreeCoin,
giftId: data.giftId,
giftIconUrl: data.giftIconUrl,
uid: data.uid,
privilegeType: data.privilegeType,
medalLevel: data.medalLevel,
medalName: data.medalName,
}
this.$refs.renderer.addMessage(message)
this.renderer.addMessage(message)
},
/** @param {chatModels.AddMemberMsg} data */
onAddMember(data) {
if (!this.config.showGift || !this.filterNewMemberMessage(data)) {
return
}
/** @type {typeof blcsdk.MemberMsg} */
let message = {
id: data.id,
type: constants.MESSAGE_TYPE_MEMBER,
@@ -309,9 +454,16 @@ export default {
authorName: data.authorName,
authorNamePronunciation: this.getPronunciation(data.authorName),
privilegeType: data.privilegeType,
title: this.$t('chat.membershipTitle')
title: this.$t('chat.membershipTitle'),
// 给模板用的字段
num: data.num,
unit: data.unit,
price: data.totalCoin / 1000,
uid: data.uid,
medalLevel: data.medalLevel,
medalName: data.medalName,
}
this.$refs.renderer.addMessage(message)
this.renderer.addMessage(message)
},
/** @param {chatModels.AddSuperChatMsg} data */
onAddSuperChat(data) {
@@ -321,6 +473,7 @@ export default {
if (data.price < this.config.minGiftPrice) { // 丢人
return
}
/** @type {typeof blcsdk.SuperChatMsg} */
let message = {
id: data.id,
type: constants.MESSAGE_TYPE_SUPER_CHAT,
@@ -330,20 +483,25 @@ export default {
price: data.price,
time: new Date(data.timestamp * 1000),
content: data.content.trim(),
translation: data.translation
translation: this.config.autoTranslate ? data.translation : '',
// 给模板用的字段
uid: data.uid,
privilegeType: data.privilegeType,
medalLevel: data.medalLevel,
medalName: data.medalName,
}
this.$refs.renderer.addMessage(message)
this.renderer.addMessage(message)
},
/** @param {chatModels.DelSuperChatMsg} data */
onDelSuperChat(data) {
this.$refs.renderer.delMessages(data.ids)
this.renderer.delMessages(data.ids)
},
/** @param {chatModels.UpdateTranslationMsg} data */
onUpdateTranslation(data) {
if (!this.config.autoTranslate) {
return
}
this.$refs.renderer.updateMessage(data.id, { translation: data.translation })
this.renderer.updateMessage(data.id, { translation: data.translation })
},
/** @param {chatModels.ChatClientFatalError} error */
onFatalError(error) {
@@ -413,13 +571,13 @@ export default {
if (!this.config.mergeSimilarDanmaku) {
return false
}
return this.$refs.renderer.mergeSimilarText(content)
return this.renderer.mergeSimilarText(content)
},
mergeSimilarGift(authorName, price, freePrice, giftName, num) {
if (!this.config.mergeGift) {
return false
}
return this.$refs.renderer.mergeSimilarGift(authorName, price, freePrice, giftName, num)
return this.renderer.mergeSimilarGift(authorName, price, freePrice, giftName, num)
},
getPronunciation(text) {
if (this.pronunciationConverter === null) {
@@ -427,29 +585,29 @@ export default {
}
return this.pronunciationConverter.getPronunciation(text)
},
async getRichContent(data) {
let richContent = []
async parseContentParts(data) {
let contentParts = []
// 官方的非文本表情
if (data.emoticon !== null) {
richContent.push({
type: constants.CONTENT_TYPE_IMAGE,
contentParts.push({
type: constants.CONTENT_PART_TYPE_IMAGE,
text: data.content,
url: data.emoticon,
width: 0,
height: 0
})
await this.fillImageContentSizes(richContent)
return richContent
await this.fillImageContentSizes(contentParts)
return contentParts
}
// 没有文本表情,只能是纯文本
if (this.config.emoticons.length === 0 && this.textEmoticons.length === 0) {
richContent.push({
type: constants.CONTENT_TYPE_TEXT,
contentParts.push({
type: constants.CONTENT_PART_TYPE_TEXT,
text: data.content
})
return richContent
return contentParts
}
// 可能含有文本表情,需要解析
@@ -466,15 +624,15 @@ export default {
// 加入之前的文本
if (pos !== startPos) {
richContent.push({
type: constants.CONTENT_TYPE_TEXT,
contentParts.push({
type: constants.CONTENT_PART_TYPE_TEXT,
text: data.content.slice(startPos, pos)
})
}
// 加入表情
richContent.push({
type: constants.CONTENT_TYPE_IMAGE,
contentParts.push({
type: constants.CONTENT_PART_TYPE_IMAGE,
text: matchEmoticon.keyword,
url: matchEmoticon.url,
width: 0,
@@ -485,19 +643,19 @@ export default {
}
// 加入尾部的文本
if (pos !== startPos) {
richContent.push({
type: constants.CONTENT_TYPE_TEXT,
contentParts.push({
type: constants.CONTENT_PART_TYPE_TEXT,
text: data.content.slice(startPos, pos)
})
}
await this.fillImageContentSizes(richContent)
return richContent
await this.fillImageContentSizes(contentParts)
return contentParts
},
async fillImageContentSizes(richContent) {
async fillImageContentSizes(contentParts) {
let urlSizeMap = new Map()
for (let content of richContent) {
if (content.type === constants.CONTENT_TYPE_IMAGE) {
for (let content of contentParts) {
if (content.type === constants.CONTENT_PART_TYPE_IMAGE) {
urlSizeMap.set(content.url, { width: 0, height: 0 })
}
}
@@ -527,8 +685,8 @@ export default {
}
await Promise.all(promises)
for (let content of richContent) {
if (content.type === constants.CONTENT_TYPE_IMAGE) {
for (let content of contentParts) {
if (content.type === constants.CONTENT_PART_TYPE_IMAGE) {
let size = urlSizeMap.get(content.url)
content.width = size.width
content.height = size.height
@@ -538,3 +696,16 @@ export default {
}
}
</script>
<style scoped>
.template-container {
width: 100%;
height: 100%;
overflow: hidden;
}
.template-iframe {
width: 100%;
height: 100%;
}
</style>

View File

@@ -1,360 +1,378 @@
<template>
<div>
<el-card shadow="never">
<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>
<el-collapse>
<el-collapse-item>
<template slot="title">
<h3>{{ $t('stylegen.outlines') }}</h3>
</template>
<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-collapse-item>
<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>
<el-collapse-item>
<template slot="title">
<h3>{{ $t('stylegen.avatars') }}</h3>
</template>
<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-collapse-item>
<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>
<el-collapse-item>
<template slot="title">
<h3>{{ $t('stylegen.userNames') }}</h3>
</template>
<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-collapse-item>
<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-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.largeEmoticonSize')">
<el-input v-model.number="form.largeEmoticonSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
</el-card>
<el-collapse-item>
<template slot="title">
<h3>{{ $t('stylegen.messages') }}</h3>
</template>
<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-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.largeEmoticonSize')">
<el-input v-model.number="form.largeEmoticonSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
</el-collapse-item>
<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>
<el-collapse-item>
<template slot="title">
<h3>{{ $t('stylegen.time') }}</h3>
</template>
<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-collapse-item>
<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>
<el-collapse-item>
<template slot="title">
<h3>{{ $t('stylegen.backgrounds') }}</h3>
</template>
<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-collapse-item>
<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-collapse-item>
<template slot="title">
<h3>{{ $t('stylegen.scAndNewMember') }}</h3>
</template>
<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.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-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>
<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-collapse-item>
<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-collapse-item>
<template slot="title">
<h3>{{ $t('stylegen.animation') }}</h3>
</template>
<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-collapse-item>
</el-collapse>
</el-form>
</div>
</el-card>
</template>
<script>

View File

@@ -1,315 +1,331 @@
<template>
<div>
<el-card shadow="never">
<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>
<el-collapse>
<el-collapse-item>
<template slot="title">
<h3>{{ $t('stylegen.avatars') }}</h3>
</template>
<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-collapse-item>
<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>
<el-collapse-item>
<template slot="title">
<h3>{{ $t('stylegen.userNames') }}</h3>
</template>
<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-collapse-item>
<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-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.largeEmoticonSize')">
<el-input v-model.number="form.largeEmoticonSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
</el-card>
<el-collapse-item>
<template slot="title">
<h3>{{ $t('stylegen.messages') }}</h3>
</template>
<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-col :xs="24" :sm="12">
<el-form-item :label="$t('stylegen.largeEmoticonSize')">
<el-input v-model.number="form.largeEmoticonSize" type="number" min="0"></el-input>
</el-form-item>
</el-col>
</el-row>
</el-collapse-item>
<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>
<el-collapse-item>
<template slot="title">
<h3>{{ $t('stylegen.time') }}</h3>
</template>
<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-collapse-item>
<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.showLargeEmoticonBg')">
<el-switch v-model="form.showLargeEmoticonBg"></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>
<el-collapse-item>
<template slot="title">
<h3>{{ $t('stylegen.backgrounds') }}</h3>
</template>
<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.showLargeEmoticonBg')">
<el-switch v-model="form.showLargeEmoticonBg"></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-collapse-item>
<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-collapse-item>
<template slot="title">
<h3>{{ $t('stylegen.scAndNewMember') }}</h3>
</template>
<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.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.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>
<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-collapse-item>
<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-collapse-item>
<template slot="title">
<h3>{{ $t('stylegen.animation') }}</h3>
</template>
<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-collapse-item>
</el-collapse>
</el-form>
</div>
</el-card>
</template>
<script>

View File

@@ -2,12 +2,12 @@
<el-row :gutter="20">
<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-tab-pane :label="$t('stylegen.legacy')" name="legacy">
<legacy ref="legacy" v-model="subComponentResults.legacy"></legacy>
</el-tab-pane>
</el-tabs>
<el-form label-width="150px" size="mini">
@@ -38,9 +38,7 @@
</el-form-item>
</el-form>
<div id="example-container" :class="{ light: exampleBgLight }">
<iframe id="example-room-iframe" ref="exampleRoomIframe"
:src="exampleRoomUrl" frameborder="0" @load="onExampleRoomLoad"
></iframe>
<iframe id="example-room-iframe" ref="exampleRoomIframe" :src="exampleRoomUrl" frameborder="0"></iframe>
</div>
</div>
</el-col>
@@ -66,14 +64,14 @@ export default {
legacy: '',
lineLike: ''
},
activeTab: 'legacy',
activeTab: 'lineLike',
// 输入框的结果
inputResult: '',
// 防抖后延迟变化的结果
debounceResult: '',
playAnimation: true,
exampleBgLight: false
exampleBgLight: true,
}
},
computed: {
@@ -96,22 +94,42 @@ export default {
},
mounted() {
this.debounceResult = this.inputResult = this.subComponentResult
window.addEventListener('message', this.onWindowMessage)
},
beforeDestroy() {
window.removeEventListener('message', this.onWindowMessage)
},
methods: {
onExampleRoomLoad() {
this.setExampleRoomCustomCss(this.debounceResult)
this.setExampleRoomClientStart(this.playAnimation)
sendMessageToExampleRoom(type, data = null) {
let msg = { type, data }
this.$refs.exampleRoomIframe.contentWindow.postMessage(msg, window.location.origin)
},
// 处理房间发送的消息
onWindowMessage(event) {
if (event.source !== this.$refs.exampleRoomIframe.contentWindow) {
return
}
if (event.origin !== window.location.origin) {
console.warn(`消息origin错误${event.origin} != ${window.location.origin}`)
return
}
let { type } = event.data
switch (type) {
case 'stylegenExampleRoomLoad':
this.setExampleRoomCustomCss(this.debounceResult)
this.setExampleRoomClientStart(this.playAnimation)
break
}
},
setExampleRoomCustomCss(css) {
this.sendMessageToExampleRoom('roomSetCustomStyle', { css })
},
setExampleRoomClientStart(isStart) {
this.sendMessageToExampleRoom(isStart ? 'roomStartClient' : 'roomStopClient')
},
sendMessageToExampleRoom(type, data = null) {
let msg = { type, data }
this.$refs.exampleRoomIframe.contentWindow.postMessage(msg, window.location.origin)
},
copyResult() {
this.$refs.result.select()

31
frontend/vercel.json Normal file
View File

@@ -0,0 +1,31 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"cleanUrls": true,
"trailingSlash": false,
"headers": [
{
"source": "/((?!api/)[^.]*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=180"
}
]
},
{
"source": "/((?!api/).+\\.\\w+)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=86400"
}
]
}
],
"rewrites": [
{
"source": "/((?!api/)[^.]+)",
"destination": "/"
}
]
}

View File

@@ -1,7 +1,16 @@
const { defineConfig } = require('@vue/cli-service')
// 不能用localhosthttps://forum.dfinity.org/t/development-workflow-quickly-test-code-modifications/1793/21
const API_BASE_URL = 'http://127.0.0.1:12450'
module.exports = {
function toBool(val) {
if (typeof val === 'string') {
return ['false', 'no', 'off', '0', ''].indexOf(val.toLowerCase()) === -1
}
return Boolean(val)
}
module.exports = defineConfig({
devServer: {
proxy: {
'/api': {
@@ -16,24 +25,35 @@ module.exports = {
},
}
},
productionSourceMap: toBool(process.env.PROD_SOURCE_MAP),
chainWebpack: config => {
const APP_VERSION = `v${process.env.npm_package_version}`
const LIB_USE_CDN = toBool(process.env.LIB_USE_CDN)
const ENV = {
APP_VERSION,
LIB_USE_CDN,
BACKEND_DISCOVERY: toBool(process.env.BACKEND_DISCOVERY),
}
config.plugin('define')
.tap(args => {
let defineMap = args[0]
let env = defineMap['process.env']
env['APP_VERSION'] = JSON.stringify(APP_VERSION)
for (let [name, value] of Object.entries(ENV)) {
env[name] = JSON.stringify(value)
}
return args
})
config.externals({
'element-ui': 'ELEMENT',
lodash: '_',
pako: 'pako',
vue: 'Vue',
'vue-router': 'VueRouter',
'vue-i18n': 'VueI18n',
})
if (LIB_USE_CDN) {
config.externals({
'element-ui': 'ELEMENT',
lodash: '_',
pako: 'pako',
vue: 'Vue',
'vue-router': 'VueRouter',
'vue-i18n': 'VueI18n',
})
}
}
}
})

43
main.py
View File

@@ -39,6 +39,7 @@ ROUTES = [
server: Optional[tornado.httpserver.HTTPServer] = None
cmd_args = None
shut_down_event: Optional[asyncio.Event] = None
@@ -55,11 +56,12 @@ async def main():
def init():
init_signal_handlers()
args = parse_args()
global cmd_args
cmd_args = parse_args()
init_logging(args.debug)
init_logging(cmd_args.debug)
logger.info('App started, initializing')
config.init(args)
config.init(cmd_args)
utils.request.init()
models.database.init()
@@ -83,21 +85,36 @@ def init_signal_handlers():
global shut_down_event
shut_down_event = asyncio.Event()
signums = (signal.SIGINT, signal.SIGTERM)
try:
loop = asyncio.get_running_loop()
for signum in signums:
loop.add_signal_handler(signum, on_shut_down_signal)
except NotImplementedError:
# 不太安全但Windows只能用这个
for signum in signums:
signal.signal(signum, on_shut_down_signal)
is_win = sys.platform == 'win32'
loop = asyncio.get_running_loop()
if not is_win:
def add_signal_handler(signum, callback):
loop.add_signal_handler(signum, callback)
else:
def add_signal_handler(signum, callback):
# 不太安全但Windows只能用这个
signal.signal(signum, lambda _signum, _frame: loop.call_soon(callback))
shut_down_signums = (signal.SIGINT, signal.SIGTERM)
if not is_win:
reload_signum = signal.SIGHUP
else:
reload_signum = signal.SIGBREAK
for shut_down_signum in shut_down_signums:
add_signal_handler(shut_down_signum, on_shut_down_signal)
add_signal_handler(reload_signum, on_reload_signal)
def on_shut_down_signal(*_args):
def on_shut_down_signal():
shut_down_event.set()
def on_reload_signal():
logger.info('Received reload signal')
config.reload(cmd_args)
def parse_args():
parser = argparse.ArgumentParser(description='用于OBS的仿YouTube风格的bilibili直播评论栏')
parser.add_argument('--host', help='服务器host默认和配置中的一样', default=None)

View File

@@ -1,5 +1,6 @@
-r blivedm/requirements.txt
cachetools==5.3.1
circuitbreaker==2.0.0
pycryptodome==3.19.1
sqlalchemy==2.0.19
tornado==6.3.3
sqlalchemy==2.0.37
tornado==6.4.2

View File

@@ -193,7 +193,6 @@ class WebLiveClient(blivedm.BLiveClient):
assert room_key.type == RoomKeyType.ROOM_ID
super().__init__(
room_key.value,
uid=0,
session=utils.request.http_session,
heartbeat_interval=self.HEARTBEAT_INTERVAL,
)
@@ -266,7 +265,10 @@ class OpenLiveClient(blivedm.OpenLiveClient):
logger.error('_start_game() failed')
return False
except api_open_live.BusinessError as e:
logger.warning('_start_game() failed')
logger.warning(
'_start_game() failed, room_id=%s',
api_open_live.auth_code_room_id_cache.get(self._room_owner_auth_code, None)
)
if e.code == 7007:
# 身份码错误
@@ -288,7 +290,11 @@ class OpenLiveClient(blivedm.OpenLiveClient):
})
return False
return self._parse_start_game(data['data'])
res = self._parse_start_game(data['data'])
if res:
api_open_live.auth_code_room_id_cache[self._room_owner_auth_code] = self.room_id
return res
async def _end_game(self):
if self._game_id in (None, ''):
@@ -501,7 +507,9 @@ class LiveMsgHandler(blivedm.BaseHandler):
async def __on_danmaku(self, client: WebLiveClient, message: dm_web_models.DanmakuMessage):
# 先异步调用再获取房间,因为返回时房间可能已经不存在了
avatar_url = await services.avatar.get_avatar_url(message.uid, message.uname)
avatar_url = message.face
if avatar_url == '':
avatar_url = await services.avatar.get_avatar_url(message.uid, message.uname)
room = client_room_manager.get_room(client.room_key)
if room is None:
@@ -516,6 +524,11 @@ class LiveMsgHandler(blivedm.BaseHandler):
else:
author_type = 0
show_content = message.msg
reply_uname = message.extra_dict.get('reply_uname', '')
if reply_uname != '':
show_content = f'@{reply_uname} {show_content}'
if message.dm_type == 1:
content_type = api.chat.ContentType.EMOTICON
content_type_params = api.chat.make_emoticon_params(
@@ -544,7 +557,7 @@ class LiveMsgHandler(blivedm.BaseHandler):
timestamp=int(message.timestamp / 1000),
author_name=message.uname,
author_type=author_type,
content=message.msg,
content=show_content,
privilege_type=message.privilege_type,
is_gift_danmaku=bool(message.msg_type),
author_level=message.user_level,
@@ -587,22 +600,22 @@ class LiveMsgHandler(blivedm.BaseHandler):
'num': message.num,
# 给插件用的字段
'giftId': message.gift_id,
'giftIconUrl': '',
'giftIconUrl': message.gift_img_basic,
'uid': str(message.uid) if message.uid != 0 else message.uname,
'privilegeType': message.guard_level,
'medalLevel': 0,
'medalName': '',
'medalLevel': 0 if message.medal_ruid != client.room_owner_uid else message.medal_level,
'medalName': '' if message.medal_ruid != client.room_owner_uid else message.medal_name,
}
room.send_cmd_data(api.chat.Command.ADD_GIFT, data)
services.plugin.broadcast_cmd_data(
sdk_models.Command.ADD_GIFT, data, make_plugin_msg_extra_from_live_client(client)
)
def _on_buy_guard(self, client: WebLiveClient, message: dm_web_models.GuardBuyMessage):
def _on_user_toast_v2(self, client: WebLiveClient, message: dm_web_models.UserToastV2Message):
utils.async_io.create_task_with_ref(self.__on_buy_guard(client, message))
@staticmethod
async def __on_buy_guard(client: WebLiveClient, message: dm_web_models.GuardBuyMessage):
async def __on_buy_guard(client: WebLiveClient, message: dm_web_models.UserToastV2Message):
# 先异步调用再获取房间,因为返回时房间可能已经不存在了
avatar_url = await services.avatar.get_avatar_url(message.uid, message.username)
@@ -618,7 +631,7 @@ class LiveMsgHandler(blivedm.BaseHandler):
'privilegeType': message.guard_level,
# 给插件用的字段
'num': message.num,
'unit': '', # 单位在USER_TOAST_MSG消息里不想改消息。现在没有别的单位web接口也很少有人用了先写死吧
'unit': message.unit,
'total_coin': message.price * message.num,
'uid': str(message.uid) if message.uid != 0 else message.username,
'medalLevel': 0,
@@ -660,8 +673,8 @@ class LiveMsgHandler(blivedm.BaseHandler):
# 给插件用的字段
'uid': str(message.uid) if message.uid != 0 else message.uname,
'privilegeType': message.guard_level,
'medalLevel': 0,
'medalName': '',
'medalLevel': 0 if message.medal_room_id != client.room_id else message.medal_level,
'medalName': '' if message.medal_room_id != client.room_id else message.medal_name,
}
room.send_cmd_data(api.chat.Command.ADD_SUPER_CHAT, data)
services.plugin.broadcast_cmd_data(
@@ -728,11 +741,17 @@ class LiveMsgHandler(blivedm.BaseHandler):
if message.open_id == client.room_owner_open_id:
author_type = 3 # 主播
elif message.is_admin:
author_type = 2 # 房管
elif message.guard_level != 0: # 1总督2提督3舰长
author_type = 1 # 舰队
else:
author_type = 0
show_content = message.msg
if message.reply_uname != '':
show_content = f'@{message.reply_uname} {show_content}'
if message.dm_type == 1:
content_type = api.chat.ContentType.EMOTICON
content_type_params = api.chat.make_emoticon_params(message.emoji_img_url)
@@ -758,7 +777,7 @@ class LiveMsgHandler(blivedm.BaseHandler):
timestamp=message.timestamp,
author_name=message.uname,
author_type=author_type,
content=message.msg,
content=show_content,
privilege_type=message.guard_level,
medal_level=0 if not message.fans_medal_wearing_status else message.fans_medal_level,
id_=message.msg_id,
@@ -784,7 +803,7 @@ class LiveMsgHandler(blivedm.BaseHandler):
if room is None:
return
total_coin = message.price * message.gift_num
total_coin = message.r_price * message.gift_num
data = {
'id': message.msg_id,
'avatarUrl': services.avatar.process_avatar_url(message.uface),

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
import asyncio
import base64
import copy
import dataclasses
import datetime
import enum
@@ -10,13 +10,13 @@ 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 cachetools
import circuitbreaker
import config
import utils.async_io
@@ -71,11 +71,7 @@ async def _do_init():
def create_translate_provider(cfg):
type_ = cfg['type']
if type_ == 'TencentTranslateFree':
return TencentTranslateFree(
cfg['query_interval'], cfg['source_language'], cfg['target_language']
)
elif type_ == 'TencentTranslate':
if type_ == 'TencentTranslate':
return TencentTranslate(
cfg['query_interval'], cfg['source_language'], cfg['target_language'],
cfg['secret_id'], cfg['secret_key'], cfg['region']
@@ -85,10 +81,10 @@ def create_translate_provider(cfg):
cfg['query_interval'], cfg['source_language'], cfg['target_language'],
cfg['app_id'], cfg['secret']
)
elif type_ == 'GeminiTranslate':
return GeminiTranslate(
cfg['query_interval'], cfg['proxy'], cfg['api_key'], cfg['model_code'],
cfg['prompt'], cfg['temperature']
elif type_ == 'OpenAiApi':
return OpenAiApi(
cfg['query_interval'], cfg['api_key'], cfg['base_url'], cfg['proxy'], cfg['model'],
cfg['prompt'], cfg['max_tokens'], cfg['temperature'], cfg['top_p']
)
return None
@@ -261,8 +257,10 @@ class TranslateProvider:
logger.info('%s became available', cls_name)
task = await _pop_task()
# 为了简化代码约定只会在_translate_wrapper里变成不可用所以获取task之后这里还是可用的
assert self.is_available
if not self.is_available:
if not _push_task(task):
task.future.set_result(None)
continue
start_time = datetime.datetime.now()
await self._translate_wrapper(task)
@@ -273,7 +271,7 @@ class TranslateProvider:
except Exception: # noqa
logger.exception('%s error:', cls_name)
async def _translate_wrapper(self, task: TranslateTask) -> Optional[str]:
async def _translate_wrapper(self, task: TranslateTask):
try:
exc = None
task.remain_retry_count -= 1
@@ -283,7 +281,7 @@ class TranslateProvider:
res = None
if res is not None:
task.future.set_result(res)
return res
return
if task.remain_retry_count > 0:
# 还可以重试则放回队列
@@ -295,220 +293,11 @@ class TranslateProvider:
task.future.set_exception(exc)
else:
task.future.set_result(None)
return None
async def _do_translate(self, text) -> Optional[str]:
raise NotImplementedError
class TencentTranslateFree(TranslateProvider):
def __init__(self, query_interval, source_language, target_language):
super().__init__(query_interval)
self._be_available_event.clear() # _do_init之后才可用
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
self._reinit_future = asyncio.create_task(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.ClientError, 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.ClientError, 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
self._on_availability_change()
return True
async def _reinit_coroutine(self):
while True:
logger.debug('TencentTranslateFree reinit')
start_time = datetime.datetime.now()
try:
await self._do_init()
except Exception: # noqa
pass
cost_time = (datetime.datetime.now() - start_time).total_seconds()
await asyncio.sleep(30 - cost_time)
@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_wrapper(self, task: TranslateTask) -> Optional[str]:
res = await super()._translate_wrapper(task)
if res is not None:
self._fail_count = 0
else:
self._on_fail()
return res
async def _do_translate(self, text) -> Optional[str]:
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.ClientError, 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.info('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
# 为了可靠性连续失败5次时冷却直到下次重新init
if self._fail_count >= 5:
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
self._on_availability_change()
class TencentTranslate(TranslateProvider):
def __init__(self, query_interval, source_language, target_language,
secret_id, secret_key, region):
@@ -559,8 +348,9 @@ class TencentTranslate(TranslateProvider):
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')
cur_time_utc = datetime.datetime.now(datetime.UTC)
request_timestamp = int(cur_time_utc.timestamp())
date = cur_time_utc.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}'
@@ -684,96 +474,96 @@ class BaiduTranslate(TranslateProvider):
self._on_availability_change()
class GeminiTranslate(TranslateProvider):
def __init__(self, query_interval, proxy, api_key, model_code, prompt, temperature):
class _OpenAiApiExpectedError(Exception):
pass
class OpenAiApi(TranslateProvider):
def __init__(self, query_interval, api_key, base_url, proxy, model, prompt, max_tokens, temperature, top_p):
super().__init__(query_interval)
self._url = base_url + '/chat/completions'
self._proxy = proxy or None
self._api_key = api_key
self._url = f'https://generativelanguage.googleapis.com/v1beta/{model_code}:generateContent'
self._prompt = prompt
self._headers = {
'Authorization': 'Bearer ' + api_key
}
self._body = {
'contents': [
{
# 'role': 'user',
'parts': [{'text': ''}]
}
'model': model,
'messages': [
{'role': 'system', 'content': prompt},
{'role': 'user', 'content': ''},
],
'safetySettings': [
{
'category': 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
'threshold': 'BLOCK_NONE'
},
{
'category': 'HARM_CATEGORY_HATE_SPEECH',
'threshold': 'BLOCK_NONE'
},
{
'category': 'HARM_CATEGORY_HARASSMENT',
'threshold': 'BLOCK_NONE'
},
{
'category': 'HARM_CATEGORY_DANGEROUS_CONTENT',
'threshold': 'BLOCK_NONE'
}
],
'generationConfig': {
'temperature': temperature,
'topP': 1,
'topK': 32,
'candidateCount': 1,
'maxOutputTokens': 8192
}
'stream': False,
'max_tokens': max_tokens,
'temperature': temperature,
'top_p': top_p,
}
self._breaker = circuitbreaker.CircuitBreaker(
failure_threshold=5,
recovery_timeout=60,
)
self._cool_down_timer_handle = None
@property
def is_available(self):
return self._cool_down_timer_handle is None and super().is_available
return self._breaker.state != circuitbreaker.STATE_OPEN and super().is_available
async def _translate_wrapper(self, task: TranslateTask):
# 并发请求,不然太慢了
utils.async_io.create_task_with_ref(super()._translate_wrapper(task))
async def _do_translate(self, text) -> Optional[str]:
input_text = self._prompt.format(original_text=text)
self._body['contents'][0]['parts'][0]['text'] = input_text
data = None
try:
async with utils.request.http_session.post(
self._url,
params={'key': self._api_key},
json=self._body,
proxy=self._proxy,
) as r:
if r.status != 200:
logger.warning('GeminiTranslate request failed: status=%d %s', r.status, r.reason)
self._on_fail(r.status)
return None
data = await r.json()
with self._breaker:
body = copy.deepcopy(self._body)
body['messages'][-1]['content'] = text
async with utils.request.http_session.post(
self._url,
headers=self._headers,
json=body,
proxy=self._proxy,
timeout=aiohttp.ClientTimeout(total=30),
) as r:
if r.status != 200:
rsp_body = await r.text()
logger.warning(
'OpenAiApi request failed: status=%d %s, body=%s', r.status, r.reason, rsp_body
)
raise _OpenAiApiExpectedError('OpenAiApi request failed')
data = await r.json()
return data['choices'][0]['message']['content']
except (_OpenAiApiExpectedError, circuitbreaker.CircuitBreakerError):
pass
except (aiohttp.ClientConnectionError, asyncio.TimeoutError) as e:
logger.warning('GeminiTranslate request failed, %s: %s', type(e).__name__, e)
return None
try:
candidates = data['candidates']
if not candidates:
block_reason = data['promptFeedback'].get('blockReason', '')
logger.warning('GeminiTranslate no candidate, block_reason=%s, text=%s', block_reason, text)
return None
first_content_parts = candidates[0]['content']['parts']
return ''.join(part.get('text', '') for part in first_content_parts)
logger.warning('OpenAiApi request failed, %s: %s', type(e).__name__, e)
except (KeyError, IndexError):
logger.warning('GeminiTranslate failed to parse response: %s', data)
return None
if data is not None:
logger.warning('OpenAiApi failed to parse response: %s', data)
else:
logger.exception('OpenAiApi unknown exception:')
def _on_fail(self, code):
if self._breaker.state == circuitbreaker.STATE_OPEN:
self._on_breaker_open()
return None
def _on_breaker_open(self):
if self._cool_down_timer_handle is not None:
return
if code in (401, 403):
# API密钥无效没有权限或者不在有效地区。需要手动处理等5分钟
self._cool_down_timer_handle = asyncio.get_running_loop().call_later(
5 * 60, self._on_cool_down_timeout
)
self._on_availability_change()
self._cool_down_timer_handle = asyncio.get_running_loop().call_later(
self._breaker.open_remaining, self._on_cool_down_timeout
)
self._on_availability_change()
def _on_cool_down_timeout(self):
self._cool_down_timer_handle = None
if self._breaker.state == circuitbreaker.STATE_OPEN:
self._cool_down_timer_handle = asyncio.get_running_loop().call_later(
self._breaker.open_remaining, self._on_cool_down_timeout
)
return
self._on_availability_change()

View File

@@ -6,7 +6,7 @@ import aiohttp
import utils.async_io
import utils.request
VERSION = 'v1.9.1'
VERSION = 'v1.10.0'
def check_update():

View File

@@ -1,8 +1,21 @@
# -*- coding: utf-8 -*-
import asyncio
import datetime
import logging
import os
import pickle
from typing import *
import aiohttp
import circuitbreaker
import api.open_live
import config
import utils.async_io
logger = logging.getLogger(__name__)
COOKIE_JAR_PATH = os.path.join(config.DATA_PATH, 'cookie_jar.pickle')
# 不带这堆头部有时候也能成功请求,但是带上后成功的概率更高
BILIBILI_COMMON_HEADERS = {
@@ -14,14 +27,37 @@ BILIBILI_COMMON_HEADERS = {
http_session: Optional[aiohttp.ClientSession] = None
_COMMON_SERVER_DISCOVERY_URLS = [
'https://api1.blive.chat/api/endpoints',
'https://api2.blive.chat/api/endpoints',
]
_last_update_common_server_time: Optional[datetime.datetime] = None
_common_server_base_urls = [
'https://api1.blive.chat',
'https://api2.blive.chat',
]
_cur_common_server_base_url: Optional[str] = None
_common_server_base_url_to_circuit_breaker: Dict[str, circuitbreaker.CircuitBreaker] = {}
def init():
try:
cookie_jar = aiohttp.CookieJar()
cookie_jar.load(COOKIE_JAR_PATH)
except (OSError, pickle.PickleError):
cookie_jar = None
global http_session
http_session = aiohttp.ClientSession(
response_class=CustomClientResponse,
timeout=aiohttp.ClientTimeout(total=10),
cookie_jar=cookie_jar,
)
cfg = config.get_config()
if not cfg.is_open_live_configured:
_update_common_server_base_urls()
async def shut_down():
if http_session is not None:
@@ -35,3 +71,97 @@ class CustomClientResponse(aiohttp.ClientResponse):
return await super()._wait_released()
except asyncio.CancelledError as e:
raise aiohttp.ClientConnectionError('Connection released') from e
def _update_common_server_base_urls():
global _last_update_common_server_time
cur_time = datetime.datetime.now()
if (
_last_update_common_server_time is not None
and cur_time - _last_update_common_server_time < datetime.timedelta(minutes=3)
):
return
_last_update_common_server_time = cur_time
utils.async_io.create_task_with_ref(_do_update_common_server_base_urls())
async def _do_update_common_server_base_urls():
global _last_update_common_server_time
_last_update_common_server_time = datetime.datetime.now()
async def request_get_urls(discovery_url):
async with http_session.get(discovery_url) as res:
res.raise_for_status()
data = await res.json()
return data['endpoints']
common_server_base_urls = []
futures = [
asyncio.create_task(request_get_urls(url))
for url in _COMMON_SERVER_DISCOVERY_URLS
]
for future in asyncio.as_completed(futures):
try:
common_server_base_urls = await future
break
except Exception as e:
logger.warning('Failed to discover common server endpoints from one source: %s', e)
for future in futures:
future.cancel()
if not common_server_base_urls:
logger.error('Failed to discover common server endpoints from any source')
return
# 按响应时间排序
sorted_common_server_base_urls = []
error_base_urls = []
async def test_endpoint(base_url):
try:
url = base_url + '/api/ping'
async with http_session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as res:
res.raise_for_status()
sorted_common_server_base_urls.append(base_url)
except Exception: # noqa
error_base_urls.append(base_url)
await asyncio.gather(*(test_endpoint(base_url) for base_url in common_server_base_urls))
sorted_common_server_base_urls.extend(error_base_urls)
global _common_server_base_urls, _cur_common_server_base_url
_common_server_base_urls = sorted_common_server_base_urls
if _cur_common_server_base_url not in _common_server_base_urls:
_cur_common_server_base_url = None
logger.info('Found common server endpoints: %s', _common_server_base_urls)
def get_common_server_base_url_and_circuit_breaker() -> Tuple[Optional[str], Optional[circuitbreaker.CircuitBreaker]]:
_update_common_server_base_urls()
global _cur_common_server_base_url
if _cur_common_server_base_url is not None:
breaker = _get_or_add_common_server_circuit_breaker(_cur_common_server_base_url)
if breaker.state != circuitbreaker.STATE_OPEN:
return _cur_common_server_base_url, breaker
_cur_common_server_base_url = None
# 找第一个未熔断的
for base_url in _common_server_base_urls:
breaker = _get_or_add_common_server_circuit_breaker(base_url)
if breaker.state != circuitbreaker.STATE_OPEN:
_cur_common_server_base_url = base_url
logger.info('Switch common server endpoint to %s', _cur_common_server_base_url)
return _cur_common_server_base_url, breaker
return None, None
def _get_or_add_common_server_circuit_breaker(base_url):
breaker = _common_server_base_url_to_circuit_breaker.get(base_url, None)
if breaker is None:
breaker = _common_server_base_url_to_circuit_breaker[base_url] = circuitbreaker.CircuitBreaker(
failure_threshold=3,
recovery_timeout=60,
expected_exception=api.open_live.TransportError,
)
return breaker