Compare commits

...

34 Commits

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

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

View File

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

5
.gitignore vendored
View File

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

View File

@@ -1,5 +1,5 @@
# 运行时
FROM python:3.6.8-slim-stretch
FROM python:3.7.10-slim-stretch
RUN mv /etc/apt/sources.list /etc/apt/sources.list.bak \
&& echo "deb http://mirrors.tuna.tsinghua.edu.cn/debian/ stretch main contrib non-free">>/etc/apt/sources.list \
&& echo "deb http://mirrors.tuna.tsinghua.edu.cn/debian/ stretch-updates main contrib non-free">>/etc/apt/sources.list \
@@ -20,7 +20,7 @@ RUN pip3 install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r r
# 前端依赖
WORKDIR ./frontend
COPY frontend/package.json frontend/package-lock.json ./
COPY frontend/package.json ./
RUN npm i --registry=https://registry.npm.taobao.org
# 编译前端

View File

@@ -7,6 +7,10 @@ import tornado.web
# noinspection PyAbstractClass
class ApiHandler(tornado.web.RequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.json_args = None
def set_default_headers(self):
# 跨域测试用
if not self.application.settings['debug']:
@@ -18,13 +22,12 @@ class ApiHandler(tornado.web.RequestHandler):
self.request.headers['Access-Control-Request-Headers'])
def prepare(self):
if self.request.headers.get('Content-Type', '').startswith('application/json'):
try:
self.json_args = json.loads(self.request.body)
except json.JSONDecodeError:
self.json_args = None
else:
self.json_args = None
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):
# 跨域测试用

View File

@@ -32,7 +32,7 @@ class Command(enum.IntEnum):
UPDATE_TRANSLATION = 7
_http_session = aiohttp.ClientSession()
_http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
room_manager: Optional['RoomManager'] = None
@@ -43,6 +43,8 @@ def init():
class Room(blivedm.BLiveClient):
HEARTBEAT_INTERVAL = 10
# 重新定义parse_XXX是为了减少对字段名的依赖防止B站改字段名
def __parse_danmaku(self, command):
info = command['info']
@@ -97,7 +99,7 @@ class Room(blivedm.BLiveClient):
}
def __init__(self, room_id):
super().__init__(room_id, session=_http_session, heartbeat_interval=10)
super().__init__(room_id, session=_http_session, heartbeat_interval=self.HEARTBEAT_INTERVAL)
self.clients: List['ChatHandler'] = []
self.auto_translate_count = 0
@@ -365,34 +367,68 @@ class RoomManager:
# noinspection PyAbstractClass
class ChatHandler(tornado.websocket.WebSocketHandler):
HEARTBEAT_INTERVAL = 10
RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + 5
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._close_on_timeout_future = None
self._heartbeat_timer_handle = None
self._receive_timeout_timer_handle = None
self.room_id = None
self.auto_translate = False
def open(self):
logger.info('Websocket connected %s', self.request.remote_ip)
self._close_on_timeout_future = asyncio.ensure_future(self._close_on_timeout())
self._heartbeat_timer_handle = asyncio.get_event_loop().call_later(
self.HEARTBEAT_INTERVAL, self._on_send_heartbeat
)
self._refresh_receive_timeout_timer()
async def _close_on_timeout(self):
try:
# 超过一定时间还没加入房间则断开
await asyncio.sleep(10)
logger.warning('Client %s joining room timed out', self.request.remote_ip)
self.close()
except (asyncio.CancelledError, tornado.websocket.WebSocketClosedError):
pass
def _on_send_heartbeat(self):
self.send_message(Command.HEARTBEAT, {})
self._heartbeat_timer_handle = asyncio.get_event_loop().call_later(
self.HEARTBEAT_INTERVAL, self._on_send_heartbeat
)
def _refresh_receive_timeout_timer(self):
if self._receive_timeout_timer_handle is not None:
self._receive_timeout_timer_handle.cancel()
self._receive_timeout_timer_handle = asyncio.get_event_loop().call_later(
self.RECEIVE_TIMEOUT, self._on_receive_timeout
)
def _on_receive_timeout(self):
logger.warning('Client %s timed out', self.request.remote_ip)
self._receive_timeout_timer_handle = None
self.close()
def on_close(self):
logger.info('Websocket disconnected %s room: %s', self.request.remote_ip, str(self.room_id))
if self.has_joined_room:
room_manager.del_client(self.room_id, self)
if self._heartbeat_timer_handle is not None:
self._heartbeat_timer_handle.cancel()
self._heartbeat_timer_handle = None
if self._receive_timeout_timer_handle is not None:
self._receive_timeout_timer_handle.cancel()
self._receive_timeout_timer_handle = None
def on_message(self, message):
try:
# 超时没有加入房间也断开
if self.has_joined_room:
self._refresh_receive_timeout_timer()
body = json.loads(message)
cmd = body['cmd']
if cmd == Command.HEARTBEAT:
return
pass
elif cmd == Command.JOIN_ROOM:
if self.has_joined_room:
return
self._refresh_receive_timeout_timer()
self.room_id = int(body['data']['roomId'])
logger.info('Client %s is joining room %d', self.request.remote_ip, self.room_id)
try:
@@ -402,21 +438,11 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
pass
asyncio.ensure_future(room_manager.add_client(self.room_id, self))
self._close_on_timeout_future.cancel()
self._close_on_timeout_future = None
else:
logger.warning('Unknown cmd, client: %s, cmd: %d, body: %s', self.request.remote_ip, cmd, body)
except Exception:
logger.exception('on_message error, client: %s, message: %s', self.request.remote_ip, message)
def on_close(self):
logger.info('Websocket disconnected %s room: %s', self.request.remote_ip, str(self.room_id))
if self.has_joined_room:
room_manager.del_client(self.room_id, self)
if self._close_on_timeout_future is not None:
self._close_on_timeout_future.cancel()
self._close_on_timeout_future = None
# 跨域测试用
def check_origin(self, origin):
if self.application.settings['debug']:
@@ -432,7 +458,7 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
try:
self.write_message(body)
except tornado.websocket.WebSocketClosedError:
self.on_close()
self.close()
async def on_join_room(self):
if self.application.settings['debug']:
@@ -550,7 +576,7 @@ class RoomInfoHandler(api.base.ApiHandler):
res.status, res.reason)
return room_id, 0
data = await res.json()
except aiohttp.ClientConnectionError:
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
logger.exception('room %d _get_room_info failed', room_id)
return room_id, 0
@@ -574,7 +600,7 @@ class RoomInfoHandler(api.base.ApiHandler):
# res.status, res.reason)
# return cls._host_server_list_cache
# data = await res.json()
# except aiohttp.ClientConnectionError:
# except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
# logger.exception('room %d _get_server_host_list failed', room_id)
# return cls._host_server_list_cache
#

Submodule blivedm updated: 8d8cc8c270...4669b2c1c9

106
config.py
View File

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

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

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

View File

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

3
frontend/.gitignore vendored
View File

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

View File

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

View File

@@ -8,8 +8,8 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^0.19.0",
"core-js": "^2.6.5",
"axios": "^0.21.1",
"core-js": "^3.6.5",
"downloadjs": "^1.4.7",
"element-ui": "^2.9.1",
"lodash": "^4.17.19",
@@ -19,13 +19,13 @@
"vue-router": "^3.0.6"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^3.7.0",
"@vue/cli-plugin-eslint": "^3.7.0",
"@vue/cli-service": "^4.2.2",
"babel-eslint": "^10.0.1",
"@vue/cli-plugin-babel": "^4.5.12",
"@vue/cli-plugin-eslint": "^4.5.12",
"@vue/cli-service": "~4.5.12",
"babel-eslint": "^10.1.0",
"babel-plugin-component": "^1.1.1",
"eslint": "^5.16.0",
"eslint-plugin-vue": "^5.0.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"vue-template-compiler": "^2.5.21"
},
"eslintConfig": {

View File

@@ -6,8 +6,8 @@ import * as avatar from './avatar'
const HEADER_SIZE = 16
// const WS_BODY_PROTOCOL_VERSION_NORMAL = 0
// const WS_BODY_PROTOCOL_VERSION_INT = 1 // 用于心跳包
// const WS_BODY_PROTOCOL_VERSION_INFLATE = 0
// const WS_BODY_PROTOCOL_VERSION_NORMAL = 1
const WS_BODY_PROTOCOL_VERSION_DEFLATE = 2
// const OP_HANDSHAKE = 0
@@ -32,6 +32,9 @@ const OP_AUTH_REPLY = 8
// const MinBusinessOp = 1000
// const MaxBusinessOp = 10000
const HEARTBEAT_INTERVAL = 10 * 1000
const RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + 5 * 1000
let textEncoder = new TextEncoder()
let textDecoder = new TextDecoder()
@@ -55,6 +58,7 @@ export default class ChatClientDirect {
this.retryCount = 0
this.isDestroying = false
this.heartbeatTimerId = null
this.receiveTimeoutTimerId = null
}
async start () {
@@ -120,15 +124,33 @@ export default class ChatClientDirect {
this.websocket.onopen = this.onWsOpen.bind(this)
this.websocket.onclose = this.onWsClose.bind(this)
this.websocket.onmessage = this.onWsMessage.bind(this)
this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), 10 * 1000)
}
onWsOpen () {
this.sendAuth()
this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), HEARTBEAT_INTERVAL)
this.refreshReceiveTimeoutTimer()
}
sendHeartbeat () {
this.websocket.send(this.makePacket({}, OP_HEARTBEAT))
}
onWsOpen () {
this.sendAuth()
refreshReceiveTimeoutTimer() {
if (this.receiveTimeoutTimerId) {
window.clearTimeout(this.receiveTimeoutTimerId)
}
this.receiveTimeoutTimerId = window.setTimeout(this.onReceiveTimeout.bind(this), RECEIVE_TIMEOUT)
}
onReceiveTimeout() {
window.console.warn('接收消息超时')
this.receiveTimeoutTimerId = null
// 直接丢弃阻塞的websocket不等onclose回调了
this.websocket.onopen = this.websocket.onclose = this.websocket.onmessage = null
this.websocket.close()
this.onWsClose()
}
onWsClose () {
@@ -137,19 +159,26 @@ export default class ChatClientDirect {
window.clearInterval(this.heartbeatTimerId)
this.heartbeatTimerId = null
}
if (this.receiveTimeoutTimerId) {
window.clearTimeout(this.receiveTimeoutTimerId)
this.receiveTimeoutTimerId = null
}
if (this.isDestroying) {
return
}
window.console.log(`掉线重连中${++this.retryCount}`)
window.console.warn(`掉线重连中${++this.retryCount}`)
window.setTimeout(this.wsConnect.bind(this), 1000)
}
onWsMessage (event) {
this.refreshReceiveTimeoutTimer()
this.retryCount = 0
if (!(event.data instanceof ArrayBuffer)) {
window.console.warn('未知的websocket消息', event.data)
return
}
let data = new Uint8Array(event.data)
this.handlerMessage(data)
}

View File

@@ -7,6 +7,9 @@ const COMMAND_ADD_SUPER_CHAT = 5
const COMMAND_DEL_SUPER_CHAT = 6
const COMMAND_UPDATE_TRANSLATION = 7
const HEARTBEAT_INTERVAL = 10 * 1000
const RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + 5 * 1000
export default class ChatClientRelay {
constructor (roomId, autoTranslate) {
this.roomId = roomId
@@ -23,6 +26,7 @@ export default class ChatClientRelay {
this.retryCount = 0
this.isDestroying = false
this.heartbeatTimerId = null
this.receiveTimeoutTimerId = null
}
start () {
@@ -48,13 +52,6 @@ export default class ChatClientRelay {
this.websocket.onopen = this.onWsOpen.bind(this)
this.websocket.onclose = this.onWsClose.bind(this)
this.websocket.onmessage = this.onWsMessage.bind(this)
this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), 10 * 1000)
}
sendHeartbeat () {
this.websocket.send(JSON.stringify({
cmd: COMMAND_HEARTBEAT
}))
}
onWsOpen () {
@@ -68,6 +65,31 @@ export default class ChatClientRelay {
}
}
}))
this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), HEARTBEAT_INTERVAL)
this.refreshReceiveTimeoutTimer()
}
sendHeartbeat () {
this.websocket.send(JSON.stringify({
cmd: COMMAND_HEARTBEAT
}))
}
refreshReceiveTimeoutTimer() {
if (this.receiveTimeoutTimerId) {
window.clearTimeout(this.receiveTimeoutTimerId)
}
this.receiveTimeoutTimerId = window.setTimeout(this.onReceiveTimeout.bind(this), RECEIVE_TIMEOUT)
}
onReceiveTimeout() {
window.console.warn('接收消息超时')
this.receiveTimeoutTimerId = null
// 直接丢弃阻塞的websocket不等onclose回调了
this.websocket.onopen = this.websocket.onclose = this.websocket.onmessage = null
this.websocket.close()
this.onWsClose()
}
onWsClose () {
@@ -76,16 +98,26 @@ export default class ChatClientRelay {
window.clearInterval(this.heartbeatTimerId)
this.heartbeatTimerId = null
}
if (this.receiveTimeoutTimerId) {
window.clearTimeout(this.receiveTimeoutTimerId)
this.receiveTimeoutTimerId = null
}
if (this.isDestroying) {
return
}
window.console.log(`掉线重连中${++this.retryCount}`)
window.console.warn(`掉线重连中${++this.retryCount}`)
window.setTimeout(this.wsConnect.bind(this), 1000)
}
onWsMessage (event) {
this.refreshReceiveTimeoutTimer()
let {cmd, data} = JSON.parse(event.data)
switch (cmd) {
case COMMAND_HEARTBEAT: {
break
}
case COMMAND_ADD_TEXT: {
if (!this.onAddText) {
break

View File

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

View File

@@ -11,8 +11,8 @@ export const DEFAULT_CONFIG = {
blockGiftDanmaku: true,
blockLevel: 0,
blockNewbie: true,
blockNotMobileVerified: true,
blockNewbie: false,
blockNotMobileVerified: false,
blockKeywords: '',
blockUsers: '',
blockMedalLevel: 0,
@@ -28,8 +28,9 @@ export function setLocalConfig (config) {
}
export function getLocalConfig () {
if (!window.localStorage.config) {
return DEFAULT_CONFIG
try {
return mergeConfig(JSON.parse(window.localStorage.config), DEFAULT_CONFIG)
} catch {
return {...DEFAULT_CONFIG}
}
return mergeConfig(JSON.parse(window.localStorage.config), DEFAULT_CONFIG)
}

View File

@@ -1,7 +1,9 @@
<template>
<yt-live-chat-ticker-renderer :hidden="showMessages.length === 0">
<div id="container" dir="ltr" class="style-scope yt-live-chat-ticker-renderer">
<div id="items" class="style-scope yt-live-chat-ticker-renderer">
<transition-group tag="div" :css="false" @enter="onTickerItemEnter" @leave="onTickerItemLeave"
id="items" class="style-scope yt-live-chat-ticker-renderer"
>
<yt-live-chat-ticker-paid-message-item-renderer v-for="message in showMessages" :key="message.raw.id"
tabindex="0" class="style-scope yt-live-chat-ticker-renderer" style="overflow: hidden;"
@click="onItemClick(message.raw)"
@@ -19,7 +21,7 @@
</div>
</div>
</yt-live-chat-ticker-paid-message-item-renderer>
</div>
</transition-group>
</div>
<template v-if="pinnedMessage">
<membership-item :key="pinnedMessage.id" v-if="pinnedMessage.type === MESSAGE_TYPE_MEMBER"
@@ -98,6 +100,32 @@ export default {
window.clearInterval(this.updateTimerId)
},
methods: {
async onTickerItemEnter(el, done) {
let width = el.clientWidth
if (width === 0) {
// CSS指定了不显示固定栏
done()
return
}
el.style.width = 0
await this.$nextTick()
el.style.width = `${width}px`
window.setTimeout(done, 200)
},
onTickerItemLeave(el, done) {
el.classList.add('sliding-down')
window.setTimeout(() => {
el.classList.add('collapsing')
el.style.width = 0
window.setTimeout(() => {
el.classList.remove('sliding-down')
el.classList.remove('collapsing')
el.style.width = 'auto'
done()
}, 200)
}, 200)
},
getShowAuthorName: constants.getShowAuthorName,
needToShow(message) {
let pinTime = this.getPinTime(message)

View File

@@ -137,7 +137,7 @@ export function getGiftShowContent (message, showGiftName) {
}
export function getShowAuthorName (message) {
if (message.authorNamePronunciation) {
if (message.authorNamePronunciation && message.authorNamePronunciation !== message.authorName) {
return `${message.authorName}(${message.authorNamePronunciation})`
}
return message.authorName

View File

@@ -47,7 +47,21 @@ import MembershipItem from './MembershipItem.vue'
import PaidMessage from './PaidMessage.vue'
import * as constants from './constants'
// 只有要添加的消息需要平滑
const NEED_SMOOTH_MESSAGE_TYPES = [
constants.MESSAGE_TYPE_TEXT,
constants.MESSAGE_TYPE_GIFT,
constants.MESSAGE_TYPE_MEMBER,
constants.MESSAGE_TYPE_SUPER_CHAT
]
// 发送消息时间间隔范围
const MESSAGE_MIN_INTERVAL = 80
const MESSAGE_MAX_INTERVAL = 1000
// 每次发送消息后增加的动画时间要比MESSAGE_MIN_INTERVAL稍微大一点太小了动画不连续太大了发送消息时会中断动画
// 84 = ceil((1000 / 60) * 5)
const CHAT_SMOOTH_ANIMATION_TIME_MS = 84
// 滚动条距离底部小于多少像素则认为在底部
const SCROLLED_TO_BOTTOM_EPSILON = 15
export default {
@@ -59,7 +73,6 @@ export default {
PaidMessage
},
props: {
css: String,
maxNumber: {
type: Number,
default: chatConfig.DEFAULT_CONFIG.maxNumber
@@ -70,15 +83,12 @@ export default {
}
},
data() {
let styleElement = document.createElement('style')
document.head.appendChild(styleElement)
return {
MESSAGE_TYPE_TEXT: constants.MESSAGE_TYPE_TEXT,
MESSAGE_TYPE_GIFT: constants.MESSAGE_TYPE_GIFT,
MESSAGE_TYPE_MEMBER: constants.MESSAGE_TYPE_MEMBER,
MESSAGE_TYPE_SUPER_CHAT: constants.MESSAGE_TYPE_SUPER_CHAT,
styleElement,
messages: [], // 显示的消息
paidMessages: [], // 固定在上方的消息
@@ -108,19 +118,14 @@ export default {
}
},
watch: {
css(val) {
this.styleElement.innerText = val
},
canScrollToBottom(val) {
this.cantScrollStartTime = val ? null : new Date()
}
},
mounted() {
this.styleElement.innerText = this.css
this.scrollToBottom()
},
beforeDestroy() {
document.head.removeChild(this.styleElement)
if (this.emitSmoothedMessageTimerId) {
window.clearTimeout(this.emitSmoothedMessageTimerId)
this.emitSmoothedMessageTimerId = null
@@ -239,36 +244,53 @@ export default {
},
enqueueMessages(messages) {
if (this.lastEnqueueTime) {
let interval = new Date() - this.lastEnqueueTime
// 理论上B站发包间隔1S如果不过滤间隔太短的会导致消息平滑失效
if (interval > 100) {
// 估计进队列时间间隔
if (!this.lastEnqueueTime) {
this.lastEnqueueTime = new Date()
} else {
let curTime = new Date()
let interval = curTime - this.lastEnqueueTime
// 让发消息速度变化不要太频繁
if (interval > 1000) {
this.enqueueIntervals.push(interval)
if (this.enqueueIntervals.length > 5) {
this.enqueueIntervals.splice(0, this.enqueueIntervals.length - 5)
}
this.estimatedEnqueueInterval = Math.max(...this.enqueueIntervals)
this.lastEnqueueTime = curTime
}
}
this.lastEnqueueTime = new Date()
// 只有要显示的消息需要平滑
// 把messages分成messageGroup每个组里最多有1个需要平滑的消息
let messageGroup = []
for (let message of messages) {
messageGroup.push(message)
if (message.type !== constants.MESSAGE_TYPE_DEL && message.type !== constants.MESSAGE_TYPE_UPDATE) {
if (this.messageNeedSmooth(message)) {
this.smoothedMessageQueue.push(messageGroup)
messageGroup = []
}
}
// 还剩下不需要平滑的消息
if (messageGroup.length > 0) {
if (this.smoothedMessageQueue.length > 0) {
// 和上一组合并
let lastMessageGroup = this.smoothedMessageQueue[this.smoothedMessageQueue.length - 1]
for (let message of messageGroup) {
lastMessageGroup.push(message)
}
} else {
// 自己一个组
this.smoothedMessageQueue.push(messageGroup)
}
}
if (!this.emitSmoothedMessageTimerId) {
this.emitSmoothedMessageTimerId = window.setTimeout(this.emitSmoothedMessages)
}
},
messageNeedSmooth({type}) {
return NEED_SMOOTH_MESSAGE_TYPES.indexOf(type) !== -1
},
emitSmoothedMessages() {
this.emitSmoothedMessageTimerId = null
if (this.smoothedMessageQueue.length <= 0) {
@@ -280,21 +302,18 @@ export default {
if (this.estimatedEnqueueInterval) {
estimatedNextEnqueueRemainTime = Math.max(this.lastEnqueueTime - new Date() + this.estimatedEnqueueInterval, 1)
}
// 最快80ms/条,计算发送的消息数,保证在下次进队列之前消费队列到最多剩3条消息不消费完是为了防止消息速度变慢时突然停顿
const MIN_SLEEP_TIME = 80
const MAX_SLEEP_TIME = 1000
const MAX_REMAIN_GROUP_NUM = 3
// 计算发送的消息数,保证在下次进队列之前发完
// 下次进队列之前应该发多少条消息
let shouldEmitGroupNum = Math.max(this.smoothedMessageQueue.length - MAX_REMAIN_GROUP_NUM, 0)
let shouldEmitGroupNum = Math.max(this.smoothedMessageQueue.length, 0)
// 下次进队列之前最多能发多少次
let maxCanEmitCount = estimatedNextEnqueueRemainTime / MIN_SLEEP_TIME
let maxCanEmitCount = estimatedNextEnqueueRemainTime / MESSAGE_MIN_INTERVAL
// 这次发多少条消息
let groupNumToEmit
if (shouldEmitGroupNum < maxCanEmitCount) {
// 队列中消息数很少每次发1条也能发到最多剩3条
// 队列中消息数很少每次发1条也能发
groupNumToEmit = 1
} else {
// 每次发1条以上保证按最快速度能发到最多剩3条
// 每次发1条以上保证按最快速度能发
groupNumToEmit = Math.ceil(shouldEmitGroupNum / maxCanEmitCount)
}
@@ -314,17 +333,17 @@ export default {
// 消息没发完,计算下次发消息时间
let sleepTime
if (groupNumToEmit === 1) {
// 队列中消息数很少,随便定个[MIN_SLEEP_TIME, MAX_SLEEP_TIME]的时间
// 队列中消息数很少,随便定个[MESSAGE_MIN_INTERVAL, MESSAGE_MAX_INTERVAL]的时间
sleepTime = estimatedNextEnqueueRemainTime / this.smoothedMessageQueue.length
sleepTime *= 0.5 + Math.random()
if (sleepTime > MAX_SLEEP_TIME) {
sleepTime = MAX_SLEEP_TIME
} else if (sleepTime < MIN_SLEEP_TIME) {
sleepTime = MIN_SLEEP_TIME
if (sleepTime > MESSAGE_MAX_INTERVAL) {
sleepTime = MESSAGE_MAX_INTERVAL
} else if (sleepTime < MESSAGE_MIN_INTERVAL) {
sleepTime = MESSAGE_MIN_INTERVAL
}
} else {
// 按最快速度发
sleepTime = MIN_SLEEP_TIME
sleepTime = MESSAGE_MIN_INTERVAL
}
this.emitSmoothedMessageTimerId = window.setTimeout(this.emitSmoothedMessages, sleepTime)
},
@@ -351,7 +370,7 @@ export default {
}
}
this.maybeResizeScrollContainer(),
this.maybeResizeScrollContainer()
this.flushMessagesBuffer()
this.$nextTick(this.maybeScrollToBottom)
},
@@ -361,8 +380,13 @@ export default {
addTime: new Date() // 添加一个本地时间给Ticker用防止本地时间和服务器时间相差很大的情况
}
this.messagesBuffer.push(message)
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
this.paidMessages.unshift(message)
const MAX_PAID_MESSAGE_NUM = 100
if (this.paidMessages.length > MAX_PAID_MESSAGE_NUM) {
this.paidMessages.splice(MAX_PAID_MESSAGE_NUM, this.paidMessages.length - MAX_PAID_MESSAGE_NUM)
}
}
},
handleDelMessage({id}) {
@@ -425,7 +449,8 @@ export default {
}
this.messagesBuffer = []
// 等items高度变化
this.$nextTick(this.showNewMessages)
await this.$nextTick()
this.showNewMessages()
},
showNewMessages() {
let hasScrollBar = this.$refs.items.clientHeight > this.$refs.scroller.clientHeight

View File

@@ -41,12 +41,19 @@ export default {
roomUrl: 'Room URL',
copy: 'Copy',
enterRoom: 'Enter room',
enterTestRoom: 'Enter test room',
exportConfig: 'Export config',
importConfig: 'Import config',
failedToParseConfig: 'Failed to parse config: '
},
stylegen: {
legacy: 'Classic',
lineLike: 'Line-like',
light: 'light',
dark: 'dark',
outlines: 'Outlines',
showOutlines: 'Show outlines',
outlineSize: 'Outline size',

View File

@@ -41,12 +41,19 @@ export default {
roomUrl: 'ルームのURL',
copy: 'コピー',
enterRoom: 'ルームに入る',
enterTestRoom: 'テストルームに入る',
exportConfig: 'コンフィグの導出',
importConfig: 'コンフィグの導入',
failedToParseConfig: 'コンフィグ解析に失敗しました'
},
stylegen: {
legacy: '古典',
lineLike: 'Line風',
light: '明るい',
dark: '暗い',
outlines: 'アウトライン',
showOutlines: 'アウトラインを表示する',
outlineSize: 'アウトラインのサイズ',

View File

@@ -41,12 +41,19 @@ export default {
roomUrl: '房间URL',
copy: '复制',
enterRoom: '进入房间',
enterTestRoom: '进入测试房间',
exportConfig: '导出配置',
importConfig: '导入配置',
failedToParseConfig: '配置解析失败:'
},
stylegen: {
legacy: '经典',
lineLike: '仿微信',
light: '明亮',
dark: '黑暗',
outlines: '描边',
showOutlines: '显示描边',
outlineSize: '描边尺寸',
@@ -77,7 +84,7 @@ export default {
backgrounds: '背景',
bgColor: '背景色',
useBarsInsteadOfBg: '用条代替背景',
useBarsInsteadOfBg: '用条代替消息背景',
messageBgColor: '消息背景色',
ownerMessageBgColor: '主播消息背景色',
moderatorMessageBgColor: '房管消息背景色',
@@ -104,7 +111,7 @@ export default {
animateIn: '进入动画',
fadeInTime: '淡入时间(毫秒)',
animateOut: '移除旧消息',
animateOutWaitTime: '等待时间(秒)',
animateOutWaitTime: '移除前等待时间(秒)',
fadeOutTime: '淡出时间(毫秒)',
slide: '滑动',
reverseSlide: '反向滑动',

View File

@@ -23,7 +23,7 @@
</a>
<a href="http://link.bilibili.com/ctool/vtuber" target="_blank">
<el-menu-item>
<i class="el-icon-share"></i>{{$t('sidebar.giftRecordOfficial')}}
<i class="el-icon-link"></i>{{$t('sidebar.giftRecordOfficial')}}
</el-menu-item>
</a>
<el-submenu index="null">

View File

@@ -9,7 +9,7 @@
</router-link>
</div>
<div class="version">
v1.5.1
v1.5.3
</div>
<sidebar></sidebar>
</el-aside>
@@ -53,7 +53,7 @@ export default {
<style>
html {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "\5FAE\8F6F\96C5\9ED1", "微软雅黑", Arial, sans-serif;
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "\5FAE \8F6F \96C5 \9ED1 ", "微软雅黑", Arial, sans-serif;
}
html, body, #app, .app-wrapper, .sidebar-container {
@@ -62,6 +62,7 @@ html, body, #app, .app-wrapper, .sidebar-container {
body {
margin: 0;
background-color: #f6f8fa;
}
a, a:focus, a:hover {

View File

@@ -2,9 +2,9 @@ import Vue from 'vue'
import VueRouter from 'vue-router'
import VueI18n from 'vue-i18n'
import {
Aside, Autocomplete, Badge, Button, Col, ColorPicker, Container, Divider, Form, FormItem, Image,
Input, Main, Menu, MenuItem, Message, Radio, RadioGroup, Row, Scrollbar, Slider, Submenu, Switch,
TabPane, Tabs, Tooltip
Aside, Autocomplete, Badge, Button, Card, Col, ColorPicker, Container, Divider, Form, FormItem, Image,
Input, Main, Menu, MenuItem, Message, Option, OptionGroup, Radio, RadioGroup, Row, Select, Scrollbar,
Slider, Submenu, Switch, TabPane, Tabs, Tooltip
} from 'element-ui'
import axios from 'axios'
@@ -24,6 +24,7 @@ if (process.env.NODE_ENV === 'development') {
// 开发时使用localhost:12450
axios.defaults.baseURL = 'http://localhost:12450'
}
axios.defaults.timeout = 10 * 1000
Vue.use(VueRouter)
Vue.use(VueI18n)
@@ -32,6 +33,7 @@ Vue.use(Aside)
Vue.use(Autocomplete)
Vue.use(Badge)
Vue.use(Button)
Vue.use(Card)
Vue.use(Col)
Vue.use(ColorPicker)
Vue.use(Container)
@@ -43,9 +45,12 @@ Vue.use(Input)
Vue.use(Main)
Vue.use(Menu)
Vue.use(MenuItem)
Vue.use(Option)
Vue.use(OptionGroup)
Vue.use(Radio)
Vue.use(RadioGroup)
Vue.use(Row)
Vue.use(Select)
Vue.use(Scrollbar)
Vue.use(Slider)
Vue.use(Submenu)
@@ -71,7 +76,19 @@ const router = new VueRouter({
{path: 'help', name: 'help', component: Help}
]
},
{path: '/room/:roomId', name: 'room', component: Room},
{path: '/room/test', name: 'test_room', component: Room, props: route => ({strConfig: route.query})},
{
path: '/room/:roomId',
name: 'room',
component: Room,
props(route) {
let roomId = parseInt(route.params.roomId)
if (isNaN(roomId)) {
roomId = null
}
return {roomId, strConfig: route.query}
}
},
{path: '*', component: NotFound}
]
})

View File

@@ -2,15 +2,15 @@
<div>
<h1>{{$t('help.help')}}</h1>
<p>{{$t('help.p1')}}</p>
<p><el-image src="/static/img/tutorial/tutorial-1.png"></el-image></p>
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-1.png"></el-image></p>
<p>{{$t('help.p2')}}</p>
<p><el-image src="/static/img/tutorial/tutorial-2.png"></el-image></p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-2.png"></el-image></p>
<p>{{$t('help.p3')}}</p>
<p><el-image src="/static/img/tutorial/tutorial-3.png"></el-image></p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-3.png"></el-image></p>
<p>{{$t('help.p4')}}</p>
<p><el-image src="/static/img/tutorial/tutorial-4.png"></el-image></p>
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-4.png"></el-image></p>
<p>{{$t('help.p5')}}</p>
<p><el-image src="/static/img/tutorial/tutorial-5.png"></el-image></p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-5.png"></el-image></p>
<p><br><br><br><br><br><br><br><br>--------------------------------------------------------------------------------------------------------</p>
<p>喜欢的话可以推荐给别人专栏求支持_(:з)_ <a href="https://www.bilibili.com/read/cv4594365" target="_blank">https://www.bilibili.com/read/cv4594365</a></p>
</div>
@@ -21,3 +21,13 @@ export default {
name: 'Help'
}
</script>
<style scoped>
.img-container {
text-align: center;
}
.img-container.large-img .el-image {
height: 80vh;
}
</style>

View File

@@ -1,90 +1,140 @@
<template>
<el-form :model="form" ref="form" label-width="150px" :rules="{
roomId: [
{required: true, message: $t('home.roomIdEmpty'), trigger: 'blur'},
{type: 'integer', min: 1, message: $t('home.roomIdInteger'), trigger: 'blur'}
]
}">
<el-tabs>
<el-tab-pane :label="$t('home.general')">
<el-form-item :label="$t('home.roomId')" required prop="roomId">
<el-input v-model.number="form.roomId" type="number" min="1"></el-input>
</el-form-item>
<el-form-item :label="$t('home.showDanmaku')">
<el-switch v-model="form.showDanmaku"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.showGift')">
<el-switch v-model="form.showGift"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.showGiftName')">
<el-switch v-model="form.showGiftName"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.mergeSimilarDanmaku')">
<el-switch v-model="form.mergeSimilarDanmaku"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.mergeGift')">
<el-switch v-model="form.mergeGift"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.minGiftPrice')">
<el-input v-model.number="form.minGiftPrice" type="number" min="0"></el-input>
</el-form-item>
<el-form-item :label="$t('home.maxNumber')">
<el-input v-model.number="form.maxNumber" type="number" min="1"></el-input>
</el-form-item>
</el-tab-pane>
<div>
<p>
<el-form :model="form" ref="form" label-width="150px" :rules="{
roomId: [
{required: true, message: $t('home.roomIdEmpty'), trigger: 'blur'},
{type: 'integer', min: 1, message: $t('home.roomIdInteger'), trigger: 'blur'}
]
}">
<el-tabs type="border-card">
<el-tab-pane :label="$t('home.general')">
<el-form-item :label="$t('home.roomId')" required prop="roomId">
<el-input v-model.number="form.roomId" type="number" min="1"></el-input>
</el-form-item>
<el-row :gutter="20">
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.showDanmaku')">
<el-switch v-model="form.showDanmaku"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.showGift')">
<el-switch v-model="form.showGift"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.showGiftName')">
<el-switch v-model="form.showGiftName"></el-switch>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.mergeSimilarDanmaku')">
<el-switch v-model="form.mergeSimilarDanmaku"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.mergeGift')">
<el-switch v-model="form.mergeGift"></el-switch>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.minGiftPrice')">
<el-input v-model.number="form.minGiftPrice" type="number" min="0"></el-input>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.maxNumber')">
<el-input v-model.number="form.maxNumber" type="number" min="1"></el-input>
</el-form-item>
</el-col>
</el-row>
</el-tab-pane>
<el-tab-pane :label="$t('home.block')">
<el-form-item :label="$t('home.giftDanmaku')">
<el-switch v-model="form.blockGiftDanmaku"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.blockLevel')">
<el-slider v-model="form.blockLevel" show-input :min="0" :max="60"></el-slider>
</el-form-item>
<el-form-item :label="$t('home.informalUser')">
<el-switch v-model="form.blockNewbie"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.unverifiedUser')">
<el-switch v-model="form.blockNotMobileVerified"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.blockKeywords')">
<el-input v-model="form.blockKeywords" type="textarea" :rows="5" :placeholder="$t('home.onePerLine')"></el-input>
</el-form-item>
<el-form-item :label="$t('home.blockUsers')">
<el-input v-model="form.blockUsers" type="textarea" :rows="5" :placeholder="$t('home.onePerLine')"></el-input>
</el-form-item>
<el-form-item :label="$t('home.blockMedalLevel')">
<el-slider v-model="form.blockMedalLevel" show-input :min="0" :max="40"></el-slider>
</el-form-item>
</el-tab-pane>
<el-tab-pane :label="$t('home.block')">
<el-row :gutter="20">
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.giftDanmaku')">
<el-switch v-model="form.blockGiftDanmaku"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.informalUser')">
<el-switch v-model="form.blockNewbie"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.unverifiedUser')">
<el-switch v-model="form.blockNotMobileVerified"></el-switch>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('home.blockLevel')">
<el-slider v-model="form.blockLevel" show-input :min="0" :max="60"></el-slider>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item :label="$t('home.blockMedalLevel')">
<el-slider v-model="form.blockMedalLevel" show-input :min="0" :max="40"></el-slider>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="$t('home.blockKeywords')">
<el-input v-model="form.blockKeywords" type="textarea" :rows="5" :placeholder="$t('home.onePerLine')"></el-input>
</el-form-item>
<el-form-item :label="$t('home.blockUsers')">
<el-input v-model="form.blockUsers" type="textarea" :rows="5" :placeholder="$t('home.onePerLine')"></el-input>
</el-form-item>
</el-tab-pane>
<el-tab-pane :label="$t('home.advanced')">
<el-form-item :label="$t('home.relayMessagesByServer')">
<el-switch v-model="form.relayMessagesByServer"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.autoTranslate')">
<el-switch v-model="form.autoTranslate" :disabled="!serverConfig.enableTranslate || !form.relayMessagesByServer"></el-switch>
</el-form-item>
<el-form-item :label="$t('home.giftUsernamePronunciation')">
<el-radio-group v-model="form.giftUsernamePronunciation">
<el-radio label="">{{$t('home.dontShow')}}</el-radio>
<el-radio label="pinyin">{{$t('home.pinyin')}}</el-radio>
<el-radio label="kana">{{$t('home.kana')}}</el-radio>
</el-radio-group>
</el-form-item>
</el-tab-pane>
</el-tabs>
<el-tab-pane :label="$t('home.advanced')">
<el-row :gutter="20">
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.relayMessagesByServer')">
<el-switch v-model="form.relayMessagesByServer"></el-switch>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.autoTranslate')">
<el-switch v-model="form.autoTranslate" :disabled="!serverConfig.enableTranslate || !form.relayMessagesByServer"></el-switch>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="$t('home.giftUsernamePronunciation')">
<el-radio-group v-model="form.giftUsernamePronunciation">
<el-radio label="">{{$t('home.dontShow')}}</el-radio>
<el-radio label="pinyin">{{$t('home.pinyin')}}</el-radio>
<el-radio label="kana">{{$t('home.kana')}}</el-radio>
</el-radio-group>
</el-form-item>
</el-tab-pane>
</el-tabs>
</el-form>
</p>
<el-divider></el-divider>
<el-form-item :label="$t('home.roomUrl')">
<el-input ref="roomUrlInput" readonly :value="obsRoomUrl" style="width: calc(100% - 8em); margin-right: 1em;"></el-input>
<el-button type="primary" @click="copyUrl">{{$t('home.copy')}}</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" :disabled="!roomUrl" @click="enterRoom">{{$t('home.enterRoom')}}</el-button>
<el-button type="primary" @click="exportConfig">{{$t('home.exportConfig')}}</el-button>
<el-button type="primary" @click="importConfig">{{$t('home.importConfig')}}</el-button>
</el-form-item>
</el-form>
<p>
<el-card>
<el-form :model="form" label-width="150px">
<el-form-item :label="$t('home.roomUrl')">
<el-input ref="roomUrlInput" readonly :value="obsRoomUrl" style="width: calc(100% - 8em); margin-right: 1em;"></el-input>
<el-button type="primary" @click="copyUrl">{{$t('home.copy')}}</el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" :disabled="!roomUrl" @click="enterRoom">{{$t('home.enterRoom')}}</el-button>
<el-button :disabled="!roomUrl" @click="enterTestRoom">{{$t('home.enterTestRoom')}}</el-button>
<el-button @click="exportConfig">{{$t('home.exportConfig')}}</el-button>
<el-button @click="importConfig">{{$t('home.importConfig')}}</el-button>
</el-form-item>
</el-form>
</el-card>
</p>
</div>
</template>
<script>
@@ -111,13 +161,7 @@ export default {
},
computed: {
roomUrl() {
if (this.form.roomId === '') {
return ''
}
let query = {...this.form}
delete query.roomId
let resolved = this.$router.resolve({name: 'room', params: {roomId: this.form.roomId}, query})
return `${window.location.protocol}//${window.location.host}${resolved.href}`
return this.getRoomUrl(false)
},
obsRoomUrl() {
if (this.roomUrl === '') {
@@ -151,6 +195,23 @@ export default {
enterRoom() {
window.open(this.roomUrl, `room ${this.form.roomId}`, 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
},
enterTestRoom() {
window.open(this.getRoomUrl(true), 'test room', 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
},
getRoomUrl(isTestRoom) {
if (isTestRoom && this.form.roomId === '') {
return ''
}
let query = {...this.form}
delete query.roomId
let resolved
if (isTestRoom) {
resolved = this.$router.resolve({name: 'test_room', query})
} else {
resolved = this.$router.resolve({name: 'room', params: {roomId: this.form.roomId}, query})
}
return `${window.location.protocol}//${window.location.host}${resolved.href}`
},
copyUrl() {
this.$refs.roomUrlInput.select()
document.execCommand('Copy')
@@ -183,9 +244,3 @@ export default {
}
}
</script>
<style scoped>
.el-form {
max-width: 800px;
}
</style>

View File

@@ -6,6 +6,7 @@
import {mergeConfig, toBool, toInt} from '@/utils'
import * as pronunciation from '@/utils/pronunciation'
import * as chatConfig from '@/api/chatConfig'
import ChatClientTest from '@/api/chat/ChatClientTest'
import ChatClientDirect from '@/api/chat/ChatClientDirect'
import ChatClientRelay from '@/api/chat/ChatClientRelay'
import ChatRenderer from '@/components/ChatRenderer'
@@ -16,6 +17,16 @@ export default {
components: {
ChatRenderer
},
props: {
roomId: {
type: Number,
default: null
},
strConfig: {
type: Object,
default: () => ({})
}
},
data() {
return {
config: {...chatConfig.DEFAULT_CONFIG},
@@ -54,9 +65,9 @@ export default {
initConfig() {
let cfg = {}
// 留空的使用默认值
for (let i in this.$route.query) {
if (this.$route.query[i] !== '') {
cfg[i] = this.$route.query[i]
for (let i in this.strConfig) {
if (this.strConfig[i] !== '') {
cfg[i] = this.strConfig[i]
}
}
cfg = mergeConfig(cfg, chatConfig.DEFAULT_CONFIG)
@@ -79,11 +90,14 @@ export default {
this.config = cfg
},
initChatClient() {
let roomId = parseInt(this.$route.params.roomId)
if (!this.config.relayMessagesByServer) {
this.chatClient = new ChatClientDirect(roomId)
if (this.roomId === null) {
this.chatClient = new ChatClientTest()
} else {
this.chatClient = new ChatClientRelay(roomId, this.config.autoTranslate)
if (!this.config.relayMessagesByServer) {
this.chatClient = new ChatClientDirect(this.roomId)
} else {
this.chatClient = new ChatClientRelay(this.roomId, this.config.autoTranslate)
}
}
this.chatClient.onAddText = this.onAddText
this.chatClient.onAddGift = this.onAddGift
@@ -94,6 +108,13 @@ export default {
this.chatClient.start()
},
start() {
this.chatClient.start()
},
stop() {
this.chatClient.stop()
},
onAddText(data) {
if (!this.config.showDanmaku || !this.filterTextMessage(data) || this.mergeSimilarText(data.content)) {
return

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -60,6 +60,7 @@ def init_logging(debug):
file_handler = logging.handlers.TimedRotatingFileHandler(
LOG_FILE_NAME, encoding='utf-8', when='midnight', backupCount=7, delay=True
)
# noinspection PyArgumentList
logging.basicConfig(
format='{asctime} {levelname} [{name}]: {message}',
datefmt='%Y-%m-%d %H:%M:%S',

View File

@@ -10,6 +10,7 @@ import aiohttp
import sqlalchemy
import sqlalchemy.exc
import config
import models.database
logger = logging.getLogger(__name__)
@@ -18,18 +19,21 @@ logger = logging.getLogger(__name__)
DEFAULT_AVATAR_URL = '//static.hdslb.com/images/member/noface.gif'
_main_event_loop = asyncio.get_event_loop()
_http_session = aiohttp.ClientSession()
_http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
# user_id -> avatar_url
_avatar_url_cache: Dict[int, str] = {}
# 正在获取头像的Futureuser_id -> Future
_uid_fetch_future_map: Dict[int, asyncio.Future] = {}
# 正在获取头像的user_id队列
_uid_queue_to_fetch = asyncio.Queue(15)
_uid_queue_to_fetch: Optional[asyncio.Queue] = None
# 上次被B站ban时间
_last_fetch_banned_time: Optional[datetime.datetime] = None
def init():
cfg = config.get_config()
global _uid_queue_to_fetch
_uid_queue_to_fetch = asyncio.Queue(cfg.fetch_avatar_max_queue_size)
asyncio.ensure_future(_get_avatar_url_from_web_consumer())
@@ -124,7 +128,8 @@ async def _get_avatar_url_from_web_consumer():
asyncio.ensure_future(_get_avatar_url_from_web_coroutine(user_id, future))
# 限制频率防止被B站ban
await asyncio.sleep(0.2)
cfg = config.get_config()
await asyncio.sleep(cfg.fetch_avatar_interval)
except Exception:
logger.exception('_get_avatar_url_from_web_consumer error:')
@@ -178,7 +183,8 @@ def update_avatar_cache(user_id, avatar_url):
def _update_avatar_cache_in_memory(user_id, avatar_url):
_avatar_url_cache[user_id] = avatar_url
while len(_avatar_url_cache) > 50000:
cfg = config.get_config()
while len(_avatar_url_cache) > cfg.avatar_cache_size:
_avatar_url_cache.pop(next(iter(_avatar_url_cache)), None)

View File

@@ -13,7 +13,7 @@ engine = None
DbSession: Optional[Type[sqlalchemy.orm.Session]] = None
def init(debug):
def init(_debug):
cfg = config.get_config()
global engine, DbSession
# engine = sqlalchemy.create_engine(cfg.database_url, echo=debug)
@@ -28,7 +28,7 @@ def get_session():
session = DbSession()
try:
yield session
except:
except BaseException:
session.rollback()
raise
finally:

View File

@@ -1,18 +1,23 @@
# -*- coding: utf-8 -*-
import asyncio
import base64
import datetime
import functools
import hashlib
import hmac
import json
import logging
import random
import re
import time
import yarl
from typing import *
import Crypto.Cipher.AES as cry_aes
import Crypto.Util.Padding as cry_pad
import aiohttp
import config
logger = logging.getLogger(__name__)
NO_TRANSLATE_TEXTS = {
@@ -21,7 +26,7 @@ NO_TRANSLATE_TEXTS = {
}
_main_event_loop = asyncio.get_event_loop()
_http_session = aiohttp.ClientSession()
_http_session: Optional[aiohttp.ClientSession] = None
_translate_providers: List['TranslateProvider'] = []
# text -> res
_translate_cache: Dict[str, str] = {}
@@ -34,17 +39,45 @@ def init():
async def _do_init():
# 考虑优先级
providers = [
TencentTranslate(),
YoudaoTranslate(),
BilibiliTranslate()
]
global _http_session
_http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
cfg = config.get_config()
if not cfg.enable_translate:
return
providers = []
for trans_cfg in cfg.translator_configs:
provider = create_translate_provider(trans_cfg)
if provider is not None:
providers.append(provider)
await asyncio.gather(*(provider.init() for provider in providers))
global _translate_providers
_translate_providers = providers
def create_translate_provider(cfg):
type_ = cfg['type']
if type_ == 'TencentTranslateFree':
return TencentTranslateFree(
cfg['query_interval'], cfg['max_queue_size'], cfg['source_language'],
cfg['target_language']
)
elif type_ == 'BilibiliTranslateFree':
return BilibiliTranslateFree(cfg['query_interval'], cfg['max_queue_size'])
elif type_ == 'TencentTranslate':
return TencentTranslate(
cfg['query_interval'], cfg['max_queue_size'], cfg['source_language'],
cfg['target_language'], cfg['secret_id'], cfg['secret_key'],
cfg['region']
)
elif type_ == 'BaiduTranslate':
return BaiduTranslate(
cfg['query_interval'], cfg['max_queue_size'], cfg['source_language'],
cfg['target_language'], cfg['app_id'], cfg['secret']
)
return None
def need_translate(text):
text = text.strip()
# 没有中文,平时打不出的字不管
@@ -54,7 +87,7 @@ def need_translate(text):
if any(0x3040 <= ord(c) <= 0x30FF for c in text):
return False
# 弹幕同传
if text.startswith(''):
if '' in text:
return False
# 中日双语
if text in NO_TRANSLATE_TEXTS:
@@ -82,14 +115,25 @@ def translate(text) -> Awaitable[Optional[str]]:
future.set_result(res)
return future
# 负载均衡找等待时间最少的provider
min_wait_time = None
min_wait_time_provider = None
for provider in _translate_providers:
if provider.is_available:
_text_future_map[key] = future
future.add_done_callback(functools.partial(_on_translate_done, key))
provider.translate(text, future)
return future
if not provider.is_available:
continue
wait_time = provider.wait_time
if min_wait_time is None or wait_time < min_wait_time:
min_wait_time = wait_time
min_wait_time_provider = provider
future.set_result(None)
# 没有可用的
if min_wait_time_provider is None:
future.set_result(None)
return future
_text_future_map[key] = future
future.add_done_callback(functools.partial(_on_translate_done, key))
min_wait_time_provider.translate(text, future)
return future
@@ -103,7 +147,8 @@ def _on_translate_done(key, future):
if res is None:
return
_translate_cache[key] = res
while len(_translate_cache) > 50000:
cfg = config.get_config()
while len(_translate_cache) > cfg.translation_cache_size:
_translate_cache.pop(next(iter(_translate_cache)), None)
@@ -115,57 +160,147 @@ class TranslateProvider:
def is_available(self):
return True
@property
def wait_time(self):
return 0
def translate(self, text, future):
raise NotImplementedError
class TencentTranslate(TranslateProvider):
def __init__(self):
# 过期时间1小时
self._qtv = ''
self._qtk = ''
self._reinit_future = None
# 连续失败的次数
self._fail_count = 0
self._cool_down_future = None
class FlowControlTranslateProvider(TranslateProvider):
def __init__(self, query_interval, max_queue_size):
self._query_interval = query_interval
# (text, future)
self._text_queue = asyncio.Queue(max_queue_size)
async def init(self):
asyncio.ensure_future(self._translate_consumer())
return True
@property
def is_available(self):
return not self._text_queue.full()
@property
def wait_time(self):
return self._text_queue.qsize() * self._query_interval
def translate(self, text, future):
try:
self._text_queue.put_nowait((text, future))
except asyncio.QueueFull:
future.set_result(None)
async def _translate_consumer(self):
while True:
try:
text, future = await self._text_queue.get()
asyncio.ensure_future(self._translate_coroutine(text, future))
# 频率限制
await asyncio.sleep(self._query_interval)
except Exception:
logger.exception('FlowControlTranslateProvider error:')
async def _translate_coroutine(self, text, future):
try:
res = await self._do_translate(text)
except BaseException as e:
future.set_exception(e)
else:
future.set_result(res)
async def _do_translate(self, text):
raise NotImplementedError
class TencentTranslateFree(FlowControlTranslateProvider):
def __init__(self, query_interval, max_queue_size, source_language, target_language):
super().__init__(query_interval, max_queue_size)
self._source_language = source_language
self._target_language = target_language
self._server_time_delta = 0
self._uc_key = self._uc_iv = ''
self._qtv = self._qtk = ''
self._reinit_future = None
# 连续失败的次数
self._fail_count = 0
async def init(self):
if not await super().init():
return False
if not await self._do_init():
return False
self._reinit_future = asyncio.ensure_future(self._reinit_coroutine())
return await self._do_init()
return True
async def _do_init(self):
try:
async with _http_session.get('https://fanyi.qq.com/') as r:
if r.status != 200:
logger.warning('TencentTranslate init request failed: status=%d %s', r.status, r.reason)
logger.warning('TencentTranslateFree init request failed: status=%d %s', r.status, r.reason)
return False
html = await r.text()
m = re.search(r"""\breauthuri\s*=\s*['"](.+?)['"]""", html)
if m is None:
logger.exception('TencentTranslate init failed: reauthuri not found')
return False
reauthuri = m[1]
try:
server_time = r.headers['Date']
server_time = datetime.datetime.strptime(server_time, '%a, %d %b %Y %H:%M:%S GMT')
server_time = server_time.replace(tzinfo=datetime.timezone.utc).timestamp()
self._server_time_delta = int((datetime.datetime.now().timestamp() - server_time) * 1000)
except (KeyError, ValueError):
self._server_time_delta = 0
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
logger.exception('TencentTranslateFree init error:')
return False
# 获取token URL
m = re.search(r"""\breauthuri\s*=\s*['"](.+?)['"]""", html)
if m is None:
logger.exception('TencentTranslateFree init failed: reauthuri not found')
return False
reauthuri = m[1]
# 获取验证用的key、iv
m = re.search(r"""\s*=\s*['"]((?:\w+\|\w+-)+\w+\|\w+)['"]""", html)
if m is None:
logger.exception('TencentTranslateFree init failed: initial global variables not found')
return False
uc_key = None
uc_iv = None
for item in m[1].split('-'):
key, _, value = item.partition('|')
if key == 'a137':
uc_key = value
elif key == 'E74':
uc_iv = value
if uc_key is not None and uc_iv is not None:
break
# 获取token
try:
async with _http_session.post('https://fanyi.qq.com/api/' + reauthuri) as r:
if r.status != 200:
logger.warning('TencentTranslate init request failed: reauthuri=%s, status=%d %s',
logger.warning('TencentTranslateFree init request failed: reauthuri=%s, status=%d %s',
reauthuri, r.status, r.reason)
return False
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
logger.exception('TencentTranslate init error:')
logger.exception('TencentTranslateFree init error:')
return False
qtv = data.get('qtv', None)
if qtv is None:
logger.warning('TencentTranslate init failed: qtv not found')
logger.warning('TencentTranslateFree init failed: qtv not found')
return False
qtk = data.get('qtk', None)
if qtk is None:
logger.warning('TencentTranslate init failed: qtk not found')
logger.warning('TencentTranslateFree init failed: qtk not found')
return False
self._uc_key = uc_key
self._uc_iv = uc_iv
self._qtv = qtv
self._qtk = qtk
return True
@@ -174,23 +309,14 @@ class TencentTranslate(TranslateProvider):
try:
while True:
await asyncio.sleep(30)
while True:
logger.debug('TencentTranslate reinit')
try:
if await self._do_init():
break
except Exception:
logger.exception('TencentTranslate init error:')
await asyncio.sleep(3 * 60)
logger.debug('TencentTranslateFree reinit')
asyncio.ensure_future(self._do_init())
except asyncio.CancelledError:
pass
@property
def is_available(self):
return self._qtv != '' and self._qtk != ''
def translate(self, text, future):
asyncio.ensure_future(self._translate_coroutine(text, future))
return '' not in (self._uc_key, self._uc_iv, self._qtv, self._qtk) and super().is_available
async def _translate_coroutine(self, text, future):
try:
@@ -210,205 +336,96 @@ class TencentTranslate(TranslateProvider):
async with _http_session.post(
'https://fanyi.qq.com/api/translate',
headers={
'Referer': 'https://fanyi.qq.com/'
'Referer': 'https://fanyi.qq.com/',
'uc': self._get_uc()
},
data={
'source': 'zh',
'target': 'jp',
'source': self._source_language,
'target': self._target_language,
'sourceText': text,
'qtv': self._qtv,
'qtk': self._qtk
}
) as r:
if r.status != 200:
logger.warning('TencentTranslate request failed: status=%d %s', r.status, r.reason)
logger.warning('TencentTranslateFree request failed: status=%d %s', r.status, r.reason)
return None
self._update_uc_key(r)
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
if data['errCode'] != 0:
logger.warning('TencentTranslate failed: %d %s', data['errCode'], data['errMsg'])
logger.warning('TencentTranslateFree failed: %d %s', data['errCode'], data['errMsg'])
return None
res = ''.join(record['targetText'] for record in data['translate']['records'])
if res == '' and text.strip() != '':
# qtv、qtk过期
logger.warning('TencentTranslate result is empty %s', data)
logger.warning('TencentTranslateFree result is empty %s', data)
return None
return res
def _get_uc(self):
user_actions = self._gen_user_actions()
cur_timestamp = str(int(datetime.datetime.now().timestamp() * 1000))
server_time_delta = str(self._server_time_delta)
uc = '|'.join([user_actions, cur_timestamp, server_time_delta])
aes = cry_aes.new(self._uc_key.encode('utf-8'), cry_aes.MODE_CBC, self._uc_iv.encode('utf-8'))
uc = cry_pad.pad(uc.encode('utf-8'), aes.block_size, 'pkcs7')
uc = aes.encrypt(uc)
uc = base64.b64encode(uc).decode('utf-8')
return uc
@staticmethod
def _gen_user_actions():
# 1点击翻译2源输入框聚焦或失去焦点3点击源语言列表4点击交换语言5点击目标语言列表6源输入框输入、粘贴
user_actions = []
if random.randint(1, 5) == 1:
for i in range(random.randint(1, 2)):
user_actions.append('2')
user_actions.append('6')
for i in range(random.randint(0, 6)):
user_actions.append(random.choice('26'))
if random.randint(1, 5) == 1:
user_actions.append('1')
return ''.join(user_actions)
def _update_uc_key(self, r):
try:
hf_f = r.headers['f']
hf_ts = int(r.headers['ts'])
except (KeyError, ValueError):
return
cur_timestamp = int(datetime.datetime.now().timestamp() * 1000)
hf_f = base64.b64decode(hf_f.encode('utf-8')).decode('utf-8')
pos = int(hf_f[72: 72 + 4])
uc_key = hf_f[pos: pos + 16]
uc_iv = hf_f[pos + 16: pos + 16 + 16]
self._server_time_delta = cur_timestamp - hf_ts
self._uc_key = uc_key
self._uc_iv = uc_iv
def _on_fail(self):
self._fail_count += 1
# 目前没有测试出被ban的情况为了可靠性,连续失败20次时冷却重新init
if self._fail_count >= 20 and self._cool_down_future is None:
self._cool_down_future = asyncio.ensure_future(self._cool_down())
# 为了可靠性,连续失败10次时冷却直到下次重新init
if self._fail_count >= 10:
self._cool_down()
async def _cool_down(self):
logger.info('TencentTranslate is cooling down')
def _cool_down(self):
logger.info('TencentTranslateFree is cooling down')
# 下次_do_init后恢复
self._uc_key = self._uc_iv = ''
self._qtv = self._qtk = ''
try:
while True:
await asyncio.sleep(3 * 60)
logger.info('TencentTranslate reinit')
try:
if await self._do_init():
self._fail_count = 0
break
except Exception:
logger.exception('TencentTranslate init error:')
finally:
logger.info('TencentTranslate finished cooling down')
self._cool_down_future = None
self._fail_count = 0
class YoudaoTranslate(TranslateProvider):
def __init__(self):
self._has_init = False
self._cool_down_future = None
async def init(self):
# 获取cookie
try:
async with _http_session.get('http://fanyi.youdao.com/') as r:
if r.status >= 400:
logger.warning('YoudaoTranslate init request failed: status=%d %s', r.status, r.reason)
return False
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return False
cookies = _http_session.cookie_jar.filter_cookies(yarl.URL('http://fanyi.youdao.com/'))
res = 'JSESSIONID' in cookies and 'OUTFOX_SEARCH_USER_ID' in cookies
if res:
self._has_init = True
return res
@property
def is_available(self):
return self._has_init
def translate(self, text, future):
asyncio.ensure_future(self._translate_coroutine(text, future))
async def _translate_coroutine(self, text, future):
try:
res = await self._do_translate(text)
except BaseException as e:
future.set_exception(e)
else:
future.set_result(res)
class BilibiliTranslateFree(FlowControlTranslateProvider):
def __init__(self, query_interval, max_queue_size):
super().__init__(query_interval, max_queue_size)
async def _do_translate(self, text):
try:
async with _http_session.post(
'http://fanyi.youdao.com/translate_o?smartresult=dict&smartresult=rule',
headers={
'Referer': 'http://fanyi.youdao.com/'
},
data={
'i': text,
'from': 'zh-CHS',
'to': 'ja',
'smartresult': 'dict',
'client': 'fanyideskweb',
**self._generate_salt(text),
'doctype': 'json',
'version': '2.1',
'keyfrom': 'fanyi.web',
'action': 'FY_BY_REALTlME'
}
) as r:
if r.status != 200:
logger.warning('YoudaoTranslate request failed: status=%d %s', r.status, r.reason)
return None
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
except aiohttp.ContentTypeError:
# 被ban了
if self._cool_down_future is None:
self._cool_down_future = asyncio.ensure_future(self._cool_down())
return None
if data['errorCode'] != 0:
logger.warning('YoudaoTranslate failed: %d', data['errorCode'])
return None
res = []
for outer_result in data['translateResult']:
for inner_result in outer_result:
res.append(inner_result['tgt'])
return ''.join(res)
@staticmethod
def _generate_salt(text):
timestamp = int(time.time() * 1000)
salt = f'{timestamp}{random.randint(0, 9)}'
md5 = hashlib.md5()
md5.update(f'fanyideskweb{text}{salt}n%A-rKaT5fb[Gy?;N5@Tj'.encode())
sign = md5.hexdigest()
return {
'ts': timestamp,
'bv': '7bcd9ea3ff9b319782c2a557acee9179', # md5(navigator.appVersion)
'salt': salt,
'sign': sign
}
async def _cool_down(self):
logger.info('YoudaoTranslate is cooling down')
self._has_init = False
try:
while True:
await asyncio.sleep(3 * 60)
try:
is_success = await self.init()
except Exception:
logger.exception('YoudaoTranslate init error:')
continue
if is_success:
break
finally:
logger.info('YoudaoTranslate finished cooling down')
self._cool_down_future = None
# 目前B站后端是百度翻译
class BilibiliTranslate(TranslateProvider):
def __init__(self):
# 最长等待时间大约21秒(text, future)
self._text_queue = asyncio.Queue(7)
async def init(self):
asyncio.ensure_future(self._translate_consumer())
return True
@property
def is_available(self):
return not self._text_queue.full()
def translate(self, text, future):
try:
self._text_queue.put_nowait((text, future))
except asyncio.QueueFull:
future.set_result(None)
async def _translate_consumer(self):
while True:
try:
text, future = await self._text_queue.get()
asyncio.ensure_future(self._translate_coroutine(text, future))
# 频率限制一分钟20次
await asyncio.sleep(3.1)
except Exception:
logger.exception('BilibiliTranslate error:')
async def _translate_coroutine(self, text, future):
try:
res = await self._do_translate(text)
except BaseException as e:
future.set_exception(e)
else:
future.set_result(res)
@staticmethod
async def _do_translate(text):
try:
async with _http_session.get(
'https://api.live.bilibili.com/av/v1/SuperChat/messageTranslate',
@@ -421,12 +438,183 @@ class BilibiliTranslate(TranslateProvider):
}
) as r:
if r.status != 200:
logger.warning('BilibiliTranslate request failed: status=%d %s', r.status, r.reason)
logger.warning('BilibiliTranslateFree request failed: status=%d %s', r.status, r.reason)
return None
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
if data['code'] != 0:
logger.warning('BilibiliTranslate failed: %d %s', data['code'], data['msg'])
logger.warning('BilibiliTranslateFree failed: %d %s', data['code'], data['msg'])
return None
return data['data']['message_trans']
class TencentTranslate(FlowControlTranslateProvider):
def __init__(self, query_interval, max_queue_size, source_language, target_language,
secret_id, secret_key, region):
super().__init__(query_interval, max_queue_size)
self._source_language = source_language
self._target_language = target_language
self._secret_id = secret_id
self._secret_key = secret_key
self._region = region
self._cool_down_timer_handle = None
@property
def is_available(self):
return self._cool_down_timer_handle is None and super().is_available
async def _do_translate(self, text):
try:
async with self._request_tencent_cloud(
'TextTranslate',
'2018-03-21',
{
'SourceText': text,
'Source': self._source_language,
'Target': self._target_language,
'ProjectId': 0
}
) as r:
if r.status != 200:
logger.warning('TencentTranslate request failed: status=%d %s', r.status, r.reason)
return None
data = (await r.json())['Response']
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
error = data.get('Error', None)
if error is not None:
logger.warning('TencentTranslate failed: %s %s, RequestId=%s', error['Code'],
error['Message'], data['RequestId'])
self._on_fail(error['Code'])
return None
return data['TargetText']
def _request_tencent_cloud(self, action, version, body):
body_bytes = json.dumps(body).encode('utf-8')
canonical_headers = 'content-type:application/json; charset=utf-8\nhost:tmt.tencentcloudapi.com\n'
signed_headers = 'content-type;host'
hashed_request_payload = hashlib.sha256(body_bytes).hexdigest()
canonical_request = f'POST\n/\n\n{canonical_headers}\n{signed_headers}\n{hashed_request_payload}'
request_timestamp = int(datetime.datetime.now().timestamp())
date = datetime.datetime.utcfromtimestamp(request_timestamp).strftime('%Y-%m-%d')
credential_scope = f'{date}/tmt/tc3_request'
hashed_canonical_request = hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()
string_to_sign = f'TC3-HMAC-SHA256\n{request_timestamp}\n{credential_scope}\n{hashed_canonical_request}'
def sign(key, msg):
return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest()
secret_date = sign(('TC3' + self._secret_key).encode('utf-8'), date)
secret_service = sign(secret_date, 'tmt')
secret_signing = sign(secret_service, 'tc3_request')
signature = hmac.new(secret_signing, string_to_sign.encode('utf-8'), hashlib.sha256).hexdigest()
authorization = (
f'TC3-HMAC-SHA256 Credential={self._secret_id}/{credential_scope}, '
f'SignedHeaders={signed_headers}, Signature={signature}'
)
headers = {
'Authorization': authorization,
'Content-Type': 'application/json; charset=utf-8',
'X-TC-Action': action,
'X-TC-Version': version,
'X-TC-Timestamp': str(request_timestamp),
'X-TC-Region': self._region
}
return _http_session.post('https://tmt.tencentcloudapi.com/', headers=headers, data=body_bytes)
def _on_fail(self, code):
if self._cool_down_timer_handle is not None:
return
sleep_time = 0
if code == 'FailedOperation.NoFreeAmount':
# 下个月恢复免费额度
cur_time = datetime.datetime.now()
year = cur_time.year
month = cur_time.month + 1
if month > 12:
year += 1
month = 1
next_month_time = datetime.datetime(year, month, 1, minute=5)
sleep_time = (next_month_time - cur_time).total_seconds()
# Python 3.8之前不能超过一天
sleep_time = min(sleep_time, 24 * 60 * 60 - 1)
elif code in ('FailedOperation.ServiceIsolate', 'LimitExceeded'):
# 需要手动处理等5分钟
sleep_time = 5 * 60
if sleep_time != 0:
self._cool_down_timer_handle = asyncio.get_event_loop().call_later(
sleep_time, self._on_cool_down_timeout
)
def _on_cool_down_timeout(self):
self._cool_down_timer_handle = None
class BaiduTranslate(FlowControlTranslateProvider):
def __init__(self, query_interval, max_queue_size, source_language, target_language,
app_id, secret):
super().__init__(query_interval, max_queue_size)
self._source_language = source_language
self._target_language = target_language
self._app_id = app_id
self._secret = secret
self._cool_down_timer_handle = None
@property
def is_available(self):
return self._cool_down_timer_handle is None and super().is_available
async def _do_translate(self, text):
try:
async with _http_session.post(
'https://fanyi-api.baidu.com/api/trans/vip/translate',
data=self._add_sign({
'q': text,
'from': self._source_language,
'to': self._target_language,
'appid': self._app_id,
'salt': random.randint(1, 999999999)
})
) as r:
if r.status != 200:
logger.warning('BaiduTranslate request failed: status=%d %s', r.status, r.reason)
return None
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
error_code = data.get('error_code', None)
if error_code is not None:
logger.warning('BaiduTranslate failed: %s %s', error_code, data['error_msg'])
self._on_fail(error_code)
return None
return ''.join(result['dst'] for result in data['trans_result'])
def _add_sign(self, data):
str_to_sign = f"{self._app_id}{data['q']}{data['salt']}{self._secret}"
sign = hashlib.md5(str_to_sign.encode('utf-8')).hexdigest()
return {**data, 'sign': sign}
def _on_fail(self, code):
if self._cool_down_timer_handle is not None:
return
sleep_time = 0
if code == '54004':
# 账户余额不足需要手动处理等5分钟
sleep_time = 5 * 60
if sleep_time != 0:
self._cool_down_timer_handle = asyncio.get_event_loop().call_later(
sleep_time, self._on_cool_down_timeout
)
def _on_cool_down_timeout(self):
self._cool_down_timer_handle = None

View File

@@ -1,3 +1,4 @@
aiohttp==3.5.4
aiohttp==3.7.4
pycryptodome==3.10.1
sqlalchemy==1.3.13
tornado==6.0.2

View File

@@ -4,7 +4,7 @@ import asyncio
import aiohttp
VERSION = 'v1.5.1'
VERSION = 'v1.5.3'
def check_update():
@@ -13,7 +13,7 @@ def check_update():
async def _do_check_update():
try:
async with aiohttp.ClientSession() as session:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
async with session.get('https://api.github.com/repos/xfgryujk/blivechat/releases/latest') as r:
data = await r.json()
if data['name'] != VERSION: