mirror of
https://github.com/xfgryujk/blivechat.git
synced 2025-02-07 10:10:19 +08:00
Merge branch 'dev'
This commit is contained in:
commit
659fddc4d5
@ -17,5 +17,5 @@ README.md
|
||||
|
||||
# runtime data
|
||||
data/*
|
||||
!data/config.ini
|
||||
!data/config.example.ini
|
||||
log/*
|
||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -105,5 +105,6 @@ venv.bak/
|
||||
|
||||
|
||||
.idea/
|
||||
data/database.db
|
||||
*.log*
|
||||
data/*
|
||||
!data/config.example.ini
|
||||
log/*
|
||||
|
@ -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 \
|
||||
|
78
api/chat.py
78
api/chat.py
@ -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
|
||||
#
|
||||
|
2
blivedm
2
blivedm
@ -1 +1 @@
|
||||
Subproject commit 8d8cc8c2706d62bbfa74cbc36f536b9717fe8f36
|
||||
Subproject commit 4669b2c1c9a1654db340d02ff16c9f88be661d9f
|
104
config.py
104
config.py
@ -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')
|
||||
|
||||
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
142
data/config.example.ini
Normal 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=10),49元/百万字符
|
||||
# * 尊享版每月前200万字符免费,超出后仅收取超出部分费用(QPS=100),49元/百万字符
|
||||
|
||||
# 类型:百度翻译
|
||||
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 =
|
@ -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
3
frontend/.gitignore
vendored
@ -19,3 +19,6 @@ yarn-error.log*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
|
||||
package-lock.json
|
||||
|
@ -1,6 +1,6 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/app'
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
],
|
||||
plugins: [
|
||||
[
|
||||
|
@ -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": {
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
211
frontend/src/api/chat/ChatClientTest.js
Normal file
211
frontend/src/api/chat/ChatClientTest.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -41,12 +41,19 @@ export default {
|
||||
roomUrl: 'ルームのURL',
|
||||
copy: 'コピー',
|
||||
enterRoom: 'ルームに入る',
|
||||
enterTestRoom: 'テストルームに入る',
|
||||
exportConfig: 'コンフィグの導出',
|
||||
importConfig: 'コンフィグの導入',
|
||||
|
||||
failedToParseConfig: 'コンフィグ解析に失敗しました'
|
||||
},
|
||||
stylegen: {
|
||||
legacy: '古典',
|
||||
lineLike: 'Line風',
|
||||
|
||||
light: '明るい',
|
||||
dark: '暗い',
|
||||
|
||||
outlines: 'アウトライン',
|
||||
showOutlines: 'アウトラインを表示する',
|
||||
outlineSize: 'アウトラインのサイズ',
|
||||
|
@ -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: '反向滑动',
|
||||
|
@ -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">
|
||||
|
@ -9,7 +9,7 @@
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="version">
|
||||
v1.5.1
|
||||
v1.5.2-beta
|
||||
</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 {
|
||||
|
@ -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}
|
||||
]
|
||||
})
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
33
frontend/src/views/StyleGenerator/FontSelect.vue
Normal file
33
frontend/src/views/StyleGenerator/FontSelect.vue
Normal 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>
|
689
frontend/src/views/StyleGenerator/Legacy.vue
Normal file
689
frontend/src/views/StyleGenerator/Legacy.vue
Normal 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>
|
598
frontend/src/views/StyleGenerator/LineLike.vue
Normal file
598
frontend/src/views/StyleGenerator/LineLike.vue
Normal 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>
|
162
frontend/src/views/StyleGenerator/common.js
Normal file
162
frontend/src/views/StyleGenerator/common.js
Normal 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
@ -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 {
|
||||
|
@ -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;
|
||||
}`
|
||||
}
|
@ -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] = {}
|
||||
# 正在获取头像的Future,user_id -> Future
|
||||
_uid_fetch_future_map: Dict[int, asyncio.Future] = {}
|
||||
# 正在获取头像的user_id队列
|
||||
_uid_queue_to_fetch = asyncio.Queue(15)
|
||||
_uid_queue_to_fetch = 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)
|
||||
|
||||
|
||||
|
@ -1,18 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
|
||||
import yarl
|
||||
from typing import *
|
||||
|
||||
import aiohttp
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NO_TRANSLATE_TEXTS = {
|
||||
@ -21,7 +23,7 @@ NO_TRANSLATE_TEXTS = {
|
||||
}
|
||||
|
||||
_main_event_loop = asyncio.get_event_loop()
|
||||
_http_session = aiohttp.ClientSession()
|
||||
_http_session = None
|
||||
_translate_providers: List['TranslateProvider'] = []
|
||||
# text -> res
|
||||
_translate_cache: Dict[str, str] = {}
|
||||
@ -34,17 +36,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 +84,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 +112,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 +144,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,55 +157,111 @@ 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小时
|
||||
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._qtv = ''
|
||||
self._qtk = ''
|
||||
self._reinit_future = None
|
||||
# 连续失败的次数
|
||||
self._fail_count = 0
|
||||
self._cool_down_future = None
|
||||
|
||||
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')
|
||||
logger.exception('TencentTranslateFree init failed: reauthuri not found')
|
||||
return False
|
||||
reauthuri = m[1]
|
||||
|
||||
async with _http_session.post('https://fanyi.qq.com/api/' + reauthuri) as r:
|
||||
if r.status != 200:
|
||||
logger.warning('TencentTranslate init request failed: reauthuri=%s, status=%d %s',
|
||||
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._qtv = qtv
|
||||
@ -174,23 +272,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 self._qtv != '' and self._qtk != '' and super().is_available
|
||||
|
||||
async def _translate_coroutine(self, text, future):
|
||||
try:
|
||||
@ -213,202 +302,47 @@ class TencentTranslate(TranslateProvider):
|
||||
'Referer': 'https://fanyi.qq.com/'
|
||||
},
|
||||
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
|
||||
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 _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())
|
||||
# 目前没有测试出被ban的情况,为了可靠性,连续失败20次时冷却直到下次重新init
|
||||
if self._fail_count >= 20:
|
||||
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._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 +355,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
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user