Compare commits

..

305 Commits

Author SHA1 Message Date
John Smith
b5c7554365 更新版本号v1.8.0 2023-09-10 10:40:19 +08:00
John Smith
c6272c908a 添加身份码相关的提示 2023-09-10 10:39:02 +08:00
John Smith
926ee879a6 修复打包后的报错 2023-09-10 09:48:12 +08:00
John Smith
a35d7a5bfc 移除自动缩小头像尺寸 2023-09-10 00:06:33 +08:00
John Smith
72c11d60da 移除从用户中心获取头像的接口 2023-09-10 00:03:29 +08:00
John Smith
a50461d4e6 添加重连次数太多的保险措施 2023-09-09 23:54:49 +08:00
John Smith
78828963ce 支持使用身份码时前端直连 2023-09-09 23:16:13 +08:00
John Smith
68f8ab3a92 前端支持使用身份码 2023-09-09 17:13:53 +08:00
John Smith
cad573312b 前端初始化房间时从后端接口获取文本表情 2023-09-08 23:54:47 +08:00
John Smith
3fb1c41845 前端直连回退到uid=0的版本,避免被限流 2023-09-08 23:49:35 +08:00
John Smith
6d07110927 添加获取文本表情的接口 2023-09-08 23:00:45 +08:00
John Smith
de0bac3119 添加开放平台接口的代理 2023-09-08 20:53:04 +08:00
John Smith
07569033fa 通过服务器转发时支持开放平台接口 2023-09-07 00:56:32 +08:00
John Smith
72841ab46e 优雅地停机 2023-09-06 21:34:04 +08:00
John Smith
fe141bc8fd 升级blivedm 2023-09-03 22:53:15 +08:00
John Smith
f5672c3e9c 修复前端样式生成器不显示文本消息 2023-08-31 23:45:03 +08:00
John Smith
b57bac3765 更新版本号v1.7.0 2023-08-31 19:16:00 +08:00
John Smith
7c2c48273c 纯表情消息不需要翻译 2023-08-28 19:36:33 +08:00
John Smith
5bdf2e41df 修复Python 3.11 asyncio.wait报错
close #120
2023-08-28 19:28:52 +08:00
John Smith
3d9095536d 支持官方的文本表情 2023-08-27 00:23:52 +08:00
John Smith
c3db0a41b3 添加host、port配置项 2023-08-26 17:01:19 +08:00
John Smith
60b9f81fe2 减少判断心跳超时的开销 2023-08-26 16:42:01 +08:00
John Smith
00c47a3e55 调整一些头像参数 2023-08-26 16:21:35 +08:00
John Smith
93e374ac12 添加其他头像接口 2023-08-24 23:07:17 +08:00
John Smith
dac531920e 添加通过勋章信息获取头像的接口
# Conflicts:
#	services/avatar.py

# Conflicts:
#	services/avatar.py
2023-08-24 22:29:34 +08:00
John Smith
c3566823c1 获取头像接口封装成类 2023-08-24 21:52:51 +08:00
John Smith
17b4985632 优化消息平滑算法 2023-08-06 13:50:27 +08:00
John Smith
4314bb61e9 升级Vue到2.7、前端VSCode插件改用Volar 2023-08-06 10:46:37 +08:00
John Smith
bea8711353 翻译区分优先级,高优先级的可重试 2023-08-05 21:27:40 +08:00
John Smith
8fa63f1d75 优化一些参数 2023-07-31 23:32:53 +08:00
John Smith
8e7d9b266b 使用LRU缓存、头像优先使用Protobuf协议中的、优化获取头像代码 2023-07-30 22:22:47 +08:00
John Smith
aea6f12bd2 添加启动时打开浏览器配置项 2023-07-30 15:29:40 +08:00
John Smith
ecb4a08e56 修复浏览器缓存导致前端没更新的问题 2023-07-29 23:05:04 +08:00
John Smith
6d5232d7d8 升级前端依赖 2023-07-29 18:34:02 +08:00
John Smith
63f541b87e 使用Python 3.8的asyncio特性 2023-07-29 12:48:57 +08:00
John Smith
e478959a1b 修改一个命名 2023-07-29 11:52:09 +08:00
John Smith
57ec8495e1 修复aiohttp的一些警告 2023-07-29 01:22:21 +08:00
John Smith
d5e8054e12 升级SQLAlchemy到2.0.19 2023-07-29 01:07:04 +08:00
John Smith
13b3e54f2b 升级blivedm 2023-07-28 00:45:24 +08:00
John Smith
f8520909f4 Merge pull request #116 from xfgryujk/dependabot/pip/aiohttp-3.8.5
Bump aiohttp from 3.7.4 to 3.8.5
2023-07-28 00:25:42 +08:00
dependabot[bot]
92cead6ff1 Bump aiohttp from 3.7.4 to 3.8.5
Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.7.4 to 3.8.5.
- [Release notes](https://github.com/aio-libs/aiohttp/releases)
- [Changelog](https://github.com/aio-libs/aiohttp/blob/v3.8.5/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.7.4...v3.8.5)

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

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

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

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

    Update services/avatar.py

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

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

    fix: syntax error

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-02-26 02:29:46 +00:00
John Smith
845e144992 修复了样式生成器中切换SC固定栏后不显示的问题 2021-02-14 14:31:53 +08:00
John Smith
c19e7692d4 添加SC固定栏过渡 2021-02-12 17:25:49 +08:00
John Smith
b395886535 添加接收消息超时 2021-02-01 23:49:31 +08:00
John Smith
ddc13a6327 一些小优化 2021-01-28 23:50:12 +08:00
John Smith
d3cd5a3120 优化消息平滑,让发消息速度变化不要太频繁 2021-01-24 19:10:25 +08:00
John Smith
f50652af83 删除package-lock.json 2020-11-28 23:50:18 +08:00
John Smith
4059a06cc8 删除package-lock.json 2020-11-28 23:04:44 +08:00
John Smith
138bf8f7a5 Merge branch 'dev' 2020-11-28 22:58:16 +08:00
John Smith
098bc86e0c 更新版本号v1.5.1 2020-11-28 22:57:30 +08:00
John Smith
104f296929 修改一些说明 2020-11-28 22:56:45 +08:00
John Smith
1a52775974 修复B站翻译 2020-11-28 22:05:44 +08:00
John Smith
4f0964c0b3 添加标注打赏用户名读音 2020-11-28 21:53:49 +08:00
John Smith
c57520fff1 Merge pull request #34 from xfgryujk/dependabot/npm_and_yarn/frontend/highlight.js-9.18.5
Bump highlight.js from 9.18.1 to 9.18.5 in /frontend
2020-11-25 13:10:59 +08:00
dependabot[bot]
ff95909d5a Bump highlight.js from 9.18.1 to 9.18.5 in /frontend
Bumps [highlight.js](https://github.com/highlightjs/highlight.js) from 9.18.1 to 9.18.5.
- [Release notes](https://github.com/highlightjs/highlight.js/releases)
- [Changelog](https://github.com/highlightjs/highlight.js/blob/9.18.5/CHANGES.md)
- [Commits](https://github.com/highlightjs/highlight.js/compare/9.18.1...9.18.5)

Signed-off-by: dependabot[bot] <support@github.com>
2020-11-25 00:00:10 +00:00
John Smith
8c7fc2780d 兼容腾讯翻译升级 2020-11-24 23:17:30 +08:00
John Smith
210749fa0f 兼容腾讯翻译升级 2020-11-17 21:56:01 +08:00
John Smith
888bc896a5 修正B站API错误信息字段 2020-10-17 17:12:22 +08:00
John Smith
e7d8fd0ff6 Merge branch 'dev' 2020-10-11 14:51:55 +08:00
John Smith
cce51a6990 更新版本号v1.5.0 2020-10-11 14:37:30 +08:00
John Smith
adeba8f552 优化SC固定栏 2020-10-11 14:36:23 +08:00
John Smith
0e06c4ba24 前端头像加载失败则使用默认头像 2020-10-11 13:14:10 +08:00
John Smith
0e060e61fa 修改一些说明 2020-10-11 13:00:57 +08:00
John Smith
8cf4310152 init_room支持降级 2020-10-11 12:52:15 +08:00
John Smith
d6897ca4b4 添加通过服务器转发消息配置 2020-09-14 21:50:40 +08:00
John Smith
6e2f3b1a0c 完成房间信息和头像接口 2020-09-13 21:24:20 +08:00
John Smith
6a64f3c795 添加前端直连B站弹幕服务器 2020-09-13 16:29:49 +08:00
John Smith
2dbf1769e0 分离房间和弹幕API 2020-09-12 17:37:52 +08:00
John Smith
54aac237ee Merge pull request #30 from xfgryujk/dependabot/npm_and_yarn/frontend/http-proxy-1.18.1
Bump http-proxy from 1.18.0 to 1.18.1 in /frontend
2020-09-12 15:57:13 +08:00
John Smith
2ec6096d4d 把YouTube的CSS移到单独文件 2020-09-12 15:50:16 +08:00
John Smith
3808609963 数据库头像URL类型改成VARCHAR 2020-09-12 11:33:11 +08:00
John Smith
c66a47c3e5 git忽略日志 2020-09-12 10:51:29 +08:00
dependabot[bot]
435ee43c98 Bump http-proxy from 1.18.0 to 1.18.1 in /frontend
Bumps [http-proxy](https://github.com/http-party/node-http-proxy) from 1.18.0 to 1.18.1.
- [Release notes](https://github.com/http-party/node-http-proxy/releases)
- [Changelog](https://github.com/http-party/node-http-proxy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/http-party/node-http-proxy/compare/1.18.0...1.18.1)

Signed-off-by: dependabot[bot] <support@github.com>
2020-09-09 00:12:58 +00:00
John Smith
cb150effbb Merge branch 'dev' 2020-09-05 12:08:50 +08:00
John Smith
47d7528b61 更新版本号v1.4.5 2020-09-05 12:08:17 +08:00
John Smith
152db9f00a 优化消息平滑 2020-09-05 12:05:01 +08:00
John Smith
7a244757d2 修复OBS 25.0.8不支持中文字体名的问题 2020-09-05 09:44:40 +08:00
John Smith
626099f835 docker暴露一些目录 2020-09-05 08:45:19 +08:00
John Smith
ca55f9917d 日志保存到文件 2020-09-03 20:01:54 +08:00
John Smith
4d58245ad9 添加加载器URL配置 2020-08-30 17:46:04 +08:00
John Smith
28b04f27ee 改一些默认配置 2020-08-29 14:27:15 +08:00
John Smith
1252e1942d 添加tornado xheaders配置 2020-08-22 18:38:52 +08:00
John Smith
38774252cc 更新blivedm 2020-08-19 23:17:29 +08:00
John Smith
7cfa94c68c 改API路径 2020-08-19 23:16:01 +08:00
John Smith
4e7cc7a782 更新nginx配置 2020-08-19 22:55:54 +08:00
John Smith
bfa6054899 显示的房间URL改成加载器URL 2020-08-18 21:48:33 +08:00
John Smith
d25a32beaf 增加牌子等级上限 2020-08-16 12:15:01 +08:00
John Smith
127344802d 添加允许自动翻译的房间配置 2020-08-16 12:13:53 +08:00
John Smith
96070ac473 更新使用方法 2020-08-16 10:10:49 +08:00
John Smith
583deb4588 Merge pull request #25 from xfgryujk/dependabot/npm_and_yarn/frontend/elliptic-6.5.3
Bump elliptic from 6.5.2 to 6.5.3 in /frontend
2020-07-30 23:43:30 +08:00
dependabot[bot]
b555c6ac2d Bump elliptic from 6.5.2 to 6.5.3 in /frontend
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.2 to 6.5.3.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.5.2...v6.5.3)

Signed-off-by: dependabot[bot] <support@github.com>
2020-07-30 15:40:11 +00:00
John Smith
602a9f2a44 更新初始化房间API 2020-07-19 22:19:05 +08:00
John Smith
a89b64b454 修改样式 2020-07-19 21:33:26 +08:00
John Smith
49d58a9a60 删除BiliSC链接 2020-07-19 16:31:49 +08:00
John Smith
cb7ecc066c Merge pull request #23 from xfgryujk/dependabot/npm_and_yarn/frontend/lodash-4.17.19
Bump lodash from 4.17.15 to 4.17.19 in /frontend
2020-07-17 21:33:12 +08:00
dependabot[bot]
57aeada21e Bump lodash from 4.17.15 to 4.17.19 in /frontend
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.15 to 4.17.19.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.15...4.17.19)

Signed-off-by: dependabot[bot] <support@github.com>
2020-07-17 02:05:38 +00:00
John Smith
c2de73a1a5 Merge pull request #22 from xfgryujk/dependabot/npm_and_yarn/frontend/websocket-extensions-0.1.4
Bump websocket-extensions from 0.1.3 to 0.1.4 in /frontend
2020-06-07 01:46:39 +08:00
dependabot[bot]
71f02c0269 Bump websocket-extensions from 0.1.3 to 0.1.4 in /frontend
Bumps [websocket-extensions](https://github.com/faye/websocket-extensions-node) from 0.1.3 to 0.1.4.
- [Release notes](https://github.com/faye/websocket-extensions-node/releases)
- [Changelog](https://github.com/faye/websocket-extensions-node/blob/master/CHANGELOG.md)
- [Commits](https://github.com/faye/websocket-extensions-node/compare/0.1.3...0.1.4)

Signed-off-by: dependabot[bot] <support@github.com>
2020-06-06 16:14:56 +00:00
John Smith
9b8a581a83 更新版本号v1.4.4 2020-06-06 11:41:55 +08:00
John Smith
f554c8b6d5 兼容B站弹幕升级(使用旧接口获取token会连不上某些服务器) 2020-05-20 21:26:00 +08:00
John Smith
b862a0906e 修复弹幕时间不正确的问题 2020-05-20 21:20:08 +08:00
John Smith
61b13808fb 更新版本号v1.4.3 2020-05-10 14:14:14 +08:00
John Smith
7f25c219d8 前端优化 2020-05-09 23:17:49 +08:00
John Smith
32ba4187aa 修改一些文本 2020-05-09 22:46:24 +08:00
John Smith
06c0dfa3f2 修复冒号两边空白太宽的问题、消息颜色影响重复标记颜色的问题 2020-05-09 22:15:34 +08:00
John Smith
4c7bf69275 (真)修复启动长时间后自动翻译失效的问题 2020-05-09 21:38:21 +08:00
John Smith
63644f5499 修复qtv、qtk反了 2020-03-21 19:13:06 +08:00
John Smith
8a57304790 更新版本号v1.4.2 2020-03-21 19:05:53 +08:00
John Smith
d7df071595 修复启动长时间后自动翻译失效的问题 2020-03-21 18:42:27 +08:00
John Smith
6e8b04ef4f 修复删缓存时的错误、提高容错性 2020-03-21 15:23:31 +08:00
John Smith
b73a4e3748 更新版本号v1.4.1 2020-03-01 14:22:12 +08:00
John Smith
102746d0b0 添加显示礼物名、只合并同类礼物 2020-03-01 14:14:19 +08:00
John Smith
2d237af6fd 提示用户已加载页面 2020-02-29 19:20:29 +08:00
John Smith
4030ff43cd 减小编译后文件尺寸 2020-02-22 17:21:47 +08:00
John Smith
092cc8d9ab 更新帮助 2020-02-06 20:31:43 +08:00
John Smith
2d7106ae93 更新版本号v1.4.0 2020-02-06 19:53:33 +08:00
John Smith
d5b885aab4 添加前端获取后端配置 2020-02-06 19:51:03 +08:00
John Smith
34f0effb6a 修复翻译导致弹幕无法滚动到底部的问题 2020-02-06 18:31:05 +08:00
John Smith
d3e9300fa4 添加自动翻译前端 2020-02-06 17:39:56 +08:00
John Smith
0c9560f8ca 添加自动翻译后端 2020-02-06 12:48:20 +08:00
John Smith
1c5df01fbc websocket消息优化 2020-02-05 17:28:10 +08:00
John Smith
49cdc56cff 防止队列中出现相同uid时重复获取头像 2020-02-05 12:58:26 +08:00
John Smith
00e1f31f84 优化前端防卡代码 2020-02-04 20:33:29 +08:00
John Smith
b7e06652b4 防止前端弹幕卡住 2020-02-04 20:13:11 +08:00
John Smith
0cf49ee042 防止更新版本后浏览器加载缓存 2020-02-04 16:26:28 +08:00
John Smith
7d610ecfdc 修复获取头像future没有设置结果的问题、部分头像URL没有处理的问题 2020-02-04 16:12:16 +08:00
John Smith
c8e4278c39 修复被B站ban后不会恢复获取头像的问题 2020-02-03 19:29:29 +08:00
John Smith
07b47a26ca 更新版本号v1.3.0
close #15
2020-02-03 16:23:55 +08:00
John Smith
8d55331e6c 头像缓存持久化 2020-02-03 16:18:21 +08:00
John Smith
cae06858fc 前端配置放在URL参数 2020-02-01 21:27:50 +08:00
John Smith
8d4e8e6f35 一些前端优化 2020-02-01 18:17:23 +08:00
John Smith
8d40f9f9e5 更新版本号 2020-01-12 23:02:15 +08:00
John Smith
e93f6b2383 兼容B站弹幕升级 2020-01-12 22:46:02 +08:00
John Smith
4c2e216191 添加帮助 2019-12-16 23:40:01 +08:00
John Smith
2a35541dc5 捕获write_message异常 2019-12-08 17:55:58 +08:00
John Smith
a927282e77 修复弹幕太快时消息平滑失效的问题 2019-12-01 16:36:50 +08:00
John Smith
a7faac5425 修复有时消息不能正确合并的问题 2019-11-30 23:57:52 +08:00
John Smith
a22496c355 修复固定消息的顺序 2019-11-30 23:24:06 +08:00
John Smith
20ec58b965 Merge pull request #13 from simon300000/https-face
auto http/https avatarUrl
2019-11-30 14:26:20 +08:00
simon3000
7453d7e890 (python) http/https avatarUrl 2019-11-30 07:20:35 +01:00
simon3000
1991e33b9d Revert "auto http/https avatarUrl"
This reverts commit 4b98c56965.
2019-11-30 07:16:38 +01:00
John Smith
f429a6a03d Merge pull request #12 from simon300000/generate-https
roomUrl auto https support
2019-11-30 14:16:28 +08:00
simon3000
4b98c56965 auto http/https avatarUrl 2019-11-30 07:08:17 +01:00
simon3000
eb43a37ae5 roomUrl auto https support 2019-11-30 06:56:05 +01:00
John Smith
1cf6d88460 各种优化 2019-11-29 23:48:43 +08:00
John Smith
9ddbebbd7e 更新版本号 2019-11-28 22:25:50 +08:00
John Smith
ca7f27d536 添加平滑滚动弹幕 2019-11-28 22:18:27 +08:00
John Smith
98184e6732 添加平滑消息队列 2019-11-28 00:52:16 +08:00
John Smith
3b2616c278 封装addMessage到ChatRenderer
暂时移除了messagesBuffer
2019-11-24 18:48:10 +08:00
John Smith
9565c52e23 更新版本号 2019-10-13 21:19:33 +08:00
John Smith
bb1acaecd6 配置允许储存任意JSON 2019-10-13 21:04:03 +08:00
John Smith
dcbb90a7bd 兼容B站弹幕升级 2019-10-13 20:27:52 +08:00
John Smith
c46542bee0 兼容HTTPS 2019-10-13 17:02:54 +08:00
John Smith
709ec0585e 更新README、版本号 2019-10-06 16:56:40 +08:00
John Smith
a9c6709080 添加BiliSC链接 2019-10-06 16:41:47 +08:00
John Smith
938f758920 添加掉线重连 2019-10-06 16:20:48 +08:00
John Smith
ce4974be94 添加删除SC功能 2019-10-06 12:23:47 +08:00
John Smith
040bad0eba 修复空SC下面不是圆角边框的问题 2019-10-06 10:55:01 +08:00
John Smith
60e6b5ec99 隐藏空SC内容、更新版本号 2019-09-23 22:28:11 +08:00
John Smith
695a111c9a SC使用官方功能 2019-09-23 22:22:27 +08:00
John Smith
0160549dbe 重复标记用HSL、代码优化 2019-09-22 17:18:34 +08:00
John Smith
3fd0d9ab28 更新JS包 2019-09-17 23:53:26 +08:00
John Smith
b1061ae103 更新blivedm 2019-09-17 23:08:03 +08:00
John Smith
6e737ce33f 样式生成器添加梗 2019-09-16 22:21:44 +08:00
John Smith
10db6b9ae2 添加最大弹幕数 2019-09-16 22:15:32 +08:00
John Smith
1514bfed5b 自动检查更新 2019-09-16 20:48:03 +08:00
John Smith
dc264d40f0 SC配置按美元汇率 2019-09-15 23:31:23 +08:00
John Smith
104e647f61 样式生成器添加默认字体 2019-07-18 09:15:27 +08:00
John Smith
d5d7fdde6d 日志改用英语 2019-07-18 09:08:03 +08:00
John Smith
23aa88d564 时间补齐2位 2019-07-18 08:31:58 +08:00
John Smith
ac1bda0392 修改一堆文案 2019-07-15 21:30:52 +08:00
John Smith
5cdb786966 去掉了点击预览会消失的CSS 2019-07-15 19:50:51 +08:00
John Smith
2b5ca2e219 Merge pull request #8 from simon300000/honest
「尝试」优化了样式生成器的CSS,加入了一个只有好孩子才能看到的小按钮
2019-07-15 19:30:39 +08:00
John Smith
fc77b246c2 我傻了 2019-07-15 18:01:38 +08:00
John Smith
083832cb3e Merge pull request #7 from simon300000/patch-css
增加element字体CSS
2019-07-15 17:58:42 +08:00
John Smith
804db2fec1 全局应用字体 2019-07-15 17:56:03 +08:00
simon3000
769964d5db 增加element字体CSS 2019-07-15 17:56:03 +08:00
John Smith
aba0efca34 Merge pull request #9 from xfgryujk/dependabot/npm_and_yarn/frontend/lodash-4.17.13
Bump lodash from 4.17.11 to 4.17.13 in /frontend
2019-07-15 17:35:06 +08:00
dependabot[bot]
9ff18fe8b5 Bump lodash from 4.17.11 to 4.17.13 in /frontend
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.11 to 4.17.13.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.11...4.17.13)

Signed-off-by: dependabot[bot] <support@github.com>
2019-07-15 09:33:24 +00:00
simon3000
ee78cbf90e 改用refs直接执行function 2019-07-13 01:51:58 +02:00
simon3000
ffc3c9d8a0 将监听的event名称改为exampleResize 2019-07-13 01:16:57 +02:00
simon3000
a69b6b6406 给ChatRenderer加上demo prop, 为true时自动resize 2019-07-13 01:14:55 +02:00
simon3000
1252402e5e Merge branch 'patch-scroll-on-resize' into honest 2019-07-13 01:03:45 +02:00
simon3000
53be587189 自动resize 2019-07-11 23:14:11 +02:00
simon3000
d3cc8b0c77 👿 2019-07-11 22:52:06 +02:00
simon3000
4f4d117ce0 使用calc优化CSS生成器的浏览部分 2019-07-11 22:51:41 +02:00
simon3000
0d1e38346e 增加element字体CSS 2019-07-11 22:15:46 +02:00
simon3000
273dc0f43f 窗口缩放时滚动 2019-07-11 21:50:28 +02:00
John Smith
26f19b6630 Update README.md 2019-07-11 11:35:17 +08:00
John Smith
f2d2528afe 修改默认端口为12450 2019-07-10 15:30:35 +08:00
John Smith
d0fd997415 兼容B站弹幕升级 2019-07-10 15:19:03 +08:00
John Smith
7086acb001 补i18n依赖 2019-07-08 16:45:14 +08:00
John Smith
2914a19288 防止移动端侧边栏遮挡UI
简单粗暴的适配移动端
2019-07-08 16:07:05 +08:00
John Smith
64cca50982 Merge branch 'i18n' 2019-07-08 11:42:54 +08:00
John Smith
702bd56869 添加国际化 2019-07-08 11:42:35 +08:00
John Smith
1eedee1119 完善docker 2019-07-07 23:01:42 +08:00
John Smith
b95ef95cfd Merge pull request #3 from starP-W/master
为v1.1.2添加docker部署方式
2019-07-07 13:37:18 +08:00
starP-W
01a3ec367e Added dockerfile with blivechat v1.1.2 2019-07-06 01:23:29 +08:00
John Smith
2d86449c24 并发获取头像 2019-07-04 19:12:24 +08:00
John Smith
290b09f49a 添加导出导入配置 2019-07-04 18:56:27 +08:00
John Smith
4d5a38bf7d 修复弹幕速度太快时不能合并的问题 2019-07-03 00:12:40 +08:00
John Smith
b8cb5e96e9 防止获取头像频率太高被ban 2019-07-01 18:32:54 +08:00
John Smith
3722bf25ad 移除了重复计数的白色边框 2019-06-24 22:26:09 +08:00
John Smith
c3bcbacbfa 修复移除messagesBuffer时移除messages的问题 2019-06-24 22:20:41 +08:00
John Smith
b370ce931a 防止一些不用显示贴纸的SC一闪而过 2019-06-22 18:27:55 +08:00
John Smith
5cd3776d8e 无SC时隐藏贴纸 2019-06-22 18:16:06 +08:00
John Smith
f5ef539eac 修复勋章无法加载的问题 2019-06-22 17:33:01 +08:00
John Smith
36136af08d 更新readme 2019-06-22 17:17:30 +08:00
John Smith
82f7a84793 点击SC贴纸时显示消息 2019-06-22 16:56:54 +08:00
John Smith
46baa83458 添加SC贴纸 2019-06-22 16:15:40 +08:00
John Smith
833c366707 添加勋章等级屏蔽 2019-06-21 17:38:22 +08:00
John Smith
0ef11439ff 修复同时添加和删除消息时会触发所有消息的动画的问题(vuejs/vue#6857) 2019-06-21 16:50:18 +08:00
John Smith
ec27b1bbcc 添加弹幕速度限制等设置 2019-06-20 23:53:34 +08:00
John Smith
ef4c0daede 添加勋章 2019-06-20 20:03:07 +08:00
John Smith
fe598369b7 添加样式预览 2019-06-17 12:29:25 +08:00
John Smith
e0df7bc3d6 添加生成样式 2019-06-16 23:04:30 +08:00
John Smith
05180c3acb 滚动条模仿、简化代码 2019-06-14 09:09:46 +08:00
John Smith
45173e5162 更高仿YouTube 2019-06-13 23:50:50 +08:00
John Smith
846e830f9c 添加错误处理 2019-06-12 22:37:07 +08:00
John Smith
da242fc853 头像不使用webp 2019-06-12 19:31:56 +08:00
John Smith
1cbaed4127 完成配置功能 2019-06-12 19:20:17 +08:00
John Smith
920e6fff96 完成配置保存和读取 2019-06-12 13:55:49 +08:00
John Smith
22e8f88533 添加各种UI 2019-06-11 18:52:00 +08:00
John Smith
874c9e84e0 添加侧边栏 2019-06-11 12:51:25 +08:00
John Smith
ce40899231 使用ElementUI 2019-06-10 18:38:21 +08:00
John Smith
8dbba50548 优化文件结构 2019-06-09 12:37:59 +08:00
John Smith
82bfb69a0a 更新blivedm 2019-06-06 22:13:45 +08:00
John Smith
51a5199718 B站弹幕升级 2019-05-29 22:33:39 +08:00
106 changed files with 55794 additions and 12520 deletions

24
.dockerignore Normal file
View File

@@ -0,0 +1,24 @@
# compiled output
**/__pycache__/
build/
**/dist/
# dependencies
**/node_modules/
# IDEs and editors
.idea/
# misc
**/.git*
*.spec
screenshots/
README.md
# runtime data
data/*
!data/config.example.ini
!data/emoticons/
data/emoticons/*
!data/emoticons/.gitkeep
log/*

6
.gitignore vendored
View File

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

47
Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
#
# 构建前端
#
FROM node:18.17.0-bullseye AS builder
ARG BASE_PATH='/root/blivechat'
WORKDIR "${BASE_PATH}/frontend"
# 前端依赖
COPY frontend/package.json ./
RUN npm i
# 编译前端
COPY frontend ./
RUN npm run build
#
# 准备后端
#
FROM python:3.8.12-bullseye
ARG BASE_PATH='/root/blivechat'
ARG EXT_DATA_PATH='/mnt/data'
WORKDIR "${BASE_PATH}"
# 后端依赖
COPY blivedm/requirements.txt blivedm/
COPY requirements.txt ./
RUN pip3 install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple -r requirements.txt
# 数据目录
COPY . ./
RUN mkdir -p "${EXT_DATA_PATH}/frontend/dist" \
&& mv data "${EXT_DATA_PATH}/data" \
&& ln -s "${EXT_DATA_PATH}/data" data \
&& mv log "${EXT_DATA_PATH}/log" \
&& ln -s "${EXT_DATA_PATH}/log" log \
&& ln -s "${EXT_DATA_PATH}/frontend/dist" frontend/dist
# 编译好的前端
COPY --from=builder "${BASE_PATH}/frontend/dist" "${EXT_DATA_PATH}/frontend/dist/"
# 运行
VOLUME "${EXT_DATA_PATH}"
EXPOSE 12450
ENTRYPOINT ["python3", "main.py"]
CMD ["--host", "0.0.0.0", "--port", "12450"]

204
README.md
View File

@@ -1,41 +1,185 @@
# blivechat
用于OBS的仿YouTube风格的bilibili直播聊天层
最近喜欢看VTuber想为此写些程序于是有了这个东西。~~写到一半发现有类似项目了,而且还是纯前端实现的:[bilibili-live-chat](https://github.com/Tsuk1ko/bilibili-live-chat)。~~ 本项目就当做练手吧而且对YouTube的模仿程度更高指样式和Super Chat
用于OBS的仿YouTube风格的bilibili直播评论栏
![截图](https://github.com/xfgryujk/blivechat/blob/master/screenshot.png)
![OBS截图](./screenshots/obs.png)
![Chrome截图](./screenshots/chrome.png)
![样式生成器截图](./screenshots/stylegen.png)
## 特性
* 兼容YouTube直播chat的样式生成样式参考[https://chatv2.septapus.com/](https://chatv2.septapus.com/)
* 金瓜子礼物模仿Super Chat显示
* 兼容YouTube直播评论栏的样式
* 付费礼物模仿醒目留言显示
* 高亮舰队、房管、主播的用户名
* 支持屏蔽弹幕、合并相似弹幕等设置
* 自带两种样式生成器经典YouTube风格和仿微信风格
* 支持前端直连B站服务器或者通过后端转发
* 支持自动翻译弹幕、醒目留言到日语,可以在后台配置翻译目标语言
* 支持标注打赏用户名的读音,可选拼音或日文假名
* 支持配置自定义表情不需要开通B站官方表情
## 使用方法
### 发布版
1. 下载[发布版](https://github.com/xfgryujk/blivechat/releases)仅提供Windows
以下几种方式任选一种即可
### 一、本地使用
1. 下载[发布版](https://github.com/xfgryujk/blivechat/releases)仅提供x64 Windows版
2. 双击`blivechat.exe`运行服务器或者用命令行可以指定host和端口号
```bat
blivechat.exe --host 127.0.0.1 --port 80
```
3. 用浏览器打开[http://localhost](http://localhost)输入房间号进入复制房间URL其实就是http://localhost/room/<房间ID>
4. (可选)在[https://chatv2.septapus.com/](https://chatv2.septapus.com/)生成样式复制CSS
```sh
blivechat.exe --host 127.0.0.1 --port 12450
```
或者也可以在配置文件里指定host和端口号
3. 用浏览器打开[http://localhost:12450](http://localhost:12450)输入主播在开始直播时获得的身份码复制房间URL
4. 用样式生成器生成样式复制CSS
5. 在OBS中添加浏览器源输入URL和自定义CSS
### 源代码版
1. 编译前端需要安装NPM
```sh
cd frontend
npm i
npm run build
```
2. 运行服务器:
```sh
python3 main.py
```
或者可以指定host和端口号
```sh
python3 main.py --host 127.0.0.1 --port 80
```
3. 用浏览器打开[http://localhost](http://localhost)输入房间号进入复制房间URL其实就是http://localhost/room/<房间ID>
4. (可选)在[https://chatv2.septapus.com/](https://chatv2.septapus.com/)生成样式复制CSS
5. 在OBS中添加浏览器源输入URL和自定义CSS
**注意事项:**
* 本地使用时不要关闭blivechat.exe那个黑框否则不能继续获取弹幕
* 样式生成器没有列出所有本地字体,但是可以手动输入本地字体
* 如果需要使用翻译功能,建议看[配置官方翻译接口傻瓜式教程](https://www.bilibili.com/read/cv14663633)
### 二、公共服务器
请优先在本地使用,使用公共服务器会有更大的延迟,而且服务器故障时可能发生直播事故
* [公共服务器](http://chat.bilisc.com/)
### 三、源代码版自建服务器或在Windows以外平台
0. 由于使用了git子模块clone时需要加上`--recursive`参数:
```sh
git clone --recursive https://github.com/xfgryujk/blivechat.git
```
如果已经clone拉子模块的方法
```sh
git submodule update --init --recursive
```
1. 编译前端需要安装Node.js
```sh
cd frontend
npm i
npm run build
```
2. 运行服务器需要Python3.8以上版本):
```sh
pip3 install -r requirements.txt
python3 main.py
```
或者可以指定host和端口号
```sh
python3 main.py --host 127.0.0.1 --port 12450
```
3. 用浏览器打开[http://localhost:12450](http://localhost:12450),以下略
### 四、Docker自建服务器
1. ```sh
docker run --name blivechat -d -p 12450:12450 \
--mount source=blivechat-data,target=/mnt/data \
xfgryujk/blivechat:latest
```
2. 用浏览器打开[http://localhost:12450](http://localhost:12450),以下略
## 自建服务器相关补充
### 服务器配置
服务器配置在`data/config.ini`,可以配置数据库和允许自动翻译等,编辑后要重启生效
**自建服务器时强烈建议不使用加载器**否则可能因为混合HTTP和HTTPS等原因加载不出来
### 参考nginx配置
`sudo vim /etc/nginx/sites-enabled/blivechat.conf`
```nginx
upstream blivechat {
keepalive 8;
# blivechat地址
server 127.0.0.1:12450;
}
# 强制HTTPS
server {
listen 80;
listen [::]:80;
server_name YOUR.DOMAIN.NAME;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name YOUR.DOMAIN.NAME;
# SSL
ssl_certificate /PATH/TO/CERT.crt;
ssl_certificate_key /PATH/TO/CERT_KEY.key;
set $blivechat_path /PATH/TO/BLIVECHAT;
client_body_buffer_size 256k;
client_max_body_size 1.1m;
# 代理header
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 静态文件
location / {
root $blivechat_path/frontend/dist;
try_files $uri $uri/ @index;
}
# 不存在的文件请求转发到index.html交给前端路由
location @index {
rewrite ^ /index.html last;
}
location = /index.html {
root $blivechat_path/frontend/dist;
# index.html不缓存防止更新后前端还是旧版
add_header Cache-Control no-cache;
}
location /emoticons {
alias $blivechat_path/data/emoticons;
}
# 动态API
location /api {
proxy_pass http://blivechat;
}
# websocket
location = /api/chat {
proxy_pass http://blivechat;
# 代理websocket必须设置
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# 由于这个块有proxy_set_header这些不会自动继承
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```

21
api/base.py Normal file
View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
import json
from typing import *
import tornado.web
class ApiHandler(tornado.web.RequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.json_args: Optional[dict] = None
def prepare(self):
self.set_header('Cache-Control', 'no-cache')
if not self.request.headers.get('Content-Type', '').startswith('application/json'):
return
try:
self.json_args = json.loads(self.request.body)
except json.JSONDecodeError:
pass

394
api/chat.py Normal file
View File

@@ -0,0 +1,394 @@
# -*- coding: utf-8 -*-
import asyncio
import enum
import json
import logging
import random
import time
import uuid
from typing import *
import aiohttp
import tornado.websocket
import api.base
import blivedm.blivedm.clients.web as dm_web_cli
import config
import services.avatar
import services.chat
import services.translate
import utils.request
logger = logging.getLogger(__name__)
class Command(enum.IntEnum):
HEARTBEAT = 0
JOIN_ROOM = 1
ADD_TEXT = 2
ADD_GIFT = 3
ADD_MEMBER = 4
ADD_SUPER_CHAT = 5
DEL_SUPER_CHAT = 6
UPDATE_TRANSLATION = 7
class ContentType(enum.IntEnum):
TEXT = 0
EMOTICON = 1
def make_message_body(cmd, data):
return json.dumps(
{
'cmd': cmd,
'data': data
}
).encode('utf-8')
def make_text_message_data(
avatar_url: str = services.avatar.DEFAULT_AVATAR_URL,
timestamp: int = None,
author_name: str = '',
author_type: int = 0,
content: str = '',
privilege_type: int = 0,
is_gift_danmaku: bool = False,
author_level: int = 1,
is_newbie: bool = False,
is_mobile_verified: bool = True,
medal_level: int = 0,
id_: str = None,
translation: str = '',
content_type: int = ContentType.TEXT,
content_type_params: list = None,
):
# 为了节省带宽用list而不是dict
return [
# 0: avatarUrl
avatar_url,
# 1: timestamp
timestamp if timestamp is not None else int(time.time()),
# 2: authorName
author_name,
# 3: authorType
author_type,
# 4: content
content,
# 5: privilegeType
privilege_type,
# 6: isGiftDanmaku
1 if is_gift_danmaku else 0,
# 7: authorLevel
author_level,
# 8: isNewbie
1 if is_newbie else 0,
# 9: isMobileVerified
1 if is_mobile_verified else 0,
# 10: medalLevel
medal_level,
# 11: id
id_ if id_ is not None else uuid.uuid4().hex,
# 12: translation
translation,
# 13: contentType
content_type,
# 14: contentTypeParams
content_type_params if content_type_params is not None else [],
# 15: textEmoticons
[], # 已废弃,保留
]
def make_emoticon_params(url):
return [
# 0: url
url,
]
def make_translation_message_data(msg_id, translation):
return [
# 0: id
msg_id,
# 1: translation
translation,
]
class ChatHandler(tornado.websocket.WebSocketHandler):
HEARTBEAT_INTERVAL = 10
RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + 5
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._heartbeat_timer_handle = None
self._receive_timeout_timer_handle = None
self.room_key: Optional[services.chat.RoomKey] = None
self.auto_translate = False
def open(self):
logger.info('client=%s connected', self.request.remote_ip)
self._heartbeat_timer_handle = asyncio.get_running_loop().call_later(
self.HEARTBEAT_INTERVAL, self._on_send_heartbeat
)
self._refresh_receive_timeout_timer()
def _on_send_heartbeat(self):
self.send_cmd_data(Command.HEARTBEAT, {})
self._heartbeat_timer_handle = asyncio.get_running_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_running_loop().call_later(
self.RECEIVE_TIMEOUT, self._on_receive_timeout
)
def _on_receive_timeout(self):
logger.info('client=%s timed out', self.request.remote_ip)
self._receive_timeout_timer_handle = None
self.close()
def on_close(self):
logger.info('client=%s disconnected, room=%s', self.request.remote_ip, self.room_key)
if self.has_joined_room:
services.chat.client_room_manager.del_client(self.room_key, 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:
body = json.loads(message)
cmd = int(body['cmd'])
if cmd == Command.HEARTBEAT:
# 超时没有加入房间也断开
if self.has_joined_room:
self._refresh_receive_timeout_timer()
elif cmd == Command.JOIN_ROOM:
self._on_join_room_req(body)
else:
logger.warning('client=%s unknown cmd=%d, body=%s', self.request.remote_ip, cmd, body)
except Exception: # noqa
logger.exception('client=%s on_message error, message=%s', self.request.remote_ip, message)
def _on_join_room_req(self, body: dict):
if self.has_joined_room:
return
data = body['data']
room_key_dict = data.get('roomKey', None)
if room_key_dict is not None:
room_key_type = services.chat.RoomKeyType(room_key_dict['type'])
room_key_value = room_key_dict['value']
if room_key_type == services.chat.RoomKeyType.ROOM_ID:
if not isinstance(room_key_value, int):
raise TypeError(f'Room key value type error, value={room_key_value}')
elif room_key_type == services.chat.RoomKeyType.AUTH_CODE:
if not isinstance(room_key_value, str):
raise TypeError(f'Room key value type error, value={room_key_value}')
else:
raise ValueError(f'Unknown RoomKeyType={room_key_type}')
else:
# 兼容旧版客户端 TODO 过几个版本可以移除
room_key_type = services.chat.RoomKeyType.ROOM_ID
room_key_value = int(data['roomId'])
self.room_key = services.chat.RoomKey(room_key_type, room_key_value)
logger.info('client=%s joining room %s', self.request.remote_ip, self.room_key)
try:
cfg = data['config']
self.auto_translate = bool(cfg['autoTranslate'])
except KeyError:
pass
services.chat.client_room_manager.add_client(self.room_key, self)
asyncio.create_task(self._on_joined_room())
self._refresh_receive_timeout_timer()
# 跨域测试用
def check_origin(self, origin):
if self.application.settings['debug']:
return True
return super().check_origin(origin)
@property
def has_joined_room(self):
return self.room_key is not None
def send_cmd_data(self, cmd, data):
self.send_body_no_raise(make_message_body(cmd, data))
def send_body_no_raise(self, body: Union[bytes, str, Dict[str, Any]]):
try:
self.write_message(body)
except tornado.websocket.WebSocketClosedError:
self.close()
async def _on_joined_room(self):
if self.settings['debug']:
await self._send_test_message()
# 不允许自动翻译的提示
if self.auto_translate:
cfg = config.get_config()
if (
cfg.allow_translate_rooms
# 身份码就不管了吧,反正配置正确的情况下不会看到这个提示
and self.room_key.type == services.chat.RoomKeyType.ROOM_ID
and self.room_key.value not in cfg.allow_translate_rooms
):
self.send_cmd_data(Command.ADD_TEXT, make_text_message_data(
author_name='blivechat',
author_type=2,
content='Translation is not allowed in this room. Please download to use translation',
author_level=60,
))
# 测试用
async def _send_test_message(self):
base_data = {
'avatarUrl': await services.avatar.get_avatar_url(300474),
'timestamp': int(time.time()),
'authorName': 'xfgryujk',
}
text_data = make_text_message_data(
avatar_url=base_data['avatarUrl'],
timestamp=base_data['timestamp'],
author_name=base_data['authorName'],
content='我能吞下玻璃而不伤身体',
author_level=60,
)
member_data = {
**base_data,
'id': uuid.uuid4().hex,
'privilegeType': 3
}
gift_data = {
**base_data,
'id': uuid.uuid4().hex,
'totalCoin': 450000,
'giftName': '摩天大楼',
'num': 1
}
sc_data = {
**base_data,
'id': str(random.randint(1, 65535)),
'price': 30,
'content': 'The quick brown fox jumps over the lazy dog',
'translation': ''
}
self.send_cmd_data(Command.ADD_TEXT, text_data)
text_data[4] = 'te[dog]st'
text_data[11] = uuid.uuid4().hex
self.send_cmd_data(Command.ADD_TEXT, text_data)
text_data[2] = '主播'
text_data[3] = 3
text_data[4] = "I can eat glass, it doesn't hurt me."
text_data[11] = uuid.uuid4().hex
self.send_cmd_data(Command.ADD_TEXT, text_data)
self.send_cmd_data(Command.ADD_MEMBER, member_data)
self.send_cmd_data(Command.ADD_SUPER_CHAT, sc_data)
sc_data['id'] = str(random.randint(1, 65535))
sc_data['price'] = 100
sc_data['content'] = '敏捷的棕色狐狸跳过了懒狗'
self.send_cmd_data(Command.ADD_SUPER_CHAT, sc_data)
# self.send_cmd_data(Command.DEL_SUPER_CHAT, {'ids': [sc_data['id']]})
self.send_cmd_data(Command.ADD_GIFT, gift_data)
gift_data['id'] = uuid.uuid4().hex
gift_data['totalCoin'] = 1245000
gift_data['giftName'] = '小电视飞船'
self.send_cmd_data(Command.ADD_GIFT, gift_data)
class RoomInfoHandler(api.base.ApiHandler):
async def get(self):
room_id = int(self.get_query_argument('roomId'))
logger.info('client=%s getting room info, room=%d', self.request.remote_ip, room_id)
room_id, owner_uid = await self._get_room_info(room_id)
# 连接其他host必须要key
host_server_list = dm_web_cli.DEFAULT_DANMAKU_SERVER_LIST
if owner_uid == 0:
# 缓存3分钟
self.set_header('Cache-Control', 'private, max-age=180')
else:
# 缓存1天
self.set_header('Cache-Control', 'private, max-age=86400')
self.write({
'roomId': room_id,
'ownerUid': owner_uid,
'hostServerList': host_server_list
})
@staticmethod
async def _get_room_info(room_id):
try:
async with utils.request.http_session.get(
dm_web_cli.ROOM_INIT_URL,
headers={
**utils.request.BILIBILI_COMMON_HEADERS,
'Origin': 'https://live.bilibili.com',
'Referer': f'https://live.bilibili.com/{room_id}'
},
params={
'room_id': room_id
}
) as res:
if res.status != 200:
logger.warning('room=%d _get_room_info failed: %d %s', room_id,
res.status, res.reason)
return room_id, 0
data = await res.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
logger.exception('room=%d _get_room_info failed', room_id)
return room_id, 0
if data['code'] != 0:
logger.warning('room=%d _get_room_info failed: %s', room_id, data['message'])
return room_id, 0
room_info = data['data']['room_info']
return room_info['room_id'], room_info['uid']
class AvatarHandler(api.base.ApiHandler):
async def get(self):
uid = int(self.get_query_argument('uid'))
avatar_url = await services.avatar.get_avatar_url_or_none(uid)
if avatar_url is None:
avatar_url = services.avatar.DEFAULT_AVATAR_URL
# 缓存3分钟
self.set_header('Cache-Control', 'private, max-age=180')
else:
# 缓存1天
self.set_header('Cache-Control', 'private, max-age=86400')
self.write({'avatarUrl': avatar_url})
class TextEmoticonMappingsHandler(api.base.ApiHandler):
async def get(self):
# 缓存1天
self.set_header('Cache-Control', 'private, max-age=86400')
cfg = config.get_config()
self.write({'textEmoticons': cfg.text_emoticons})
ROUTES = [
(r'/api/chat', ChatHandler),
(r'/api/room_info', RoomInfoHandler),
(r'/api/avatar_url', AvatarHandler),
(r'/api/text_emoticon_mappings', TextEmoticonMappingsHandler),
]

96
api/main.py Normal file
View File

@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
import asyncio
import hashlib
import logging
import os
import tornado.web
import api.base
import config
import update
logger = logging.getLogger(__name__)
EMOTICON_UPLOAD_PATH = os.path.join(config.DATA_PATH, 'emoticons')
EMOTICON_BASE_URL = '/emoticons'
class MainHandler(tornado.web.StaticFileHandler):
"""为了使用Vue Router的history模式把不存在的文件请求转发到index.html"""
async def get(self, path, include_body=True):
if path == '':
await self._get_index(include_body)
return
try:
await super().get(path, include_body)
except tornado.web.HTTPError as e:
if e.status_code != 404:
raise
# 不存在的文件请求转发到index.html交给前端路由
await self._get_index(include_body)
async def _get_index(self, include_body=True):
# index.html不缓存防止更新后前端还是旧版
self.set_header('Cache-Control', 'no-cache')
await super().get('index.html', include_body)
class ServerInfoHandler(api.base.ApiHandler):
async def get(self):
cfg = config.get_config()
self.write({
'version': update.VERSION,
'config': {
'enableTranslate': cfg.enable_translate,
'enableUploadFile': cfg.enable_upload_file,
'loaderUrl': cfg.loader_url
}
})
class UploadEmoticonHandler(api.base.ApiHandler):
async def post(self):
cfg = config.get_config()
if not cfg.enable_upload_file:
raise tornado.web.HTTPError(403)
try:
file = self.request.files['file'][0]
except LookupError:
raise tornado.web.MissingArgumentError('file')
if len(file.body) > 1024 * 1024:
raise tornado.web.HTTPError(413, 'file is too large, size=%d', len(file.body))
if not file.content_type.lower().startswith('image/'):
raise tornado.web.HTTPError(415)
url = await asyncio.get_running_loop().run_in_executor(
None, self._save_file, file.body, self.request.remote_ip
)
self.write({'url': url})
@staticmethod
def _save_file(body, client):
md5 = hashlib.md5(body).hexdigest()
filename = md5 + '.png'
path = os.path.join(EMOTICON_UPLOAD_PATH, filename)
logger.info('client=%s uploaded file, path=%s, size=%d', client, path, len(body))
tmp_path = path + '.tmp'
with open(tmp_path, 'wb') as f:
f.write(body)
os.replace(tmp_path, path)
return f'{EMOTICON_BASE_URL}/{filename}'
ROUTES = [
(r'/api/server_info', ServerInfoHandler),
(r'/api/emoticon', UploadEmoticonHandler),
]
# 通配的放在最后
LAST_ROUTES = [
(rf'{EMOTICON_BASE_URL}/(.*)', tornado.web.StaticFileHandler, {'path': EMOTICON_UPLOAD_PATH}),
(r'/(.*)', MainHandler, {'path': config.WEB_ROOT}),
]

194
api/open_live.py Normal file
View File

@@ -0,0 +1,194 @@
# -*- coding: utf-8 -*-
import asyncio
import datetime
import hashlib
import hmac
import json
import logging
import random
from typing import *
import aiohttp
import tornado.web
import api.base
import config
import utils.request
logger = logging.getLogger(__name__)
START_GAME_OPEN_LIVE_URL = 'https://live-open.biliapi.com/v2/app/start'
END_GAME_OPEN_LIVE_URL = 'https://live-open.biliapi.com/v2/app/end'
GAME_HEARTBEAT_OPEN_LIVE_URL = 'https://live-open.biliapi.com/v2/app/heartbeat'
COMMON_SERVER_BASE_URL = 'https://chat.bilisc.com'
START_GAME_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/start_game'
END_GAME_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/end_game'
GAME_HEARTBEAT_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/game_heartbeat'
class TransportError(Exception):
"""网络错误或HTTP状态码错误"""
class BusinessError(Exception):
"""业务返回码错误"""
def __init__(self, data: dict):
super().__init__(f"message={data['message']}, request_id={data['request_id']}")
self.data = data
@property
def code(self) -> int:
return self.data['code']
async def request_open_live_or_common_server(open_live_url, common_server_url, body: dict) -> dict:
"""如果配置了开放平台,则直接请求,否则转发请求到公共服务器的内部接口"""
cfg = config.get_config()
if cfg.is_open_live_configured:
return await _request_open_live(open_live_url, body)
try:
req_ctx_mgr = utils.request.http_session.post(common_server_url, json=body)
return await _read_response(req_ctx_mgr)
except TransportError:
logger.exception('Request common server failed:')
raise
except BusinessError as e:
logger.warning('Request common server failed: %s', e)
raise
async def _request_open_live(url, body: dict) -> dict:
cfg = config.get_config()
assert cfg.is_open_live_configured
body_bytes = json.dumps(body).encode('utf-8')
headers = {
'x-bili-accesskeyid': cfg.open_live_access_key_id,
'x-bili-content-md5': hashlib.md5(body_bytes).hexdigest(),
'x-bili-signature-method': 'HMAC-SHA256',
'x-bili-signature-nonce': str(random.randint(0, 999999999)),
'x-bili-signature-version': '1.0',
'x-bili-timestamp': str(int(datetime.datetime.now().timestamp())),
}
str_to_sign = '\n'.join(
f'{key}:{value}'
for key, value in headers.items()
)
signature = hmac.new(
cfg.open_live_access_key_secret.encode('utf-8'), str_to_sign.encode('utf-8'), hashlib.sha256
).hexdigest()
headers['Authorization'] = signature
headers['Content-Type'] = 'application/json'
headers['Accept'] = 'application/json'
try:
req_ctx_mgr = utils.request.http_session.post(url, headers=headers, data=body_bytes)
return await _read_response(req_ctx_mgr)
except TransportError:
logger.exception('Request open live failed:')
raise
except BusinessError as e:
logger.warning('Request open live failed: %s', e)
raise
async def _read_response(req_ctx_mgr: AsyncContextManager[aiohttp.ClientResponse]) -> dict:
try:
async with req_ctx_mgr as r:
r.raise_for_status()
data = await r.json()
code = data['code']
if code != 0:
raise BusinessError(data)
return data
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
raise TransportError(f'{type(e).__name__}: {e}')
class _OpenLiveHandlerBase(api.base.ApiHandler):
def prepare(self):
super().prepare()
if not isinstance(self.json_args, dict):
raise tornado.web.MissingArgumentError('body')
if 'app_id' in self.json_args:
cfg = config.get_config()
self.json_args['app_id'] = cfg.open_live_app_id
logger.info('client=%s requesting open live, cls=%s', self.request.remote_ip, type(self).__name__)
class _PublicHandlerBase(_OpenLiveHandlerBase):
"""外部接口,如果配置了开放平台,则直接请求,否则转发请求到公共服务器的内部接口"""
_OPEN_LIVE_URL: str
_COMMON_SERVER_URL: str
async def post(self):
try:
res = await request_open_live_or_common_server(
self._OPEN_LIVE_URL, self._COMMON_SERVER_URL, self.json_args
)
except TransportError:
raise tornado.web.HTTPError(500)
except BusinessError as e:
res = e.data
self.write(res)
class _PrivateHandlerBase(_OpenLiveHandlerBase):
"""内部接口,如果配置了开放平台,则直接请求,否则响应错误"""
_OPEN_LIVE_URL: str
async def post(self):
cfg = config.get_config()
if not cfg.is_open_live_configured:
raise tornado.web.HTTPError(501)
try:
res = await _request_open_live(self._OPEN_LIVE_URL, self.json_args)
except TransportError:
raise tornado.web.HTTPError(500)
except BusinessError as e:
res = e.data
self.write(res)
class StartGamePublicHandler(_PublicHandlerBase):
_OPEN_LIVE_URL = START_GAME_OPEN_LIVE_URL
_COMMON_SERVER_URL = START_GAME_COMMON_SERVER_URL
class StartGamePrivateHandler(_PrivateHandlerBase):
_OPEN_LIVE_URL = START_GAME_OPEN_LIVE_URL
class EndGamePublicHandler(_PublicHandlerBase):
_OPEN_LIVE_URL = END_GAME_OPEN_LIVE_URL
_COMMON_SERVER_URL = END_GAME_COMMON_SERVER_URL
class EndGamePrivateHandler(_PrivateHandlerBase):
_OPEN_LIVE_URL = END_GAME_OPEN_LIVE_URL
class GameHeartbeatPublicHandler(_PublicHandlerBase):
_OPEN_LIVE_URL = GAME_HEARTBEAT_OPEN_LIVE_URL
_COMMON_SERVER_URL = GAME_HEARTBEAT_COMMON_SERVER_URL
class GameHeartbeatPrivateHandler(_PrivateHandlerBase):
_OPEN_LIVE_URL = GAME_HEARTBEAT_OPEN_LIVE_URL
ROUTES = [
(r'/api/open_live/start_game', StartGamePublicHandler),
(r'/api/internal/open_live/start_game', StartGamePrivateHandler),
(r'/api/open_live/end_game', EndGamePublicHandler),
(r'/api/internal/open_live/end_game', EndGamePrivateHandler),
(r'/api/open_live/game_heartbeat', GameHeartbeatPublicHandler),
(r'/api/internal/open_live/game_heartbeat', GameHeartbeatPrivateHandler),
]

Submodule blivedm updated: f755468908...b1d066b59f

238
chat.py
View File

@@ -1,238 +0,0 @@
# -*- coding: utf-8 -*-
import asyncio
import enum
import json
import logging
import time
from typing import *
import aiohttp
import tornado.websocket
import blivedm.blivedm as blivedm
logger = logging.getLogger(__name__)
class Command(enum.IntEnum):
JOIN_ROOM = 0
ADD_TEXT = 1
ADD_GIFT = 2
ADD_VIP = 3
_http_session = aiohttp.ClientSession()
_avatar_url_cache: Dict[int, str] = {}
async def get_avatar_url(user_id):
if user_id in _avatar_url_cache:
return _avatar_url_cache[user_id]
async with _http_session.get('https://api.bilibili.com/x/space/acc/info',
params={'mid': user_id}) as r:
data = await r.json()
url = data['data']['face']
if not url.endswith('noface.gif'):
url += '@24w_24h.webp'
_avatar_url_cache[user_id] = url
if len(_avatar_url_cache) > 10000:
for _, key in zip(range(100), _avatar_url_cache):
del _avatar_url_cache[key]
return url
class Room(blivedm.BLiveClient):
_COMMAND_HANDLERS = blivedm.BLiveClient._COMMAND_HANDLERS.copy()
def __init__(self, room_id):
super().__init__(room_id, session=_http_session)
self.future = None
self.clients: List['ChatHandler'] = []
self.owner_id = None
def start(self):
self.future = self.run()
def stop(self):
if self.future is not None:
self.future.cancel()
asyncio.ensure_future(self.close())
async def _get_room_id(self):
"""重载用来获取主播UID"""
async with self._session.get(blivedm.ROOM_INIT_URL,
params={'id': self._short_id},
ssl=self._ssl) as res:
if res.status == 200:
data = await res.json()
if data['code'] == 0:
self._room_id = data['data']['room_id']
self.owner_id = data['data']['uid']
else:
raise ConnectionAbortedError('获取房间ID失败' + data['msg'])
else:
raise ConnectionAbortedError('获取房间ID失败' + res.reason)
def send_message(self, cmd, data):
body = json.dumps({'cmd': cmd, 'data': data})
for client in self.clients:
client.write_message(body)
async def __my_on_get_danmaku(self, command):
if command['info'][2][0] == self.owner_id:
author_type = 3 # 主播
elif command['info'][2][2] != 0:
author_type = 2 # 房管
elif command['info'][7] != 0: # 1总督2提督3舰长
author_type = 1 # 舰队
else:
author_type = 0
self.send_message(Command.ADD_TEXT, {
'avatarUrl': await get_avatar_url(command['info'][2][0]),
'timestamp': command['info'][0][4],
'authorName': command['info'][2][1],
'authorType': author_type,
'content': command['info'][1]
})
_COMMAND_HANDLERS['DANMU_MSG'] = __my_on_get_danmaku
async def __my_on_gift(self, command):
if command['data']['coin_type'] != 'gold': # 丢人
return
self.send_message(Command.ADD_GIFT, {
'avatarUrl': await get_avatar_url(command['data']['uid']),
'authorName': command['data']['uname'],
'giftName': command['data']['giftName'],
'giftNum': command['data']['num'],
'totalCoin': command['data']['total_coin']
})
_COMMAND_HANDLERS['SEND_GIFT'] = __my_on_gift
async def __on_new_member(self, command):
# 新舰长 {'cmd': 'GUARD_BUY', 'data': {'uid': 1822222, 'username': 'MRSKING', 'guard_level': 3,
# 'num': 1, 'price': 198000, 'gift_id': 10003, 'gift_name': '舰长', 'start_time': 1558506165,
# 'end_time': 1558506165}}
self.send_message(Command.ADD_VIP, {
'avatarUrl': await get_avatar_url(command['data']['uid']),
'authorName': command['data']['username'],
})
_COMMAND_HANDLERS['GUARD_BUY'] = __on_new_member
class RoomManager:
def __init__(self):
self._rooms: Dict[int, Room] = {}
def add_client(self, room_id, client):
if room_id in self._rooms:
room = self._rooms[room_id]
else:
logger.info('创建房间%d', room_id)
room = Room(room_id)
self._rooms[room_id] = room
room.start()
room.clients.append(client)
if client.application.settings['debug']:
self.__send_test_message(room)
def del_client(self, room_id, client: 'ChatHandler'):
if room_id not in self._rooms:
return
room = self._rooms[room_id]
room.clients.remove(client)
if not room.clients:
logger.info('移除房间%d', room_id)
room.stop()
del self._rooms[room_id]
# 测试用
@staticmethod
def __send_test_message(room):
room.send_message(Command.ADD_TEXT, {
'avatarUrl': 'https://i0.hdslb.com/bfs/face/29b6be8aa611e70a3d3ac219cdaf5e72b604f2de.jpg@24w_24h.webp',
'timestamp': time.time(),
'authorName': 'xfgryujk',
'authorType': 0,
'content': '我能吞下玻璃而不伤身体'
})
room.send_message(Command.ADD_TEXT, {
'avatarUrl': 'https://i0.hdslb.com/bfs/face/29b6be8aa611e70a3d3ac219cdaf5e72b604f2de.jpg@24w_24h.webp',
'timestamp': time.time(),
'authorName': '主播',
'authorType': 3,
'content': "I can eat glass, it doesn't hurt me."
})
room.send_message(Command.ADD_VIP, {
'avatarUrl': 'https://i0.hdslb.com/bfs/face/29b6be8aa611e70a3d3ac219cdaf5e72b604f2de.jpg@24w_24h.webp',
'authorName': 'xfgryujk',
})
room.send_message(Command.ADD_GIFT, {
'avatarUrl': 'https://i0.hdslb.com/bfs/face/29b6be8aa611e70a3d3ac219cdaf5e72b604f2de.jpg@24w_24h.webp',
'authorName': 'xfgryujk',
'giftName': '礼花',
'giftNum': 1,
'totalCoin': 28000
})
room.send_message(Command.ADD_GIFT, {
'avatarUrl': 'https://i0.hdslb.com/bfs/face/29b6be8aa611e70a3d3ac219cdaf5e72b604f2de.jpg@24w_24h.webp',
'authorName': 'xfgryujk',
'giftName': '节奏风暴',
'giftNum': 1,
'totalCoin': 100000
})
room.send_message(Command.ADD_GIFT, {
'avatarUrl': 'https://i0.hdslb.com/bfs/face/29b6be8aa611e70a3d3ac219cdaf5e72b604f2de.jpg@24w_24h.webp',
'authorName': 'xfgryujk',
'giftName': '摩天大楼',
'giftNum': 1,
'totalCoin': 450000
})
room.send_message(Command.ADD_GIFT, {
'avatarUrl': 'https://i0.hdslb.com/bfs/face/29b6be8aa611e70a3d3ac219cdaf5e72b604f2de.jpg@24w_24h.webp',
'authorName': 'xfgryujk',
'giftName': '小电视飞船',
'giftNum': 1,
'totalCoin': 1245000
})
room_manager = RoomManager()
# noinspection PyAbstractClass
class ChatHandler(tornado.websocket.WebSocketHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.room_id = None
def open(self):
logger.info('Websocket连接 %s', self.request.remote_ip)
def on_message(self, message):
if self.room_id is not None:
return
body = json.loads(message)
if body['cmd'] == Command.JOIN_ROOM:
self.room_id = int(body['data']['roomId'])
logger.info('客户端%s加入房间%d', self.request.remote_ip, self.room_id)
room_manager.add_client(self.room_id, self)
else:
logger.warning('未知的命令: %s data: %s', body['cmd'], body['data'])
def on_close(self):
logger.info('Websocket断开 %s room: %s', self.request.remote_ip, self.room_id)
if self.room_id is not None:
room_manager.del_client(self.room_id, self)
# 跨域测试用
def check_origin(self, origin):
if self.application.settings['debug']:
return True
return super().check_origin(origin)

175
config.py Normal file
View File

@@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
import configparser
import logging
import os
from typing import *
logger = logging.getLogger(__name__)
BASE_PATH = os.path.dirname(os.path.realpath(__file__))
WEB_ROOT = os.path.join(BASE_PATH, 'frontend', 'dist')
DATA_PATH = os.path.join(BASE_PATH, 'data')
CONFIG_PATH_LIST = [
os.path.join(DATA_PATH, 'config.ini'),
os.path.join(DATA_PATH, 'config.example.ini')
]
_config: Optional['AppConfig'] = None
def init():
if reload():
return
logger.warning('Using default config')
global _config
_config = AppConfig()
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):
return False
global _config
_config = config
return True
def get_config():
return _config
class AppConfig:
def __init__(self):
self.host = '127.0.0.1'
self.port = 12450
self.database_url = 'sqlite:///data/database.db'
self.tornado_xheaders = False
self.loader_url = ''
self.open_browser_at_startup = True
self.enable_upload_file = True
self.fetch_avatar_max_queue_size = 4
self.avatar_cache_size = 10000
self.open_live_access_key_id = ''
self.open_live_access_key_secret = ''
self.open_live_app_id = 0
self.enable_translate = True
self.allow_translate_rooms = set()
self.translate_max_queue_size = 10
self.translation_cache_size = 50000
self.translator_configs = []
self.text_emoticons = []
@property
def is_open_live_configured(self):
return (
self.open_live_access_key_id != '' and self.open_live_access_key_secret != '' and self.open_live_app_id != 0
)
def load(self, path):
try:
config = configparser.ConfigParser()
config.read(path, 'utf-8-sig')
self._load_app_config(config)
self._load_translator_configs(config)
self._load_text_emoticons(config)
except Exception: # noqa
logger.exception('Failed to load config:')
return False
return True
def _load_app_config(self, config: configparser.ConfigParser):
app_section = config['app']
self.host = app_section.get('host', self.host)
self.port = app_section.getint('port', self.port)
self.database_url = app_section.get('database_url', self.database_url)
self.tornado_xheaders = app_section.getboolean('tornado_xheaders', self.tornado_xheaders)
self.loader_url = app_section.get('loader_url', self.loader_url)
self.open_browser_at_startup = app_section.getboolean('open_browser_at_startup', self.open_browser_at_startup)
self.enable_upload_file = app_section.getboolean('enable_upload_file', self.enable_upload_file)
self.fetch_avatar_max_queue_size = app_section.getint(
'fetch_avatar_max_queue_size', self.fetch_avatar_max_queue_size
)
self.avatar_cache_size = app_section.getint('avatar_cache_size', self.avatar_cache_size)
self.open_live_access_key_id = app_section.get('open_live_access_key_id', self.open_live_access_key_id)
self.open_live_access_key_secret = app_section.get(
'open_live_access_key_secret', self.open_live_access_key_secret
)
self.open_live_app_id = app_section.getint('open_live_app_id', self.open_live_app_id)
self.enable_translate = app_section.getboolean('enable_translate', self.enable_translate)
self.allow_translate_rooms = _str_to_list(app_section.get('allow_translate_rooms', ''), int, set)
self.translate_max_queue_size = app_section.getint('translate_max_queue_size', self.translate_max_queue_size)
self.translation_cache_size = app_section.getint('translation_cache_size', self.translation_cache_size)
def _load_translator_configs(self, config: configparser.ConfigParser):
app_section = config['app']
section_names = _str_to_list(app_section.get('translator_configs', ''))
translator_configs = []
for section_name in section_names:
try:
section = config[section_name]
type_ = section['type']
translator_config = {
'type': type_,
'query_interval': section.getfloat('query_interval'),
}
if type_ == 'TencentTranslateFree':
translator_config['source_language'] = section['source_language']
translator_config['target_language'] = section['target_language']
elif type_ == 'BilibiliTranslateFree':
pass
elif type_ == 'TencentTranslate':
translator_config['source_language'] = section['source_language']
translator_config['target_language'] = section['target_language']
translator_config['secret_id'] = section['secret_id']
translator_config['secret_key'] = section['secret_key']
translator_config['region'] = section['region']
elif type_ == 'BaiduTranslate':
translator_config['source_language'] = section['source_language']
translator_config['target_language'] = section['target_language']
translator_config['app_id'] = section['app_id']
translator_config['secret'] = section['secret']
else:
raise ValueError(f'Invalid translator type: {type_}')
except Exception: # noqa
logger.exception('Failed to load translator=%s config:', section_name)
continue
translator_configs.append(translator_config)
self.translator_configs = translator_configs
def _load_text_emoticons(self, config: configparser.ConfigParser):
mappings_section = config['text_emoticon_mappings']
text_emoticons = []
for value in mappings_section.values():
keyword, _, url = value.partition(',')
text_emoticons.append({'keyword': keyword, 'url': url})
self.text_emoticons = text_emoticons
def _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)

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

@@ -0,0 +1,232 @@
# 如果要修改配置可以复制此文件并重命名为“config.ini”再修改
# If you want to modify the configuration, copy this file and rename it to "config.ini" and edit
[app]
# 服务器监听的地址
# The address the server listens on
host = 127.0.0.1
port = 12450
# 数据库配置,见 https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls
# See https://docs.sqlalchemy.org/en/20/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
# 启动时打开浏览器
# Open browser at startup
open_browser_at_startup = true
# 允许上传自定义表情文件
# Enable uploading custom emote file
enable_upload_file = true
# 获取头像最大队列长度
# Maximum queue length for fetching avatar
fetch_avatar_max_queue_size = 4
# 内存中头像缓存数量
# Number of avatar caches in memory
avatar_cache_size = 10000
# 允许自动翻译到日语
# Enable auto translate to Japanese
enable_translate = true
# 允许翻译的房间ID以逗号分隔。如果为空允许所有房间
# Comma separated room IDs in which translation are allowed. If empty, all rooms are allowed
# Example: allow_translate_rooms = 4895312,22347054,21693691
allow_translate_rooms =
# 翻译最大队列长度
# Maximum queue length for translating
translate_max_queue_size = 10
# 翻译缓存数量
# Number of translation caches
translation_cache_size = 50000
# -------------------------------------------------------------------------------------------------
# 以下是给字幕组看的实在懒得翻译了_(:з」∠)_。如果你不了解以下参数的意思使用默认值就好
# **The following is for translation team. Leave it default if you don't know its meaning**
# -------------------------------------------------------------------------------------------------
# 在B站直播开放平台申请的开发者密钥如果不填会把请求转发到作者的服务器
open_live_access_key_id =
open_live_access_key_secret =
# 在B站直播开放平台创建的项目ID如果不填会把请求转发到作者的服务器
open_live_app_id = 0
# 翻译器配置,索引到下面的配置节。可以以逗号分隔配置多个翻译器,翻译时会自动负载均衡
# 配置多个翻译器可以增加额度、增加QPS、容灾
# 不同配置可以使用同一个类型,但要使用不同的账号,否则还是会遇到额度、调用频率限制
translator_configs = tencent_translate_free
[tencent_translate_free]
# 类型:腾讯翻译白嫖版。使用了网页版的接口,**将来可能失效**
type = TencentTranslateFree
# 请求间隔时间(秒),等于 1 / QPS
query_interval = 1
# 自动auto中文zh日语jp英语en韩语kr
# 完整语言列表见文档https://cloud.tencent.com/document/product/551/15619
# 源语言
source_language = zh
# 目标语言
target_language = jp
[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
# 自动auto中文zh日语ja英语en韩语ko
# 完整语言列表见文档https://cloud.tencent.com/document/product/551/15619
# 源语言
source_language = zh
# 目标语言
target_language = ja
# 腾讯云API密钥
secret_id =
secret_key =
# 腾讯云地域参数用来标识希望操作哪个地域的数据建议按照运行blivechat的机器所在地区就近选择
# 北京ap-beijing上海ap-shanghai香港ap-hongkong首尔ap-seoul
# 完整地域列表见文档https://cloud.tencent.com/document/api/551/15615#.E5.9C.B0.E5.9F.9F.E5.88.97.E8.A1.A8
region = ap-shanghai
[baidu_translate]
# 文档https://fanyi-api.baidu.com/
# 定价https://fanyi-api.baidu.com/product/112
# * 标准版每月前5万字符免费超出仅收取超出部分费用QPS=1按49元/百万字符计费
# * 高级版每月前100万字符免费超出仅收取超出部分费用QPS=10按49元/百万字符计费
# * 尊享版每月前200万字符免费超出后仅收取超出部分费用QPS=100按49元/百万字符计费
# 类型:百度翻译
type = BaiduTranslate
# 请求间隔时间(秒),等于 1 / QPS
query_interval = 1.5
# 自动auto中文zh日语jp英语en韩语kor
# 完整语言列表见文档https://fanyi-api.baidu.com/doc/21
# 源语言
source_language = zh
# 目标语言
target_language = jp
# 百度翻译开放平台应用ID和密钥
app_id =
secret =
# 傻逼B站获取表情都要登录开放平台也不发文本表情的URL我服了
[text_emoticon_mappings]
1 = [dog],http://i0.hdslb.com/bfs/live/4428c84e694fbf4e0ef6c06e958d9352c3582740.png
2 = [花],http://i0.hdslb.com/bfs/live/7dd2ef03e13998575e4d8a803c6e12909f94e72b.png
3 = [妙],http://i0.hdslb.com/bfs/live/08f735d950a0fba267dda140673c9ab2edf6410d.png
4 = [哇],http://i0.hdslb.com/bfs/live/650c3e22c06edcbca9756365754d38952fc019c3.png
5 = [爱],http://i0.hdslb.com/bfs/live/1daaa5d284dafaa16c51409447da851ff1ec557f.png
6 = [手机],http://i0.hdslb.com/bfs/live/b159f90431148a973824f596288e7ad6a8db014b.png
7 = [撇嘴],http://i0.hdslb.com/bfs/live/4255ce6ed5d15b60311728a803d03dd9a24366b2.png
8 = [委屈],http://i0.hdslb.com/bfs/live/69312e99a00d1db2de34ef2db9220c5686643a3f.png
9 = [抓狂],http://i0.hdslb.com/bfs/live/a7feb260bb5b15f97d7119b444fc698e82516b9f.png
10 = [比心],http://i0.hdslb.com/bfs/live/4e029593562283f00d39b99e0557878c4199c71d.png
11 = [赞],http://i0.hdslb.com/bfs/live/2dd666d3651bafe8683acf770b7f4163a5f49809.png
12 = [滑稽],http://i0.hdslb.com/bfs/live/8624fd172037573c8600b2597e3731ef0e5ea983.png
13 = [吃瓜],http://i0.hdslb.com/bfs/live/ffb53c252b085d042173379ac724694ce3196194.png
14 = [笑哭],http://i0.hdslb.com/bfs/live/c5436c6806c32b28d471bb23d42f0f8f164a187a.png
15 = [捂脸],http://i0.hdslb.com/bfs/live/e6073c6849f735ae6cb7af3a20ff7dcec962b4c5.png
16 = [喝彩],http://i0.hdslb.com/bfs/live/b51824125d09923a4ca064f0c0b49fc97d3fab79.png
17 = [偷笑],http://i0.hdslb.com/bfs/live/e2ba16f947a23179cdc00420b71cc1d627d8ae25.png
18 = [大笑],http://i0.hdslb.com/bfs/live/e2589d086df0db8a7b5ca2b1273c02d31d4433d4.png
19 = [惊喜],http://i0.hdslb.com/bfs/live/9c75761c5b6e1ff59b29577deb8e6ad996b86bd7.png
20 = [傲娇],http://i0.hdslb.com/bfs/live/b5b44f099059a1bafb2c2722cfe9a6f62c1dc531.png
21 = [疼],http://i0.hdslb.com/bfs/live/492b10d03545b7863919033db7d1ae3ef342df2f.png
22 = [吓],http://i0.hdslb.com/bfs/live/c6bed64ffb78c97c93a83fbd22f6fdf951400f31.png
23 = [阴险],http://i0.hdslb.com/bfs/live/a4df45c035b0ca0c58f162b5fb5058cf273d0d09.png
24 = [惊讶],http://i0.hdslb.com/bfs/live/bc26f29f62340091737c82109b8b91f32e6675ad.png
25 = [生病],http://i0.hdslb.com/bfs/live/84c92239591e5ece0f986c75a39050a5c61c803c.png
26 = [嘘],http://i0.hdslb.com/bfs/live/b6226219384befa5da1d437cb2ff4ba06c303844.png
27 = [奸笑],http://i0.hdslb.com/bfs/live/5935e6a4103d024955f749d428311f39e120a58a.png
28 = [囧],http://i0.hdslb.com/bfs/live/204413d3cf330e122230dcc99d29056f2a60e6f2.png
29 = [捂脸2],http://i0.hdslb.com/bfs/live/a2ad0cc7e390a303f6d243821479452d31902a5f.png
30 = [出窍],http://i0.hdslb.com/bfs/live/bb8e95fa54512ffea07023ea4f2abee4a163e7a0.png
31 = [吐了啊],http://i0.hdslb.com/bfs/live/2b6b4cc33be42c3257dc1f6ef3a39d666b6b4b1a.png
32 = [鼻子],http://i0.hdslb.com/bfs/live/f4ed20a70d0cb85a22c0c59c628aedfe30566b37.png
33 = [调皮],http://i0.hdslb.com/bfs/live/84fe12ecde5d3875e1090d83ac9027cb7d7fba9f.png
34 = [酸],http://i0.hdslb.com/bfs/live/98fd92c6115b0d305f544b209c78ec322e4bb4ff.png
35 = [冷],http://i0.hdslb.com/bfs/live/b804118a1bdb8f3bec67d9b108d5ade6e3aa93a9.png
36 = [OK],http://i0.hdslb.com/bfs/live/86268b09e35fbe4215815a28ef3cf25ec71c124f.png
37 = [微笑],http://i0.hdslb.com/bfs/live/f605dd8229fa0115e57d2f16cb019da28545452b.png
38 = [藏狐],http://i0.hdslb.com/bfs/live/05ef7849e7313e9c32887df922613a7c1ad27f12.png
39 = [龇牙],http://i0.hdslb.com/bfs/live/8b99266ea7b9e86cf9d25c3d1151d80c5ba5c9a1.png
40 = [防护],http://i0.hdslb.com/bfs/live/17435e60dcc28ce306762103a2a646046ff10b0a.png
41 = [笑],http://i0.hdslb.com/bfs/live/a91a27f83c38b5576f4cd08d4e11a2880de78918.png
42 = [一般],http://i0.hdslb.com/bfs/live/8d436de0c3701d87e4ca9c1be01c01b199ac198e.png
43 = [嫌弃],http://i0.hdslb.com/bfs/live/c409425ba1ad2c6534f0df7de350ba83a9c949e5.png
44 = [无语],http://i0.hdslb.com/bfs/live/4781a77be9c8f0d4658274eb4e3012c47a159f23.png
45 = [哈欠],http://i0.hdslb.com/bfs/live/6e496946725cd66e7ff1b53021bf1cc0fc240288.png
46 = [可怜],http://i0.hdslb.com/bfs/live/8e88e6a137463703e96d4f27629f878efa323456.png
47 = [歪嘴笑],http://i0.hdslb.com/bfs/live/bea1f0497888f3e9056d3ce14ba452885a485c02.png
48 = [亲亲],http://i0.hdslb.com/bfs/live/10662d9c0d6ddb3203ecf50e77788b959d4d1928.png
49 = [问号],http://i0.hdslb.com/bfs/live/a0c456b6d9e3187399327828a9783901323bfdb5.png
50 = [波吉],http://i0.hdslb.com/bfs/live/57dee478868ed9f1ce3cf25a36bc50bde489c404.png
51 = [OH],http://i0.hdslb.com/bfs/live/0d5123cddf389302df6f605087189fd10919dc3c.png
52 = [再见],http://i0.hdslb.com/bfs/live/f408e2af700adcc2baeca15510ef620bed8d4c43.png
53 = [白眼],http://i0.hdslb.com/bfs/live/7fa907ae85fa6327a0466e123aee1ac32d7c85f7.png
54 = [鼓掌],http://i0.hdslb.com/bfs/live/d581d0bc30c8f9712b46ec02303579840c72c42d.png
55 = [大哭],http://i0.hdslb.com/bfs/live/816402551e6ce30d08b37a917f76dea8851fe529.png
56 = [呆],http://i0.hdslb.com/bfs/live/179c7e2d232cd74f30b672e12fc728f8f62be9ec.png
57 = [流汗],http://i0.hdslb.com/bfs/live/b00e2e02904096377061ec5f93bf0dd3321f1964.png
58 = [生气],http://i0.hdslb.com/bfs/live/2c69dad2e5c0f72f01b92746bc9d148aee1993b2.png
59 = [加油],http://i0.hdslb.com/bfs/live/fbc3c8bc4152a65bbf4a9fd5a5d27710fbff2119.png
60 = [害羞],http://i0.hdslb.com/bfs/live/d8ce9b05c0e40cec61a15ba1979c8517edd270bf.png
61 = [虎年],http://i0.hdslb.com/bfs/live/a51af0d7d9e60ce24f139c468a3853f9ba9bb184.png
62 = [doge2],http://i0.hdslb.com/bfs/live/f547cc853cf43e70f1e39095d9b3b5ac1bf70a8d.png
63 = [金钱豹],http://i0.hdslb.com/bfs/live/b6e8131897a9a718ee280f2510bfa92f1d84429b.png
64 = [瓜子],http://i0.hdslb.com/bfs/live/fd35718ac5a278fd05fe5287ebd41de40a59259d.png
65 = [墨镜],http://i0.hdslb.com/bfs/live/5e01c237642c8b662a69e21b8e0fbe6e7dbc2aa1.png
66 = [难过],http://i0.hdslb.com/bfs/live/5776481e380648c0fb3d4ad6173475f69f1ce149.png
67 = [抱抱],http://i0.hdslb.com/bfs/live/abddb0b621b389fc8c2322b1cfcf122d8936ba91.png
68 = [跪了],http://i0.hdslb.com/bfs/live/4f2155b108047d60c1fa9dccdc4d7abba18379a0.png
69 = [摊手],http://i0.hdslb.com/bfs/live/1e0a2baf088a34d56e2cc226b2de36a5f8d6c926.png
70 = [热],http://i0.hdslb.com/bfs/live/6df760280b17a6cbac8c1874d357298f982ba4cf.png
71 = [三星堆],http://i0.hdslb.com/bfs/live/0a1ab3f0f2f2e29de35c702ac1ecfec7f90e325d.png
72 = [鼠],http://i0.hdslb.com/bfs/live/98f842994035505c728e32e32045d649e371ecd6.png
73 = [汤圆],http://i0.hdslb.com/bfs/live/23ae12d3a71b9d7a22c8773343969fcbb94b20d0.png
74 = [泼水],http://i0.hdslb.com/bfs/live/29533893115c4609a4af336f49060ea13173ca78.png
75 = [鬼魂],http://i0.hdslb.com/bfs/live/5d86d55ba9a2f99856b523d8311cf75cfdcccdbc.png
76 = [不行],http://i0.hdslb.com/bfs/live/607f74ccf5eec7d2b17d91b9bb36be61a5dd196b.png
77 = [响指],http://i0.hdslb.com/bfs/live/3b2fedf09b0ac79679b5a47f5eb3e8a38e702387.png
78 = [牛],http://i0.hdslb.com/bfs/live/5e61223561203c50340b4c9b41ba7e4b05e48ae2.png
79 = [保佑],http://i0.hdslb.com/bfs/live/241b13adb4933e38b7ea6f5204e0648725e76fbf.png
80 = [抱拳],http://i0.hdslb.com/bfs/live/3f170894dd08827ee293afcb5a3d2b60aecdb5b1.png
81 = [给力],http://i0.hdslb.com/bfs/live/d1ba5f4c54332a21ed2ca0dcecaedd2add587839.png
82 = [耶],http://i0.hdslb.com/bfs/live/eb2d84ba623e2335a48f73fb5bef87bcf53c1239.png

0
data/emoticons/.gitkeep Normal file
View File

2
frontend/.eslintignore Normal file
View File

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

81
frontend/.eslintrc.js Normal file
View File

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

3
frontend/.gitignore vendored
View File

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

View File

@@ -1,5 +1,13 @@
module.exports = {
presets: [
'@vue/app'
'@vue/cli-plugin-babel/preset'
],
plugins: [
[
'component', {
'libraryName': 'element-ui',
'styleLibraryName': 'theme-chalk'
}
]
]
}

17
frontend/jsconfig.json Normal file
View File

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

11483
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "blivechat",
"version": "0.1.0",
"version": "1.8.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
@@ -8,40 +8,29 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"core-js": "^2.6.5",
"vue": "^2.6.10",
"vue-router": "^3.0.6"
"axios": "^1.4.0",
"core-js": "^3.8.3",
"downloadjs": "^1.4.7",
"element-ui": "^2.15.13",
"lodash": "^4.17.21",
"pako": "^2.1.0",
"vue": "^2.7.14",
"vue-i18n": "^8.28.2",
"vue-router": "^3.6.5"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^3.7.0",
"@vue/cli-plugin-eslint": "^3.7.0",
"@vue/cli-service": "^3.7.0",
"babel-eslint": "^10.0.1",
"eslint": "^5.16.0",
"eslint-plugin-vue": "^5.0.0",
"vue-template-compiler": "^2.5.21"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"rules": {},
"parserOptions": {
"parser": "babel-eslint"
}
},
"postcss": {
"plugins": {
"autoprefixer": {}
}
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"babel-plugin-component": "^1.1.1",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^9.16.1"
},
"browserslist": [
"> 1%",
"last 2 versions"
"last 2 versions",
"not dead"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -0,0 +1,255 @@
import axios from 'axios'
import * as chat from '.'
import * as base from './ChatClientOfficialBase'
import ChatClientOfficialBase from './ChatClientOfficialBase'
const GAME_HEARTBEAT_INTERVAL = 20 * 1000
export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
constructor(roomOwnerAuthCode) {
super()
this.CMD_CALLBACK_MAP = CMD_CALLBACK_MAP
this.roomOwnerAuthCode = roomOwnerAuthCode
// 调用initRoom后初始化
this.roomOwnerUid = null
this.hostServerUrlList = []
this.authBody = null
this.gameId = null
this.gameHeartbeatTimerId = null
}
stop() {
super.stop()
if (this.gameHeartbeatTimerId) {
window.clearInterval(this.gameHeartbeatTimerId)
this.gameHeartbeatTimerId = null
}
this.endGame()
}
async initRoom() {
if (!await this.startGame()) {
return false
}
if (this.gameId && this.gameHeartbeatTimerId === null) {
this.gameHeartbeatTimerId = window.setInterval(this.sendGameHeartbeat.bind(this), GAME_HEARTBEAT_INTERVAL)
}
return true
}
async startGame() {
let res
try {
res = (await axios.post('/api/open_live/start_game', {
code: this.roomOwnerAuthCode,
app_id: 0
})).data
if (res.code !== 0) {
throw Error(`code=${res.code}, message=${res.message}, request_id=${res.request_id}`)
}
} catch (e) {
console.error('startGame failed:', e)
return false
}
let data = res.data
this.gameId = data.game_info.game_id
let websocketInfo = data.websocket_info
this.authBody = websocketInfo.auth_body
this.hostServerUrlList = websocketInfo.wss_link
let anchorInfo = data.anchor_info
// this.roomId = anchorInfo.room_id
this.roomOwnerUid = anchorInfo.uid
return true
}
async endGame() {
if (!this.gameId) {
return true
}
try {
let res = (await axios.post('/api/open_live/end_game', {
app_id: 0,
game_id: this.gameId
})).data
if (res.code !== 0) {
if (res.code === 7000 || res.code === 7003) {
// 项目已经关闭了也算成功
return true
}
throw Error(`code=${res.code}, message=${res.message}, request_id=${res.request_id}`)
}
} catch (e) {
console.error('endGame failed:', e)
return false
}
return true
}
async sendGameHeartbeat() {
if (!this.gameId) {
return false
}
// 保存一下防止await之后gameId改变
let gameId = this.gameId
try {
let res = (await axios.post('/api/open_live/game_heartbeat', {
game_id: this.gameId
})).data
if (res.code !== 0) {
console.error(`sendGameHeartbeat failed: code=${res.code}, message=${res.message}, request_id=${res.request_id}`)
if (res.code === 7003 && this.gameId === gameId) {
// 项目异常关闭,可能是心跳超时,需要重新开启项目
this.needInitRoom = true
this.discardWebsocket()
}
return false
}
} catch (e) {
console.error('sendGameHeartbeat failed:', e)
return false
}
return true
}
async onBeforeWsConnect() {
// 重连次数太多则重新init_room保险
let reinitPeriod = Math.max(3, (this.hostServerUrlList || []).length)
if (this.retryCount > 0 && this.retryCount % reinitPeriod === 0) {
this.needInitRoom = true
}
return super.onBeforeWsConnect()
}
getWsUrl() {
return this.hostServerUrlList[this.retryCount % this.hostServerUrlList.length]
}
sendAuth() {
this.websocket.send(this.makePacket(this.authBody, base.OP_AUTH))
}
async dmCallback(command) {
if (!this.onAddText) {
return
}
let data = command.data
let authorType
if (data.uid === this.roomOwnerUid) {
authorType = 3
} else if (data.guard_level !== 0) {
authorType = 1
} else {
authorType = 0
}
let emoticon = null
if (data.dm_type === 1) {
emoticon = data.emoji_img_url
}
data = {
avatarUrl: chat.processAvatarUrl(data.uface),
timestamp: data.timestamp,
authorName: data.uname,
authorType: authorType,
content: data.msg,
privilegeType: data.guard_level,
isGiftDanmaku: false,
authorLevel: 1,
isNewbie: false,
isMobileVerified: true,
medalLevel: data.fans_medal_wearing_status ? data.fans_medal_level : 0,
id: data.msg_id,
translation: '',
emoticon: emoticon,
}
this.onAddText(data)
}
sendGiftCallback(command) {
if (!this.onAddGift) {
return
}
let data = command.data
if (!data.paid) { // 丢人
return
}
data = {
id: data.msg_id,
avatarUrl: chat.processAvatarUrl(data.uface),
timestamp: data.timestamp,
authorName: data.uname,
totalCoin: data.price,
giftName: data.gift_name,
num: data.gift_num
}
this.onAddGift(data)
}
async guardCallback(command) {
if (!this.onAddMember) {
return
}
let data = command.data
data = {
id: data.msg_id,
avatarUrl: chat.processAvatarUrl(data.user_info.uface),
timestamp: data.timestamp,
authorName: data.user_info.uname,
privilegeType: data.guard_level
}
this.onAddMember(data)
}
superChatCallback(command) {
if (!this.onAddSuperChat) {
return
}
let data = command.data
data = {
id: data.message_id.toString(),
avatarUrl: chat.processAvatarUrl(data.uface),
timestamp: data.start_time,
authorName: data.uname,
price: data.rmb,
content: data.message,
translation: ''
}
this.onAddSuperChat(data)
}
superChatDelCallback(command) {
if (!this.onDelSuperChat) {
return
}
let ids = []
for (let id of command.data.message_ids) {
ids.push(id.toString())
}
this.onDelSuperChat({ ids })
}
}
const CMD_CALLBACK_MAP = {
LIVE_OPEN_PLATFORM_DM: ChatClientDirectOpenLive.prototype.dmCallback,
LIVE_OPEN_PLATFORM_SEND_GIFT: ChatClientDirectOpenLive.prototype.sendGiftCallback,
LIVE_OPEN_PLATFORM_GUARD: ChatClientDirectOpenLive.prototype.guardCallback,
LIVE_OPEN_PLATFORM_SUPER_CHAT: ChatClientDirectOpenLive.prototype.superChatCallback,
LIVE_OPEN_PLATFORM_SUPER_CHAT_DEL: ChatClientDirectOpenLive.prototype.superChatDelCallback
}

View File

@@ -0,0 +1,185 @@
import axios from 'axios'
import { getUuid4Hex } from '@/utils'
import * as chat from '.'
import * as base from './ChatClientOfficialBase'
import ChatClientOfficialBase from './ChatClientOfficialBase'
export default class ChatClientDirectWeb extends ChatClientOfficialBase {
constructor(roomId) {
super()
this.CMD_CALLBACK_MAP = CMD_CALLBACK_MAP
// 调用initRoom后初始化如果失败使用这里的默认值
this.roomId = roomId
this.roomOwnerUid = -1
this.hostServerList = [
{ host: "broadcastlv.chat.bilibili.com", port: 2243, wss_port: 443, ws_port: 2244 }
]
}
async initRoom() {
let res
try {
res = (await axios.get('/api/room_info', { params: {
roomId: this.roomId
} })).data
} catch {
return true
}
this.roomId = res.roomId
this.roomOwnerUid = res.ownerUid
if (res.hostServerList.length !== 0) {
this.hostServerList = res.hostServerList
}
return true
}
async onBeforeWsConnect() {
// 重连次数太多则重新init_room保险
let reinitPeriod = Math.max(3, (this.hostServerList || []).length)
if (this.retryCount > 0 && this.retryCount % reinitPeriod === 0) {
this.needInitRoom = true
}
return super.onBeforeWsConnect()
}
getWsUrl() {
let hostServer = this.hostServerList[this.retryCount % this.hostServerList.length]
return `wss://${hostServer.host}:${hostServer.wss_port}/sub`
}
sendAuth() {
let authParams = {
uid: 0,
roomid: this.roomId,
protover: 3,
platform: 'web',
type: 2,
buvid: '',
}
this.websocket.send(this.makePacket(authParams, base.OP_AUTH))
}
async danmuMsgCallback(command) {
if (!this.onAddText) {
return
}
let info = command.info
let roomId, medalLevel
if (info[3]) {
roomId = info[3][3]
medalLevel = info[3][0]
} else {
roomId = medalLevel = 0
}
let uid = info[2][0]
let isAdmin = info[2][2]
let privilegeType = info[7]
let authorType
if (uid === this.roomOwnerUid) {
authorType = 3
} else if (isAdmin) {
authorType = 2
} else if (privilegeType !== 0) {
authorType = 1
} else {
authorType = 0
}
let data = {
avatarUrl: await chat.getAvatarUrl(uid),
timestamp: info[0][4] / 1000,
authorName: info[2][1],
authorType: authorType,
content: info[1],
privilegeType: privilegeType,
isGiftDanmaku: Boolean(info[0][9]),
authorLevel: info[4][0],
isNewbie: info[2][5] < 10000,
isMobileVerified: Boolean(info[2][6]),
medalLevel: roomId === this.roomId ? medalLevel : 0,
id: getUuid4Hex(),
translation: '',
emoticon: info[0][13].url || null,
}
this.onAddText(data)
}
sendGiftCallback(command) {
if (!this.onAddGift) {
return
}
let data = command.data
if (data.coin_type !== 'gold') { // 丢人
return
}
data = {
id: getUuid4Hex(),
avatarUrl: chat.processAvatarUrl(data.face),
timestamp: data.timestamp,
authorName: data.uname,
totalCoin: data.total_coin,
giftName: data.giftName,
num: data.num
}
this.onAddGift(data)
}
async guardBuyCallback(command) {
if (!this.onAddMember) {
return
}
let data = command.data
data = {
id: getUuid4Hex(),
avatarUrl: await chat.getAvatarUrl(data.uid),
timestamp: data.start_time,
authorName: data.username,
privilegeType: data.guard_level
}
this.onAddMember(data)
}
superChatMessageCallback(command) {
if (!this.onAddSuperChat) {
return
}
let data = command.data
data = {
id: data.id.toString(),
avatarUrl: chat.processAvatarUrl(data.user_info.face),
timestamp: data.start_time,
authorName: data.user_info.uname,
price: data.price,
content: data.message,
translation: ''
}
this.onAddSuperChat(data)
}
superChatMessageDeleteCallback(command) {
if (!this.onDelSuperChat) {
return
}
let ids = []
for (let id of command.data.ids) {
ids.push(id.toString())
}
this.onDelSuperChat({ ids })
}
}
const CMD_CALLBACK_MAP = {
DANMU_MSG: ChatClientDirectWeb.prototype.danmuMsgCallback,
SEND_GIFT: ChatClientDirectWeb.prototype.sendGiftCallback,
GUARD_BUY: ChatClientDirectWeb.prototype.guardBuyCallback,
SUPER_CHAT_MESSAGE: ChatClientDirectWeb.prototype.superChatMessageCallback,
SUPER_CHAT_MESSAGE_DELETE: ChatClientDirectWeb.prototype.superChatMessageDeleteCallback
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,298 @@
import { BrotliDecode } from './brotli_decode'
import { inflate } from 'pako'
const HEADER_SIZE = 16
export const WS_BODY_PROTOCOL_VERSION_NORMAL = 0
export const WS_BODY_PROTOCOL_VERSION_HEARTBEAT = 1
export const WS_BODY_PROTOCOL_VERSION_DEFLATE = 2
export const WS_BODY_PROTOCOL_VERSION_BROTLI = 3
export const OP_HANDSHAKE = 0
export const OP_HANDSHAKE_REPLY = 1
export const OP_HEARTBEAT = 2
export const OP_HEARTBEAT_REPLY = 3
export const OP_SEND_MSG = 4
export const OP_SEND_MSG_REPLY = 5
export const OP_DISCONNECT_REPLY = 6
export const OP_AUTH = 7
export const OP_AUTH_REPLY = 8
export const OP_RAW = 9
export const OP_PROTO_READY = 10
export const OP_PROTO_FINISH = 11
export const OP_CHANGE_ROOM = 12
export const OP_CHANGE_ROOM_REPLY = 13
export const OP_REGISTER = 14
export const OP_REGISTER_REPLY = 15
export const OP_UNREGISTER = 16
export const OP_UNREGISTER_REPLY = 17
// B站业务自定义OP
// export const MinBusinessOp = 1000
// export const MaxBusinessOp = 10000
export const AUTH_REPLY_CODE_OK = 0
export const AUTH_REPLY_CODE_TOKEN_ERROR = -101
const HEARTBEAT_INTERVAL = 10 * 1000
const RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + (5 * 1000)
let textEncoder = new TextEncoder()
let textDecoder = new TextDecoder()
export default class ChatClientOfficialBase {
constructor() {
this.CMD_CALLBACK_MAP = {}
this.onAddText = null
this.onAddGift = null
this.onAddMember = null
this.onAddSuperChat = null
this.onDelSuperChat = null
this.onUpdateTranslation = null
this.needInitRoom = true
this.websocket = null
this.retryCount = 0
this.isDestroying = false
this.heartbeatTimerId = null
this.receiveTimeoutTimerId = null
}
start() {
this.wsConnect()
}
stop() {
this.isDestroying = true
if (this.websocket) {
this.websocket.close()
}
}
async initRoom() {
throw Error('Not implemented')
}
makePacket(data, operation) {
let body
if (typeof data === 'object') {
body = textEncoder.encode(JSON.stringify(data))
} else { // string
body = textEncoder.encode(data)
}
let header = new ArrayBuffer(HEADER_SIZE)
let headerView = new DataView(header)
headerView.setUint32(0, HEADER_SIZE + body.byteLength) // pack_len
headerView.setUint16(4, HEADER_SIZE) // raw_header_size
headerView.setUint16(6, 1) // ver
headerView.setUint32(8, operation) // operation
headerView.setUint32(12, 1) // seq_id
return new Blob([header, body])
}
sendAuth() {
throw Error('Not implemented')
}
async wsConnect() {
if (this.isDestroying) {
return
}
await this.onBeforeWsConnect()
if (this.isDestroying) {
return
}
this.websocket = new WebSocket(this.getWsUrl())
this.websocket.binaryType = 'arraybuffer'
this.websocket.onopen = this.onWsOpen.bind(this)
this.websocket.onclose = this.onWsClose.bind(this)
this.websocket.onmessage = this.onWsMessage.bind(this)
}
async onBeforeWsConnect() {
if (!this.needInitRoom) {
return
}
if (!await this.initRoom()) {
this.onWsClose()
throw Error('initRoom failed')
}
this.needInitRoom = false
}
getWsUrl() {
throw Error('Not implemented')
}
onWsOpen() {
this.sendAuth()
this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), HEARTBEAT_INTERVAL)
this.refreshReceiveTimeoutTimer()
}
sendHeartbeat() {
this.websocket.send(this.makePacket({}, OP_HEARTBEAT))
}
refreshReceiveTimeoutTimer() {
if (this.receiveTimeoutTimerId) {
window.clearTimeout(this.receiveTimeoutTimerId)
}
this.receiveTimeoutTimerId = window.setTimeout(this.onReceiveTimeout.bind(this), RECEIVE_TIMEOUT)
}
onReceiveTimeout() {
console.warn('接收消息超时')
this.discardWebsocket()
}
discardWebsocket() {
if (this.receiveTimeoutTimerId) {
window.clearTimeout(this.receiveTimeoutTimerId)
this.receiveTimeoutTimerId = null
}
// 直接丢弃阻塞的websocket不等onclose回调了
this.websocket.onopen = this.websocket.onclose = this.websocket.onmessage = null
this.websocket.close()
this.onWsClose()
}
onWsClose() {
this.websocket = null
if (this.heartbeatTimerId) {
window.clearInterval(this.heartbeatTimerId)
this.heartbeatTimerId = null
}
if (this.receiveTimeoutTimerId) {
window.clearTimeout(this.receiveTimeoutTimerId)
this.receiveTimeoutTimerId = null
}
if (this.isDestroying) {
return
}
this.retryCount++
console.warn('掉线重连中', this.retryCount)
window.setTimeout(this.wsConnect.bind(this), 1000)
}
onWsMessage(event) {
if (!(event.data instanceof ArrayBuffer)) {
console.warn('未知的websocket消息类型data=', event.data)
return
}
let data = new Uint8Array(event.data)
this.parseWsMessage(data)
// 至少成功处理1条消息
this.retryCount = 0
}
parseWsMessage(data) {
let offset = 0
let dataView = new DataView(data.buffer)
let packLen = dataView.getUint32(0)
let rawHeaderSize = dataView.getUint16(4)
// let ver = dataView.getUint16(6)
let operation = dataView.getUint32(8)
// let seqId = dataView.getUint32(12)
switch (operation) {
case OP_AUTH_REPLY:
case OP_SEND_MSG_REPLY: {
// 业务消息,可能有多个包一起发,需要分包
while (true) { // eslint-disable-line no-constant-condition
let body = new Uint8Array(data.buffer, offset + rawHeaderSize, packLen - rawHeaderSize)
this.parseBusinessMessage(dataView, body)
offset += packLen
if (offset >= data.byteLength) {
break
}
dataView = new DataView(data.buffer, offset)
packLen = dataView.getUint32(0)
rawHeaderSize = dataView.getUint16(4)
}
break
}
case OP_HEARTBEAT_REPLY: {
// 服务器心跳包,包含人气值,这里没用
this.refreshReceiveTimeoutTimer()
break
}
default: {
// 未知消息
let body = new Uint8Array(data.buffer, offset + rawHeaderSize, packLen - rawHeaderSize)
console.warn('未知包类型operation=', operation, dataView, body)
break
}
}
}
parseBusinessMessage(dataView, body) {
let ver = dataView.getUint16(6)
let operation = dataView.getUint32(8)
switch (operation) {
case OP_SEND_MSG_REPLY: {
// 业务消息
if (ver == WS_BODY_PROTOCOL_VERSION_BROTLI) {
// 压缩过的先解压
body = BrotliDecode(body)
this.parseWsMessage(body)
} else if (ver == WS_BODY_PROTOCOL_VERSION_DEFLATE) {
// web端已经不用zlib压缩了但是开放平台会用
body = inflate(body)
this.parseWsMessage(body)
} else {
// 没压缩过的直接反序列化
if (body.length !== 0) {
try {
body = JSON.parse(textDecoder.decode(body))
this.handlerCommand(body)
} catch (e) {
console.error('body=', body)
throw e
}
}
}
break
}
case OP_AUTH_REPLY: {
// 认证响应
body = JSON.parse(textDecoder.decode(body))
if (body.code !== AUTH_REPLY_CODE_OK) {
console.error('认证响应错误body=', body)
this.needInitRoom = true
this.discardWebsocket()
throw new Error('认证响应错误')
}
this.sendHeartbeat()
break
}
default: {
// 未知消息
console.warn('未知包类型operation=', operation, dataView, body)
break
}
}
}
handlerCommand(command) {
let cmd = command.cmd || ''
let pos = cmd.indexOf(':')
if (pos != -1) {
cmd = cmd.substr(0, pos)
}
let callback = this.CMD_CALLBACK_MAP[cmd]
if (callback) {
callback.call(this, command)
}
}
}

View File

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

View File

@@ -0,0 +1,285 @@
import { getUuid4Hex } from '@/utils'
import * as constants from '@/components/ChatRenderer/constants'
import * as chat from '.'
const NAMES = [
'光羊',
'黑炎',
'孤梦星影',
'博丽灵梦',
'雾雨魔理沙',
'空條承太郎',
'ディオ・ブランドー',
'ジョセフ・ジョースター',
'ジョナサン・ジョースター',
'Simon',
'Misty',
'Kinori',
'shugen',
'3Shain',
'yuyuyzl',
'xfgryujk',
'Il Harper',
'Rick Astley',
]
const CONTENTS = [
'草',
'让我看看',
'不要停下来啊',
'我不做人了JOJO',
'已经没有什么好怕的了',
'我柜子动了,我不玩了',
'老板大气,老板身体健康',
'我醉提酒游寒山,爽滑慢舔',
'無駄無駄無駄無駄無駄無駄無駄無駄',
'欧啦欧啦欧啦欧啦欧啦欧啦欧啦欧啦',
'所有没好全部康复呀,我的癌也全部康复呀',
'嚯,朝我走过来了吗,没有选择逃跑而是主动接近我么',
'有一说一,这件事大家懂的都懂,不懂的,说了你也不明白,不如不说',
'如来来了吗?如来嘛~他真来了吗?如~来~到底来没来?如来~如来他真来了吗?如来~你看看,来没来?如~来~',
'迷えば、敗れる',
'逃げるんだよォ!',
'竜神の剣を喰らえ!',
'竜が我が敌を喰らう!',
'言いたいことがあるんだよ!',
'知らず知らず隠してた 本当の声を響かせてよほら',
'kksk',
'8888888888',
'Never gonna give you up',
'Never gonna let you down',
'888888888888888888888888888888',
'I am the storm that is approaching',
"I can eat glass, it doesn't hurt me",
'The quick brown fox jumps over the lazy dog',
'Farewell, ashen one. May the flame guide thee',
'I am the bone of my sword. Steel is my body, and fire is my blood.',
]
const EMOTICONS = [
'/static/img/emoticons/233.png',
'/static/img/emoticons/miaoa.png',
'/static/img/emoticons/lipu.png'
]
const AUTHOR_TYPES = [
{ weight: 10, value: constants.AUTHRO_TYPE_NORMAL },
{ weight: 5, value: constants.AUTHRO_TYPE_MEMBER },
{ weight: 2, value: constants.AUTHRO_TYPE_ADMIN },
{ weight: 1, value: constants.AUTHRO_TYPE_OWNER }
]
function randGuardInfo() {
let authorType = randomChoose(AUTHOR_TYPES)
let privilegeType
if (authorType === constants.AUTHRO_TYPE_MEMBER) {
privilegeType = randInt(1, 3)
} else if (authorType === constants.AUTHRO_TYPE_ADMIN) {
privilegeType = randInt(0, 3)
} else {
privilegeType = 0
}
return { authorType, privilegeType }
}
const GIFT_INFO_LIST = [
{ giftName: 'B坷垃', totalCoin: 9900 },
{ giftName: '礼花', totalCoin: 28000 },
{ giftName: '花式夸夸', totalCoin: 39000 },
{ giftName: '天空之翼', totalCoin: 100000 },
{ giftName: '摩天大楼', totalCoin: 450000 },
{ giftName: '小电视飞船', totalCoin: 1245000 }
]
const SC_PRICES = [
30, 50, 100, 200, 500, 1000
]
const MESSAGE_GENERATORS = [
// 文字
{
weight: 20,
value() {
return {
type: constants.MESSAGE_TYPE_TEXT,
message: {
...randGuardInfo(),
avatarUrl: chat.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
content: randomChoose(CONTENTS),
isGiftDanmaku: randInt(1, 10) <= 1,
authorLevel: randInt(0, 60),
isNewbie: randInt(1, 10) <= 1,
isMobileVerified: randInt(1, 10) <= 9,
medalLevel: randInt(0, 40),
id: getUuid4Hex(),
translation: '',
emoticon: null,
}
}
}
},
// 表情
{
weight: 5,
value() {
return {
type: constants.MESSAGE_TYPE_TEXT,
message: {
...randGuardInfo(),
avatarUrl: chat.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
content: '',
isGiftDanmaku: false,
authorLevel: randInt(0, 60),
isNewbie: randInt(1, 10) <= 1,
isMobileVerified: randInt(1, 10) <= 9,
medalLevel: randInt(0, 40),
id: getUuid4Hex(),
translation: '',
emoticon: randomChoose(EMOTICONS),
}
}
}
},
// 礼物
{
weight: 1,
value() {
return {
type: constants.MESSAGE_TYPE_GIFT,
message: {
...randomChoose(GIFT_INFO_LIST),
id: getUuid4Hex(),
avatarUrl: chat.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: chat.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: chat.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.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() {
// 模仿B站的消息间隔模式
let sleepTime
if (randInt(0, 4) == 0) {
sleepTime = randInt(1000, 2000)
} else {
sleepTime = randInt(0, 400)
}
this.timerId = window.setTimeout(this.onTimeout.bind(this), sleepTime)
}
onTimeout() {
this.refreshTimer()
let { type, message } = randomChoose(MESSAGE_GENERATORS)()
switch (type) {
case constants.MESSAGE_TYPE_TEXT:
this.onAddText(message)
break
case constants.MESSAGE_TYPE_GIFT:
this.onAddGift(message)
break
case constants.MESSAGE_TYPE_MEMBER:
this.onAddMember(message)
break
case constants.MESSAGE_TYPE_SUPER_CHAT:
this.onAddSuperChat(message)
break
}
}
}

View File

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

View File

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

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

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

View File

@@ -0,0 +1,362 @@
html:not(.style-scope) {
--yt-live-chat-background-color: hsl(0, 0%, 100%);
--yt-live-chat-action-panel-background-color: hsla(0, 0%, 93.3%, .4);
--yt-live-chat-action-panel-background-color-transparent: hsla(0, 0%, 97%, .8);
--yt-live-chat-mode-change-background-color: hsla(0, 0%, 93.3%, .4);
--yt-live-chat-primary-text-color: hsl(0, 0%, 6.7%);
--yt-live-chat-secondary-text-color: hsla(0, 0%, 6.7%, .6);
--yt-live-chat-tertiary-text-color: hsla(0, 0%, 6.7%, .4);
--yt-live-chat-text-input-field-inactive-underline-color: #b8b8b8;
--yt-live-chat-text-input-field-placeholder-color: hsla(0, 0%, 6.7%, .6);
--yt-live-chat-icon-button-color: hsla(0, 0%, 6.7%, .4);
--yt-live-chat-enabled-send-button-color: #4285f4;
--yt-live-chat-disabled-icon-button-color: hsla(0, 0%, 6.7%, .2);
--yt-live-chat-picker-button-color: hsla(0, 0%, 6.7%, .4);
--yt-live-chat-picker-button-active-color: hsla(0, 0%, 6.7%, .8);
--yt-live-chat-picker-button-disabled-color: var(--yt-live-chat-disabled-icon-button-color);
--yt-live-chat-picker-button-hover-color: hsla(0, 0%, 6.7%, .6);
--yt-live-chat-mention-background-color: #ff5722;
--yt-live-chat-mention-text-color: hsl(0, 0%, 100%);
--yt-live-chat-deleted-message-color: rgba(0, 0, 0, .5);
--yt-live-chat-deleted-message-bar-color: rgba(11, 11, 11, .2);
--yt-live-chat-disabled-button-background-color: hsl(0, 0%, 93.3%);
--yt-live-chat-disabled-button-text-color: hsla(0, 0%, 6.7%, .4);
--yt-live-chat-sub-panel-background-color: hsl(0, 0%, 93.3%);
--yt-live-chat-sub-panel-background-color-transparent: hsla(0, 0%, 93%, .7);
--yt-live-chat-header-background-color: hsla(0, 0%, 93.3%, .4);
--yt-live-chat-header-button-color: hsl(0, 0%, 6.7%);
--yt-live-chat-error-message-color: hsl(10, 51%, 49%);
--yt-live-chat-reconnect-message-color: hsla(0, 0%, 7%, 0.2);
--yt-live-chat-moderator-color: hsl(225, 84%, 66%);
--yt-live-chat-owner-color: hsl(40, 76%, 55%);
--yt-live-chat-author-chip-owner-text-color: rgba(0,0,0,0.87);
--yt-live-chat-author-chip-verified-background-color: #CCCCCC;
--yt-live-chat-author-chip-verified-text-color: #606060;
--yt-live-chat-message-highlight-background-color: #f8f8f8;
--yt-live-chat-sponsor-color: #107516;
--yt-live-chat-overlay-color: hsla(0, 0%, 0%, 0.6);
--yt-live-chat-dialog-background-color: hsl(0, 0%, 100%);
--yt-live-chat-dialog-text-color: hsla(0, 0%, 6.7%, .6);
--yt-live-chat-poll-choice-text-color: var(--yt-spec-text-secondary);
--yt-live-chat-poll-choice-border-color: var(--yt-spec-10-percent-layer);
--yt-live-chat-poll-choice-vote-bar-background-color: hsla(0, 0%, 93.3%, .8);
--yt-live-chat-poll-choice-vote-bar-background-color-selected: #F2F8FF;
--yt-live-chat-poll-choice-color-selected: #065FD4;
--yt-live-chat-moderation-mode-hover-background-color: hsla(0, 0%, 6.7%, .2);
--yt-live-chat-additional-inline-action-button-color: hsl(0, 0%, 100%);
--yt-live-chat-additional-inline-action-button-background-color: hsla(0, 0%, 26%, 0.8);
--yt-live-chat-additional-inline-action-button-background-color-hover: hsla(0, 0%, 26%, 1.0);
--yt-formatted-string-emoji-size: 24px;
--yt-live-chat-emoji-size: 24px;
--yt-live-chat-text-input-field-suggestion-background-color: hsl(0, 0%, 100%);
--yt-live-chat-text-input-field-suggestion-background-color-hover: #eee;
--yt-live-chat-text-input-field-suggestion-text-color: #666;
--yt-live-chat-text-input-field-suggestion-text-color-hover: #333;
--yt-live-chat-ticker-arrow-background: hsl(0, 0%, 97.3%);
--yt-emoji-picker-category-background-color: var(--yt-live-chat-action-panel-background-color-transparent);
--yt-emoji-picker-category-color: var(--yt-live-chat-secondary-text-color);
--yt-emoji-picker-category-button-color: var(--yt-live-chat-picker-button-color);
--yt-emoji-picker-search-background-color: hsla(0, 0%, 100%, .6);
--yt-emoji-picker-search-color: hsla(0, 0%, 6.7%, .8);
--yt-emoji-picker-search-placeholder-color: hsla(0, 0%, 6.7%, .6);
--yt-live-chat-slider-active-color: #2196f3;
--yt-live-chat-slider-container-color: #c8c8c8;
--yt-live-chat-slider-markers-color: #505050;
--yt-live-chat-toast-background-color: hsl(0, 0%, 20%);
--yt-live-chat-toast-text-color: hsl(0, 0%, 100%);
--yt-live-chat-automod-button-background-color: hsl(0, 0%, 93.3%);
--yt-live-chat-automod-button-background-color-hover: hsla(0, 0%, 6.7%, .2);
--yt-live-chat-countdown-opacity: 0.3;
--yt-live-chat-shimmer-background-color: rgba(136, 136, 136, 0.2);
--yt-live-chat-shimmer-linear-gradient: linear-gradient(0deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, 0.5) 50%, rgba(255, 255, 255, 0) 65%);
--yt-live-chat-vem-background-color: hsl(0, 0%, 93.3%);
--yt-live-chat-upsell-dialog-renderer-button-padding: 10px 16px;
--yt-live-chat-product-picker-icon-color: rgba(17, 17, 17, 0.6);
--yt-live-chat-product-picker-hover-color: rgba(17, 17, 16, 0.1);
--yt-live-chat-product-picker-disabled-icon-color: rgba(17, 17, 17, 0.4);
--yt-pdg-paid-stickers-tab-selection-bar-color: #065FD4;
--yt-pdg-paid-stickers-author-name-font-size: 13px;
--yt-pdg-paid-stickers-margin-left: 38px;
}
html:not(.style-scope) {
--layout_-_display: flex;
;
--layout-inline_-_display: inline-flex;
;
--layout-horizontal_-_display: var(--layout_-_display);
--layout-horizontal_-_-ms-flex-direction: row;
--layout-horizontal_-_-webkit-flex-direction: row;
--layout-horizontal_-_flex-direction: row;
;
--layout-horizontal-reverse_-_display: var(--layout_-_display);
--layout-horizontal-reverse_-_-ms-flex-direction: row-reverse;
--layout-horizontal-reverse_-_-webkit-flex-direction: row-reverse;
--layout-horizontal-reverse_-_flex-direction: row-reverse;
;
--layout-vertical_-_display: var(--layout_-_display);
--layout-vertical_-_-ms-flex-direction: column;
--layout-vertical_-_-webkit-flex-direction: column;
--layout-vertical_-_flex-direction: column;
;
--layout-vertical-reverse_-_display: var(--layout_-_display);
--layout-vertical-reverse_-_-ms-flex-direction: column-reverse;
--layout-vertical-reverse_-_-webkit-flex-direction: column-reverse;
--layout-vertical-reverse_-_flex-direction: column-reverse;
;
--layout-wrap_-_-ms-flex-wrap: wrap;
--layout-wrap_-_-webkit-flex-wrap: wrap;
--layout-wrap_-_flex-wrap: wrap;
;
--layout-wrap-reverse_-_-ms-flex-wrap: wrap-reverse;
--layout-wrap-reverse_-_-webkit-flex-wrap: wrap-reverse;
--layout-wrap-reverse_-_flex-wrap: wrap-reverse;
;
--layout-flex-auto_-_-ms-flex: 1 1 auto;
--layout-flex-auto_-_-webkit-flex: 1 1 auto;
--layout-flex-auto_-_flex: 1 1 auto;
;
--layout-flex-none_-_-ms-flex: none;
--layout-flex-none_-_-webkit-flex: none;
--layout-flex-none_-_flex: none;
;
--layout-flex_-_-ms-flex: 1 1 0.000000001px;
--layout-flex_-_-webkit-flex: 1;
--layout-flex_-_flex: 1;
--layout-flex_-_-webkit-flex-basis: 0.000000001px;
--layout-flex_-_flex-basis: 0.000000001px;
;
--layout-flex-2_-_-ms-flex: 2;
--layout-flex-2_-_-webkit-flex: 2;
--layout-flex-2_-_flex: 2;
;
--layout-flex-3_-_-ms-flex: 3;
--layout-flex-3_-_-webkit-flex: 3;
--layout-flex-3_-_flex: 3;
;
--layout-flex-4_-_-ms-flex: 4;
--layout-flex-4_-_-webkit-flex: 4;
--layout-flex-4_-_flex: 4;
;
--layout-flex-5_-_-ms-flex: 5;
--layout-flex-5_-_-webkit-flex: 5;
--layout-flex-5_-_flex: 5;
;
--layout-flex-6_-_-ms-flex: 6;
--layout-flex-6_-_-webkit-flex: 6;
--layout-flex-6_-_flex: 6;
;
--layout-flex-7_-_-ms-flex: 7;
--layout-flex-7_-_-webkit-flex: 7;
--layout-flex-7_-_flex: 7;
;
--layout-flex-8_-_-ms-flex: 8;
--layout-flex-8_-_-webkit-flex: 8;
--layout-flex-8_-_flex: 8;
;
--layout-flex-9_-_-ms-flex: 9;
--layout-flex-9_-_-webkit-flex: 9;
--layout-flex-9_-_flex: 9;
;
--layout-flex-10_-_-ms-flex: 10;
--layout-flex-10_-_-webkit-flex: 10;
--layout-flex-10_-_flex: 10;
;
--layout-flex-11_-_-ms-flex: 11;
--layout-flex-11_-_-webkit-flex: 11;
--layout-flex-11_-_flex: 11;
;
--layout-flex-12_-_-ms-flex: 12;
--layout-flex-12_-_-webkit-flex: 12;
--layout-flex-12_-_flex: 12;
;
--layout-start_-_-ms-flex-align: start;
--layout-start_-_-webkit-align-items: flex-start;
--layout-start_-_align-items: flex-start;
;
--layout-center_-_-ms-flex-align: center;
--layout-center_-_-webkit-align-items: center;
--layout-center_-_align-items: center;
;
--layout-end_-_-ms-flex-align: end;
--layout-end_-_-webkit-align-items: flex-end;
--layout-end_-_align-items: flex-end;
;
--layout-baseline_-_-ms-flex-align: baseline;
--layout-baseline_-_-webkit-align-items: baseline;
--layout-baseline_-_align-items: baseline;
;
--layout-start-justified_-_-ms-flex-pack: start;
--layout-start-justified_-_-webkit-justify-content: flex-start;
--layout-start-justified_-_justify-content: flex-start;
;
--layout-center-justified_-_-ms-flex-pack: center;
--layout-center-justified_-_-webkit-justify-content: center;
--layout-center-justified_-_justify-content: center;
;
--layout-end-justified_-_-ms-flex-pack: end;
--layout-end-justified_-_-webkit-justify-content: flex-end;
--layout-end-justified_-_justify-content: flex-end;
;
--layout-around-justified_-_-ms-flex-pack: distribute;
--layout-around-justified_-_-webkit-justify-content: space-around;
--layout-around-justified_-_justify-content: space-around;
;
--layout-justified_-_-ms-flex-pack: justify;
--layout-justified_-_-webkit-justify-content: space-between;
--layout-justified_-_justify-content: space-between;
;
--layout-center-center_-_-ms-flex-align: var(--layout-center_-_-ms-flex-align);
--layout-center-center_-_-webkit-align-items: var(--layout-center_-_-webkit-align-items);
--layout-center-center_-_align-items: var(--layout-center_-_align-items);
--layout-center-center_-_-ms-flex-pack: var(--layout-center-justified_-_-ms-flex-pack);
--layout-center-center_-_-webkit-justify-content: var(--layout-center-justified_-_-webkit-justify-content);
--layout-center-center_-_justify-content: var(--layout-center-justified_-_justify-content);
;
--layout-self-start_-_-ms-align-self: flex-start;
--layout-self-start_-_-webkit-align-self: flex-start;
--layout-self-start_-_align-self: flex-start;
;
--layout-self-center_-_-ms-align-self: center;
--layout-self-center_-_-webkit-align-self: center;
--layout-self-center_-_align-self: center;
;
--layout-self-end_-_-ms-align-self: flex-end;
--layout-self-end_-_-webkit-align-self: flex-end;
--layout-self-end_-_align-self: flex-end;
;
--layout-self-stretch_-_-ms-align-self: stretch;
--layout-self-stretch_-_-webkit-align-self: stretch;
--layout-self-stretch_-_align-self: stretch;
;
--layout-self-baseline_-_-ms-align-self: baseline;
--layout-self-baseline_-_-webkit-align-self: baseline;
--layout-self-baseline_-_align-self: baseline;
;
--layout-start-aligned_-_-ms-flex-line-pack: start;
--layout-start-aligned_-_-ms-align-content: flex-start;
--layout-start-aligned_-_-webkit-align-content: flex-start;
--layout-start-aligned_-_align-content: flex-start;
;
--layout-end-aligned_-_-ms-flex-line-pack: end;
--layout-end-aligned_-_-ms-align-content: flex-end;
--layout-end-aligned_-_-webkit-align-content: flex-end;
--layout-end-aligned_-_align-content: flex-end;
;
--layout-center-aligned_-_-ms-flex-line-pack: center;
--layout-center-aligned_-_-ms-align-content: center;
--layout-center-aligned_-_-webkit-align-content: center;
--layout-center-aligned_-_align-content: center;
;
--layout-between-aligned_-_-ms-flex-line-pack: justify;
--layout-between-aligned_-_-ms-align-content: space-between;
--layout-between-aligned_-_-webkit-align-content: space-between;
--layout-between-aligned_-_align-content: space-between;
;
--layout-around-aligned_-_-ms-flex-line-pack: distribute;
--layout-around-aligned_-_-ms-align-content: space-around;
--layout-around-aligned_-_-webkit-align-content: space-around;
--layout-around-aligned_-_align-content: space-around;
;
--layout-block_-_display: block;
;
--layout-invisible_-_visibility: hidden !important;
;
--layout-relative_-_position: relative;
;
--layout-fit_-_position: absolute;
--layout-fit_-_top: 0;
--layout-fit_-_right: 0;
--layout-fit_-_bottom: 0;
--layout-fit_-_left: 0;
;
--layout-scroll_-_-webkit-overflow-scrolling: touch;
--layout-scroll_-_overflow: auto;
;
--layout-fullbleed_-_margin: 0;
--layout-fullbleed_-_height: 100vh;
;
--layout-fixed-top_-_position: fixed;
--layout-fixed-top_-_top: 0;
--layout-fixed-top_-_left: 0;
--layout-fixed-top_-_right: 0;
;
--layout-fixed-right_-_position: fixed;
--layout-fixed-right_-_top: 0;
--layout-fixed-right_-_right: 0;
--layout-fixed-right_-_bottom: 0;
;
--layout-fixed-bottom_-_position: fixed;
--layout-fixed-bottom_-_right: 0;
--layout-fixed-bottom_-_bottom: 0;
--layout-fixed-bottom_-_left: 0;
;
--layout-fixed-left_-_position: fixed;
--layout-fixed-left_-_top: 0;
--layout-fixed-left_-_bottom: 0;
--layout-fixed-left_-_left: 0;
;
}

View File

@@ -0,0 +1,30 @@
canvas.yt-icon, caption.yt-icon, center.yt-icon, cite.yt-icon, code.yt-icon, dd.yt-icon, del.yt-icon, dfn.yt-icon, div.yt-icon, dl.yt-icon, dt.yt-icon, em.yt-icon, embed.yt-icon, fieldset.yt-icon, font.yt-icon, form.yt-icon, h1.yt-icon, h2.yt-icon, h3.yt-icon, h4.yt-icon, h5.yt-icon, h6.yt-icon, hr.yt-icon, i.yt-icon, iframe.yt-icon, img.yt-icon, ins.yt-icon, kbd.yt-icon, label.yt-icon, legend.yt-icon, li.yt-icon, menu.yt-icon, object.yt-icon, ol.yt-icon, p.yt-icon, pre.yt-icon, q.yt-icon, s.yt-icon, samp.yt-icon, small.yt-icon, span.yt-icon, strike.yt-icon, strong.yt-icon, sub.yt-icon, sup.yt-icon, table.yt-icon, tbody.yt-icon, td.yt-icon, tfoot.yt-icon, th.yt-icon, thead.yt-icon, tr.yt-icon, tt.yt-icon, u.yt-icon, ul.yt-icon, var.yt-icon {
margin: 0;
padding: 0;
border: 0;
background: transparent;
}
.yt-icon[hidden] {
display: none !important;
}
yt-icon, .yt-icon-container.yt-icon {
display: inline-flex;
-ms-flex-align: center;
-webkit-align-items: center;
align-items: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
position: relative;
vertical-align: middle;
fill: currentcolor;
stroke: none;
width: var(--iron-icon-width, 24px);
height: var(--iron-icon-height, 24px);
}
yt-icon.external-container {
display: none !important;
}

View File

@@ -0,0 +1,68 @@
canvas.yt-img-shadow, caption.yt-img-shadow, center.yt-img-shadow, cite.yt-img-shadow, code.yt-img-shadow, dd.yt-img-shadow, del.yt-img-shadow, dfn.yt-img-shadow, div.yt-img-shadow, dl.yt-img-shadow, dt.yt-img-shadow, em.yt-img-shadow, embed.yt-img-shadow, fieldset.yt-img-shadow, font.yt-img-shadow, form.yt-img-shadow, h1.yt-img-shadow, h2.yt-img-shadow, h3.yt-img-shadow, h4.yt-img-shadow, h5.yt-img-shadow, h6.yt-img-shadow, hr.yt-img-shadow, i.yt-img-shadow, iframe.yt-img-shadow, img.yt-img-shadow, ins.yt-img-shadow, kbd.yt-img-shadow, label.yt-img-shadow, legend.yt-img-shadow, li.yt-img-shadow, menu.yt-img-shadow, object.yt-img-shadow, ol.yt-img-shadow, p.yt-img-shadow, pre.yt-img-shadow, q.yt-img-shadow, s.yt-img-shadow, samp.yt-img-shadow, small.yt-img-shadow, span.yt-img-shadow, strike.yt-img-shadow, strong.yt-img-shadow, sub.yt-img-shadow, sup.yt-img-shadow, table.yt-img-shadow, tbody.yt-img-shadow, td.yt-img-shadow, tfoot.yt-img-shadow, th.yt-img-shadow, thead.yt-img-shadow, tr.yt-img-shadow, tt.yt-img-shadow, u.yt-img-shadow, ul.yt-img-shadow, var.yt-img-shadow {
margin: 0;
padding: 0;
border: 0;
background: transparent;
}
.yt-img-shadow[hidden] {
display: none !important;
}
yt-img-shadow {
display: inline-block;
opacity: 0;
transition: opacity 0.2s;
-ms-flex: none;
-webkit-flex: none;
flex: none;
}
yt-img-shadow.no-transition {
opacity: 1;
transition: none;
}
yt-img-shadow.with-placeholder {
background-color: transparent;
min-height: unset;
min-width: unset;
}
yt-img-shadow[loaded] {
opacity: 1;
}
yt-img-shadow.empty img.yt-img-shadow {
visibility: hidden;
}
yt-img-shadow[object-fit="FILL"] img.yt-img-shadow, yt-img-shadow[fit] img.yt-img-shadow {
width: 100%;
height: 100%;
}
yt-img-shadow[object-fit="COVER"] img.yt-img-shadow {
width: 100%;
height: 100%;
object-fit: cover;
}
yt-img-shadow[object-fit="CONTAIN"] img.yt-img-shadow {
width: 100%;
height: 100%;
object-fit: contain;
}
yt-img-shadow[object-position="LEFT"] img.yt-img-shadow {
object-position: left;
}
img.yt-img-shadow {
display: block;
margin-left: auto;
margin-right: auto;
max-height: none;
max-width: 100%;
border-radius: none;
}

View File

@@ -0,0 +1,36 @@
canvas.yt-live-chat-author-badge-renderer, caption.yt-live-chat-author-badge-renderer, center.yt-live-chat-author-badge-renderer, cite.yt-live-chat-author-badge-renderer, code.yt-live-chat-author-badge-renderer, dd.yt-live-chat-author-badge-renderer, del.yt-live-chat-author-badge-renderer, dfn.yt-live-chat-author-badge-renderer, div.yt-live-chat-author-badge-renderer, dl.yt-live-chat-author-badge-renderer, dt.yt-live-chat-author-badge-renderer, em.yt-live-chat-author-badge-renderer, embed.yt-live-chat-author-badge-renderer, fieldset.yt-live-chat-author-badge-renderer, font.yt-live-chat-author-badge-renderer, form.yt-live-chat-author-badge-renderer, h1.yt-live-chat-author-badge-renderer, h2.yt-live-chat-author-badge-renderer, h3.yt-live-chat-author-badge-renderer, h4.yt-live-chat-author-badge-renderer, h5.yt-live-chat-author-badge-renderer, h6.yt-live-chat-author-badge-renderer, hr.yt-live-chat-author-badge-renderer, i.yt-live-chat-author-badge-renderer, iframe.yt-live-chat-author-badge-renderer, img.yt-live-chat-author-badge-renderer, ins.yt-live-chat-author-badge-renderer, kbd.yt-live-chat-author-badge-renderer, label.yt-live-chat-author-badge-renderer, legend.yt-live-chat-author-badge-renderer, li.yt-live-chat-author-badge-renderer, menu.yt-live-chat-author-badge-renderer, object.yt-live-chat-author-badge-renderer, ol.yt-live-chat-author-badge-renderer, p.yt-live-chat-author-badge-renderer, pre.yt-live-chat-author-badge-renderer, q.yt-live-chat-author-badge-renderer, s.yt-live-chat-author-badge-renderer, samp.yt-live-chat-author-badge-renderer, small.yt-live-chat-author-badge-renderer, span.yt-live-chat-author-badge-renderer, strike.yt-live-chat-author-badge-renderer, strong.yt-live-chat-author-badge-renderer, sub.yt-live-chat-author-badge-renderer, sup.yt-live-chat-author-badge-renderer, table.yt-live-chat-author-badge-renderer, tbody.yt-live-chat-author-badge-renderer, td.yt-live-chat-author-badge-renderer, tfoot.yt-live-chat-author-badge-renderer, th.yt-live-chat-author-badge-renderer, thead.yt-live-chat-author-badge-renderer, tr.yt-live-chat-author-badge-renderer, tt.yt-live-chat-author-badge-renderer, u.yt-live-chat-author-badge-renderer, ul.yt-live-chat-author-badge-renderer, var.yt-live-chat-author-badge-renderer {
margin: 0;
padding: 0;
border: 0;
background: transparent;
}
.yt-live-chat-author-badge-renderer[hidden] {
display: none !important;
}
yt-live-chat-author-badge-renderer {
display: inline-block;
}
yt-live-chat-author-badge-renderer[type='moderator'] {
color: var(--yt-live-chat-moderator-color, #5e84f1);
}
yt-live-chat-author-badge-renderer[type='owner'] {
color: var(--yt-live-chat-owner-color, #ffd600);
}
yt-live-chat-author-badge-renderer[type='member'] {
color: var(--yt-live-chat-sponsor-color, #107516);
}
yt-live-chat-author-badge-renderer[type='verified'] {
color: #999;
}
img.yt-live-chat-author-badge-renderer, yt-icon.yt-live-chat-author-badge-renderer {
display: block;
width: 16px;
height: 16px;
}

View File

@@ -0,0 +1,64 @@
canvas.yt-live-chat-author-chip, caption.yt-live-chat-author-chip, center.yt-live-chat-author-chip, cite.yt-live-chat-author-chip, code.yt-live-chat-author-chip, dd.yt-live-chat-author-chip, del.yt-live-chat-author-chip, dfn.yt-live-chat-author-chip, div.yt-live-chat-author-chip, dl.yt-live-chat-author-chip, dt.yt-live-chat-author-chip, em.yt-live-chat-author-chip, embed.yt-live-chat-author-chip, fieldset.yt-live-chat-author-chip, font.yt-live-chat-author-chip, form.yt-live-chat-author-chip, h1.yt-live-chat-author-chip, h2.yt-live-chat-author-chip, h3.yt-live-chat-author-chip, h4.yt-live-chat-author-chip, h5.yt-live-chat-author-chip, h6.yt-live-chat-author-chip, hr.yt-live-chat-author-chip, i.yt-live-chat-author-chip, iframe.yt-live-chat-author-chip, img.yt-live-chat-author-chip, ins.yt-live-chat-author-chip, kbd.yt-live-chat-author-chip, label.yt-live-chat-author-chip, legend.yt-live-chat-author-chip, li.yt-live-chat-author-chip, menu.yt-live-chat-author-chip, object.yt-live-chat-author-chip, ol.yt-live-chat-author-chip, p.yt-live-chat-author-chip, pre.yt-live-chat-author-chip, q.yt-live-chat-author-chip, s.yt-live-chat-author-chip, samp.yt-live-chat-author-chip, small.yt-live-chat-author-chip, span.yt-live-chat-author-chip, strike.yt-live-chat-author-chip, strong.yt-live-chat-author-chip, sub.yt-live-chat-author-chip, sup.yt-live-chat-author-chip, table.yt-live-chat-author-chip, tbody.yt-live-chat-author-chip, td.yt-live-chat-author-chip, tfoot.yt-live-chat-author-chip, th.yt-live-chat-author-chip, thead.yt-live-chat-author-chip, tr.yt-live-chat-author-chip, tt.yt-live-chat-author-chip, u.yt-live-chat-author-chip, ul.yt-live-chat-author-chip, var.yt-live-chat-author-chip {
margin: 0;
padding: 0;
border: 0;
background: transparent;
}
.yt-live-chat-author-chip[hidden] {
display: none !important;
}
yt-live-chat-author-chip {
display: inline-flex;
-ms-flex-align: baseline;
-webkit-align-items: baseline;
align-items: baseline;
}
#author-name.yt-live-chat-author-chip {
box-sizing: border-box;
border-radius: 2px;
color: var(--yt-live-chat-secondary-text-color);
font-weight: 500;
}
yt-live-chat-author-chip[is-highlighted] #author-name.yt-live-chat-author-chip {
padding: 2px 4px;
color: var(--yt-live-chat-author-chip-verified-text-color);
background-color: var(--yt-live-chat-author-chip-verified-background-color);
}
#author-name.yt-live-chat-author-chip[type='moderator'] {
color: var(--yt-live-chat-moderator-color);
}
yt-live-chat-author-chip[is-highlighted] #author-name.yt-live-chat-author-chip[type='owner'], #author-name.yt-live-chat-author-chip[type='owner'] {
background-color: #ffd600;
color: var(--yt-live-chat-author-chip-owner-text-color);
}
#author-name.yt-live-chat-author-chip[type='member'] {
color: var(--yt-live-chat-sponsor-color);
}
#chip-badges.yt-live-chat-author-chip:empty {
display: none;
}
yt-live-chat-author-chip[is-highlighted] #chat-badges.yt-live-chat-author-chip:not(:empty) {
margin-left: 1px;
}
yt-live-chat-author-badge-renderer.yt-live-chat-author-chip {
margin: 0 0 0 2px;
vertical-align: sub;
}
yt-live-chat-author-chip[is-highlighted] #chip-badges.yt-live-chat-author-chip yt-live-chat-author-badge-renderer.yt-live-chat-author-chip {
color: inherit;
}
#chip-badges.yt-live-chat-author-chip yt-live-chat-author-badge-renderer.yt-live-chat-author-chip:last-of-type {
margin-right: -2px;
}

View File

@@ -0,0 +1,140 @@
canvas.yt-live-chat-item-list-renderer, caption.yt-live-chat-item-list-renderer, center.yt-live-chat-item-list-renderer, cite.yt-live-chat-item-list-renderer, code.yt-live-chat-item-list-renderer, dd.yt-live-chat-item-list-renderer, del.yt-live-chat-item-list-renderer, dfn.yt-live-chat-item-list-renderer, div.yt-live-chat-item-list-renderer, dl.yt-live-chat-item-list-renderer, dt.yt-live-chat-item-list-renderer, em.yt-live-chat-item-list-renderer, embed.yt-live-chat-item-list-renderer, fieldset.yt-live-chat-item-list-renderer, font.yt-live-chat-item-list-renderer, form.yt-live-chat-item-list-renderer, h1.yt-live-chat-item-list-renderer, h2.yt-live-chat-item-list-renderer, h3.yt-live-chat-item-list-renderer, h4.yt-live-chat-item-list-renderer, h5.yt-live-chat-item-list-renderer, h6.yt-live-chat-item-list-renderer, hr.yt-live-chat-item-list-renderer, i.yt-live-chat-item-list-renderer, iframe.yt-live-chat-item-list-renderer, img.yt-live-chat-item-list-renderer, ins.yt-live-chat-item-list-renderer, kbd.yt-live-chat-item-list-renderer, label.yt-live-chat-item-list-renderer, legend.yt-live-chat-item-list-renderer, li.yt-live-chat-item-list-renderer, menu.yt-live-chat-item-list-renderer, object.yt-live-chat-item-list-renderer, ol.yt-live-chat-item-list-renderer, p.yt-live-chat-item-list-renderer, pre.yt-live-chat-item-list-renderer, q.yt-live-chat-item-list-renderer, s.yt-live-chat-item-list-renderer, samp.yt-live-chat-item-list-renderer, small.yt-live-chat-item-list-renderer, span.yt-live-chat-item-list-renderer, strike.yt-live-chat-item-list-renderer, strong.yt-live-chat-item-list-renderer, sub.yt-live-chat-item-list-renderer, sup.yt-live-chat-item-list-renderer, table.yt-live-chat-item-list-renderer, tbody.yt-live-chat-item-list-renderer, td.yt-live-chat-item-list-renderer, tfoot.yt-live-chat-item-list-renderer, th.yt-live-chat-item-list-renderer, thead.yt-live-chat-item-list-renderer, tr.yt-live-chat-item-list-renderer, tt.yt-live-chat-item-list-renderer, u.yt-live-chat-item-list-renderer, ul.yt-live-chat-item-list-renderer, var.yt-live-chat-item-list-renderer {
margin: 0;
padding: 0;
border: 0;
background: transparent;
}
.yt-live-chat-item-list-renderer[hidden] {
display: none !important;
}
yt-live-chat-item-list-renderer {
position: relative;
display: block;
overflow: hidden;
z-index: 0;
}
yt-live-chat-item-list-renderer[moderation-mode-enabled] {
--yt-live-chat-item-with-inline-actions-context-menu-display: none;
--yt-live-chat-inline-action-button-container-display: flex;
}
#contents.yt-live-chat-item-list-renderer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
-ms-flex-direction: column;
-webkit-flex-direction: column;
flex-direction: column;
}
#empty-state-message.yt-live-chat-item-list-renderer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
-ms-flex-direction: column;
-webkit-flex-direction: column;
flex-direction: column;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
}
#empty-state-message.yt-live-chat-item-list-renderer>yt-live-chat-message-renderer.yt-live-chat-item-list-renderer {
color: var(--yt-live-chat-tertiary-text-color);
background: transparent;
font-size: 18px;
--yt-live-chat-message-renderer-text-align: center;
}
yt-icon-button.yt-live-chat-item-list-renderer {
background-color: #2196f3;
border-radius: 999px;
bottom: 0;
color: #fff;
cursor: pointer;
width: 32px;
height: 32px;
margin: 0 calc(50% - 16px) 8px calc(50% - 16px);
padding: 4px;
position: absolute;
transition-property: bottom;
transition-timing-function: cubic-bezier(0.0, 0.0, 0.2, 1);
transition-duration: 0.15s;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2);
}
yt-icon-button.yt-live-chat-item-list-renderer[disabled] {
bottom: -42px;
color: #fff;
transition-timing-function: cubic-bezier(0.4, 0.0, 1, 1);
}
#item-scroller.yt-live-chat-item-list-renderer {
-ms-flex: 1 1 0.000000001px;
-webkit-flex: 1;
flex: 1;
-webkit-flex-basis: 0.000000001px;
flex-basis: 0.000000001px;
overflow-x: hidden;
overflow-y: hidden;
padding-right: var(--scrollbar-width);
}
yt-live-chat-item-list-renderer[allow-scroll] #item-scroller.yt-live-chat-item-list-renderer {
overflow-y: scroll;
padding-right: 0;
}
#item-offset.yt-live-chat-item-list-renderer {
position: relative;
}
#item-scroller.animated.yt-live-chat-item-list-renderer #item-offset.yt-live-chat-item-list-renderer {
overflow: hidden;
}
#items.yt-live-chat-item-list-renderer {
-ms-flex: 1 1 0.000000001px;
-webkit-flex: 1;
flex: 1;
-webkit-flex-basis: 0.000000001px;
flex-basis: 0.000000001px;
padding: var(--yt-live-chat-item-list-renderer-padding, 4px 0);
}
#items.yt-live-chat-item-list-renderer>*.yt-live-chat-item-list-renderer:not(:first-child) {
border-top: var(--yt-live-chat-item-list-item-border, none);
}
#item-scroller.animated.yt-live-chat-item-list-renderer #items.yt-live-chat-item-list-renderer {
bottom: 0;
left: 0;
position: absolute;
right: 0;
transform: translateY(0);
}
#docked-messages.yt-live-chat-item-list-renderer {
z-index: 1;
position: absolute;
left: 0;
right: 0;
top: 0;
}
yt-live-chat-paid-sticker-renderer.yt-live-chat-item-list-renderer {
padding: 4px 24px;
}
yt-live-chat-paid-sticker-renderer.yt-live-chat-item-list-renderer[dashboard-money-feed] {
padding: 8px 16px;
}

View File

@@ -0,0 +1,362 @@
#timestamp.yt-live-chat-membership-item-renderer {
display: var(--yt-live-chat-item-timestamp-display, inline);
margin: var(--yt-live-chat-item-timestamp-margin, 0 8px 0 0);
color: var(--yt-live-chat-tertiary-text-color);
font-size: 11px;
}
#author-photo.yt-live-chat-membership-item-renderer {
display: block;
margin-right: 16px;
overflow: hidden;
border-radius: 50%;
-ms-flex: var(--layout-flex-none_-_-ms-flex);
-webkit-flex: var(--layout-flex-none_-_-webkit-flex);
flex: var(--layout-flex-none_-_flex);
}
#menu-button.yt-live-chat-membership-item-renderer {
width: var(--yt-live-chat-32px-icon-button_-_width);
height: var(--yt-live-chat-32px-icon-button_-_height);
padding: var(--yt-live-chat-32px-icon-button_-_padding);
}
#menu.yt-live-chat-membership-item-renderer {
position: absolute;
top: 0;
bottom: 0;
right: 0;
transform: translateX(100px);
}
yt-live-chat-membership-item-renderer:hover #menu.yt-live-chat-membership-item-renderer,
yt-live-chat-membership-item-renderer[menu-visible] #menu.yt-live-chat-membership-item-renderer {
transform: none;
}
yt-live-chat-membership-item-renderer:focus-within #menu.yt-live-chat-membership-item-renderer {
transform: none;
}
#inline-action-button-container.yt-live-chat-membership-item-renderer {
position: absolute;
top: -4px;
right: 0;
bottom: -4px;
left: 0;
background-color: var(--yt-live-chat-moderation-mode-hover-background-color);
display: none;
-ms-flex-align: var(--layout-center-center_-_-ms-flex-align);
-webkit-align-items: var(--layout-center-center_-_-webkit-align-items);
align-items: var(--layout-center-center_-_align-items);
-ms-flex-pack: var(--layout-center-center_-_-ms-flex-pack);
-webkit-justify-content: var(--layout-center-center_-_-webkit-justify-content);
justify-content: var(--layout-center-center_-_justify-content);
}
yt-live-chat-membership-item-renderer[has-inline-action-buttons]:hover #inline-action-button-container.yt-live-chat-membership-item-renderer {
display: var(--layout-horizontal_-_display);
-ms-flex-direction: var(--layout-horizontal_-_-ms-flex-direction);
-webkit-flex-direction: var(--layout-horizontal_-_-webkit-flex-direction);
flex-direction: var(--layout-horizontal_-_flex-direction);
display: var(--yt-live-chat-inline-action-button-container-display, none);
}
yt-live-chat-membership-item-renderer[has-inline-action-buttons][hide-inline-action-buttons]:hover #inline-action-button-container.yt-live-chat-membership-item-renderer {
display: none;
}
yt-live-chat-membership-item-renderer[has-inline-action-buttons]:hover #menu.yt-live-chat-membership-item-renderer {
display: var(--yt-live-chat-item-with-inline-actions-context-menu-display, block);
}
#inline-action-buttons.yt-live-chat-membership-item-renderer>*.yt-live-chat-membership-item-renderer,
#additional-inline-action-buttons.yt-live-chat-membership-item-renderer>*.yt-live-chat-membership-item-renderer {
--yt-button-icon-size: 36px;
--yt-button-icon-padding: 6px;
color: var(--yt-white);
border-radius: 2px;
}
#inline-action-buttons.yt-live-chat-membership-item-renderer>*.yt-live-chat-membership-item-renderer {
background: var(--yt-luna-black-opacity-lighten-1);
}
#inline-action-buttons.yt-live-chat-membership-item-renderer>.yt-live-chat-membership-item-renderer:hover {
background: var(--yt-luna-black);
}
#additional-inline-action-buttons.yt-live-chat-membership-item-renderer>*.yt-live-chat-membership-item-renderer {
color: var(--yt-live-chat-additional-inline-action-button-color);
background: var(--yt-live-chat-additional-inline-action-button-background-color);
}
#additional-inline-action-buttons.yt-live-chat-membership-item-renderer>.yt-live-chat-membership-item-renderer:hover {
background: var(--yt-live-chat-additional-inline-action-button-background-color-hover);
}
#additional-inline-action-buttons.yt-live-chat-membership-item-renderer:not(:empty) {
margin-left: 32px;
}
#inline-action-buttons.yt-live-chat-membership-item-renderer>*.yt-live-chat-membership-item-renderer:not(:first-child),
#additional-inline-action-buttons.yt-live-chat-membership-item-renderer>*.yt-live-chat-membership-item-renderer:not(:first-child) {
margin-left: 8px;
}
yt-live-chat-membership-item-renderer {
position: relative;
display: block;
--yt-live-chat-sponsor-header-color: #0a8043;
--yt-live-chat-sponsor-color: #0f9d58;
--yt-live-chat-sponsor-text-color: #fff;
--yt-live-chat-item-timestamp-display: var(--yt-live-chat-paid-message-timestamp-display, none);
padding: 4px 24px;
}
yt-live-chat-membership-item-renderer[dashboard-money-feed] {
padding: 0;
--yt-live-chat-item-timestamp-display: block;
}
#card.yt-live-chat-membership-item-renderer {
overflow: hidden;
font-size: 14px;
border-radius: 4px;
display: var(--layout-vertical_-_display);
-ms-flex-direction: var(--layout-vertical_-_-ms-flex-direction);
-webkit-flex-direction: var(--layout-vertical_-_-webkit-flex-direction);
flex-direction: var(--layout-vertical_-_flex-direction);
box-shadow: var(--shadow-elevation-2dp_-_box-shadow);
}
yt-live-chat-membership-item-renderer[dashboard-money-feed] #card.yt-live-chat-membership-item-renderer {
border-radius: 0;
box-shadow: none;
}
#header.yt-live-chat-membership-item-renderer {
position: relative;
background-color: var(--yt-live-chat-sponsor-header-color);
padding: 8px 16px;
color: #fff;
min-height: 20px;
display: var(--layout-horizontal_-_display);
-ms-flex-direction: var(--layout-horizontal_-_-ms-flex-direction);
-webkit-flex-direction: var(--layout-horizontal_-_-webkit-flex-direction);
flex-direction: var(--layout-horizontal_-_flex-direction);
-ms-flex-align: var(--layout-center_-_-ms-flex-align);
-webkit-align-items: var(--layout-center_-_-webkit-align-items);
align-items: var(--layout-center_-_align-items);
}
yt-live-chat-membership-item-renderer[show-only-header] #header.yt-live-chat-membership-item-renderer {
background-color: var(--yt-live-chat-sponsor-color);
}
yt-live-chat-membership-item-renderer[dashboard-money-feed] #header.yt-live-chat-membership-item-renderer {
color: var(--yt-live-chat-secondary-text-color);
background-color: var(--yt-live-chat-background-color);
-ms-flex-align: var(--layout-start_-_-ms-flex-align);
-webkit-align-items: var(--layout-start_-_-webkit-align-items);
align-items: var(--layout-start_-_align-items);
}
#header-content.yt-live-chat-membership-item-renderer {
display: var(--layout-horizontal_-_display);
-ms-flex-direction: var(--layout-horizontal_-_-ms-flex-direction);
-webkit-flex-direction: var(--layout-horizontal_-_-webkit-flex-direction);
flex-direction: var(--layout-horizontal_-_flex-direction);
-ms-flex-pack: var(--layout-justified_-_-ms-flex-pack);
-webkit-justify-content: var(--layout-justified_-_-webkit-justify-content);
justify-content: var(--layout-justified_-_justify-content);
-ms-flex: var(--layout-flex_-_-ms-flex);
-webkit-flex: var(--layout-flex_-_-webkit-flex);
flex: var(--layout-flex_-_flex);
-webkit-flex-basis: var(--layout-flex_-_-webkit-flex-basis);
flex-basis: var(--layout-flex_-_flex-basis);
-ms-flex-align: var(--layout-baseline_-_-ms-flex-align);
-webkit-align-items: var(--layout-baseline_-_-webkit-align-items);
align-items: var(--layout-baseline_-_align-items);
}
yt-live-chat-membership-item-renderer[dashboard-money-feed] #header-content.yt-live-chat-membership-item-renderer {
display: var(--layout-vertical_-_display);
-ms-flex-direction: var(--layout-vertical_-_-ms-flex-direction);
-webkit-flex-direction: var(--layout-vertical_-_-webkit-flex-direction);
flex-direction: var(--layout-vertical_-_flex-direction);
}
yt-live-chat-membership-item-renderer[dashboard-money-feed] #header-content-inner-column.yt-live-chat-membership-item-renderer {
margin-bottom: 4px;
display: var(--layout-horizontal_-_display);
-ms-flex-direction: var(--layout-horizontal_-_-ms-flex-direction);
-webkit-flex-direction: var(--layout-horizontal_-_-webkit-flex-direction);
flex-direction: var(--layout-horizontal_-_flex-direction);
-ms-flex-align: var(--layout-center_-_-ms-flex-align);
-webkit-align-items: var(--layout-center_-_-webkit-align-items);
align-items: var(--layout-center_-_align-items);
-ms-flex: var(--layout-flex-none_-_-ms-flex);
-webkit-flex: var(--layout-flex-none_-_-webkit-flex);
flex: var(--layout-flex-none_-_flex);
}
#author-photo.yt-live-chat-membership-item-renderer {
width: 40px;
height: 40px;
}
yt-icon#author-photo.yt-live-chat-membership-item-renderer {
display: none;
}
yt-live-chat-membership-item-renderer[dashboard-money-feed] yt-icon#author-photo.yt-live-chat-membership-item-renderer {
display: block;
}
yt-live-chat-membership-item-renderer:not([dashboard-money-feed]) yt-live-chat-author-chip.yt-live-chat-membership-item-renderer {
--yt-live-chat-sponsor-color: var(--yt-live-chat-sponsor-text-color);
--yt-live-chat-secondary-text-color: var(--yt-live-chat-sponsor-text-color);
}
yt-live-chat-membership-item-renderer[dashboard-money-feed] yt-live-chat-author-chip.yt-live-chat-membership-item-renderer {
margin-right: 8px;
font-weight: 500;
--yt-live-chat-sponsor-color: var(--yt-live-chat-secondary-text-color);
}
#header-subtext.yt-live-chat-membership-item-renderer {
margin-top: 2px;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
font-size: 15px;
}
#header-subtext.yt-live-chat-membership-item-renderer:empty {
display: none;
}
yt-live-chat-membership-item-renderer[dashboard-money-feed] #header-subtext.yt-live-chat-membership-item-renderer {
margin: 4px 0 13px;
font-size: 11px;
font-weight: normal;
color: var(--yt-live-chat-secondary-text-color);
}
#header-primary-text.yt-live-chat-membership-item-renderer {
word-wrap: break-word;
word-break: break-word;
font-weight: 500;
color: rgba(255, 255, 255, 1);
}
#header-primary-text.yt-live-chat-membership-item-renderer:empty {
display: none;
}
yt-live-chat-membership-item-renderer[has-primary-header-text]:not([dashboard-money-feed]) yt-live-chat-author-chip.yt-live-chat-membership-item-renderer,
yt-live-chat-membership-item-renderer[has-primary-header-text]:not([dashboard-money-feed]) #header-subtext.yt-live-chat-membership-item-renderer {
font-size: 12px;
}
yt-live-chat-membership-item-renderer[dashboard-money-feed] #header-primary-text.yt-live-chat-membership-item-renderer {
display: inline;
height: 24px;
min-width: 16px;
border-radius: 12px;
margin-right: 8px;
padding: 0 12px;
background-color: var(--yt-live-chat-sponsor-color);
color: var(--yt-white);
display: var(--layout-inline_-_display, inline);
-ms-flex-align: var(--layout-center-center_-_-ms-flex-align);
-webkit-align-items: var(--layout-center-center_-_-webkit-align-items);
align-items: var(--layout-center-center_-_align-items);
-ms-flex-pack: var(--layout-center-center_-_-ms-flex-pack);
-webkit-justify-content: var(--layout-center-center_-_-webkit-justify-content);
justify-content: var(--layout-center-center_-_justify-content);
font-size: var(--ytd-badge_-_font-size);
font-weight: var(--ytd-badge_-_font-weight);
line-height: var(--ytd-badge_-_line-height);
}
#content.yt-live-chat-membership-item-renderer {
background-color: var(--yt-live-chat-sponsor-color);
color: var(--yt-live-chat-sponsor-text-color);
padding: 8px 16px;
word-wrap: break-word;
word-break: break-word;
font-size: 15px;
line-height: 20px;
}
yt-live-chat-membership-item-renderer[dashboard-money-feed] #content.yt-live-chat-membership-item-renderer {
background-color: unset;
font-size: unset;
color: var(--yt-live-chat-secondary-text-color);
padding: 0 0 16px 72px;
}
#content.yt-live-chat-membership-item-renderer img.yt-live-chat-membership-item-renderer {
/* B站表情有不是正方形的 */
/* width: var(--yt-live-chat-emoji-size); */
width: auto;
height: var(--yt-live-chat-emoji-size);
margin: -1px 2px 1px 2px;
vertical-align: middle;
}
yt-live-chat-membership-item-renderer[show-only-header] #content.yt-live-chat-membership-item-renderer,
#deleted-state.yt-live-chat-membership-item-renderer:empty {
display: none;
}
#deleted-state.yt-live-chat-membership-item-renderer {
display: block;
font-style: italic;
opacity: 0.7;
}
a.yt-live-chat-membership-item-renderer {
display: inline;
text-decoration: underline;
}
#message.yt-live-chat-membership-item-renderer a.yt-live-chat-membership-item-renderer {
word-break: break-all;
}
#message.yt-live-chat-membership-item-renderer a.yt-live-chat-membership-item-renderer .mention.yt-live-chat-membership-item-renderer {
text-decoration: underline;
}
#menu.yt-live-chat-membership-item-renderer {
background: linear-gradient(to right, transparent, var(--yt-live-chat-sponsor-header-color) 100%);
border-radius: 0 4px 4px 0;
}
yt-live-chat-membership-item-renderer[show-only-header] #menu.yt-live-chat-membership-item-renderer {
background: linear-gradient(to right, transparent, var(--yt-live-chat-sponsor-color) 100%);
}
yt-live-chat-membership-item-renderer[dashboard-money-feed] #menu.yt-live-chat-membership-item-renderer {
margin-top: 8px;
background: linear-gradient(to right, transparent, var(--yt-live-chat-background-color) 40%);
}

View File

@@ -0,0 +1,380 @@
canvas.yt-live-chat-paid-message-renderer, caption.yt-live-chat-paid-message-renderer, center.yt-live-chat-paid-message-renderer, cite.yt-live-chat-paid-message-renderer, code.yt-live-chat-paid-message-renderer, dd.yt-live-chat-paid-message-renderer, del.yt-live-chat-paid-message-renderer, dfn.yt-live-chat-paid-message-renderer, div.yt-live-chat-paid-message-renderer, dl.yt-live-chat-paid-message-renderer, dt.yt-live-chat-paid-message-renderer, em.yt-live-chat-paid-message-renderer, embed.yt-live-chat-paid-message-renderer, fieldset.yt-live-chat-paid-message-renderer, font.yt-live-chat-paid-message-renderer, form.yt-live-chat-paid-message-renderer, h1.yt-live-chat-paid-message-renderer, h2.yt-live-chat-paid-message-renderer, h3.yt-live-chat-paid-message-renderer, h4.yt-live-chat-paid-message-renderer, h5.yt-live-chat-paid-message-renderer, h6.yt-live-chat-paid-message-renderer, hr.yt-live-chat-paid-message-renderer, i.yt-live-chat-paid-message-renderer, iframe.yt-live-chat-paid-message-renderer, img.yt-live-chat-paid-message-renderer, ins.yt-live-chat-paid-message-renderer, kbd.yt-live-chat-paid-message-renderer, label.yt-live-chat-paid-message-renderer, legend.yt-live-chat-paid-message-renderer, li.yt-live-chat-paid-message-renderer, menu.yt-live-chat-paid-message-renderer, object.yt-live-chat-paid-message-renderer, ol.yt-live-chat-paid-message-renderer, p.yt-live-chat-paid-message-renderer, pre.yt-live-chat-paid-message-renderer, q.yt-live-chat-paid-message-renderer, s.yt-live-chat-paid-message-renderer, samp.yt-live-chat-paid-message-renderer, small.yt-live-chat-paid-message-renderer, span.yt-live-chat-paid-message-renderer, strike.yt-live-chat-paid-message-renderer, strong.yt-live-chat-paid-message-renderer, sub.yt-live-chat-paid-message-renderer, sup.yt-live-chat-paid-message-renderer, table.yt-live-chat-paid-message-renderer, tbody.yt-live-chat-paid-message-renderer, td.yt-live-chat-paid-message-renderer, tfoot.yt-live-chat-paid-message-renderer, th.yt-live-chat-paid-message-renderer, thead.yt-live-chat-paid-message-renderer, tr.yt-live-chat-paid-message-renderer, tt.yt-live-chat-paid-message-renderer, u.yt-live-chat-paid-message-renderer, ul.yt-live-chat-paid-message-renderer, var.yt-live-chat-paid-message-renderer {
margin: 0;
padding: 0;
border: 0;
background: transparent;
}
.yt-live-chat-paid-message-renderer[hidden] {
display: none !important;
}
#timestamp.yt-live-chat-paid-message-renderer {
display: var(--yt-live-chat-item-timestamp-display, inline);
margin: var(--yt-live-chat-item-timestamp-margin, 0 8px 0 0);
color: var(--yt-live-chat-tertiary-text-color);
font-size: 11px;
}
#author-photo.yt-live-chat-paid-message-renderer {
display: block;
margin-right: 16px;
overflow: hidden;
border-radius: 50%;
-ms-flex: none;
-webkit-flex: none;
flex: none;
}
#menu-button.yt-live-chat-paid-message-renderer {
width: 40px;
height: 40px;
padding: 8px;
}
#menu.yt-live-chat-paid-message-renderer {
position: absolute;
top: 0;
bottom: 0;
right: 0;
transform: translateX(100px);
}
yt-live-chat-paid-message-renderer:hover #menu.yt-live-chat-paid-message-renderer, yt-live-chat-paid-message-renderer[menu-visible] #menu.yt-live-chat-paid-message-renderer {
transform: none;
}
yt-live-chat-paid-message-renderer:focus-within #menu.yt-live-chat-paid-message-renderer {
transform: none;
}
#inline-action-button-container.yt-live-chat-paid-message-renderer {
position: absolute;
top: -4px;
right: 0;
bottom: -4px;
left: 0;
background-color: var(--yt-live-chat-moderation-mode-hover-background-color);
display: none;
-ms-flex-align: center;
-webkit-align-items: center;
align-items: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
}
yt-live-chat-paid-message-renderer[has-inline-action-buttons]:hover #inline-action-button-container.yt-live-chat-paid-message-renderer {
display: flex;
-ms-flex-direction: row;
-webkit-flex-direction: row;
flex-direction: row;
display: var(--yt-live-chat-inline-action-button-container-display, none);
}
yt-live-chat-paid-message-renderer[has-inline-action-buttons][hide-inline-action-buttons]:hover #inline-action-button-container.yt-live-chat-paid-message-renderer {
display: none;
}
yt-live-chat-paid-message-renderer[has-inline-action-buttons]:hover #menu.yt-live-chat-paid-message-renderer {
display: var(--yt-live-chat-item-with-inline-actions-context-menu-display, block);
}
#inline-action-buttons.yt-live-chat-paid-message-renderer>*.yt-live-chat-paid-message-renderer, #additional-inline-action-buttons.yt-live-chat-paid-message-renderer>*.yt-live-chat-paid-message-renderer {
--yt-button-icon-size: 36px;
--yt-button-icon-padding: 6px;
color: hsl(0, 0%, 100%);
border-radius: 2px;
}
#inline-action-buttons.yt-live-chat-paid-message-renderer>*.yt-live-chat-paid-message-renderer {
background: hsla(0, 0%, 6.7%, .8);
}
#inline-action-buttons.yt-live-chat-paid-message-renderer>.yt-live-chat-paid-message-renderer:hover {
background: hsl(0, 0%, 6.7%);
}
#additional-inline-action-buttons.yt-live-chat-paid-message-renderer>*.yt-live-chat-paid-message-renderer {
color: var(--yt-live-chat-additional-inline-action-button-color);
background: var(--yt-live-chat-additional-inline-action-button-background-color);
}
#additional-inline-action-buttons.yt-live-chat-paid-message-renderer>.yt-live-chat-paid-message-renderer:hover {
background: var(--yt-live-chat-additional-inline-action-button-background-color-hover);
}
#additional-inline-action-buttons.yt-live-chat-paid-message-renderer:not(:empty) {
margin-left: 32px;
}
#inline-action-buttons.yt-live-chat-paid-message-renderer>*.yt-live-chat-paid-message-renderer:not(:first-child), #additional-inline-action-buttons.yt-live-chat-paid-message-renderer>*.yt-live-chat-paid-message-renderer:not(:first-child) {
margin-left: 8px;
}
yt-live-chat-paid-message-renderer {
position: relative;
display: block;
padding: 4px 24px;
font-size: 15px;
--yt-live-chat-paid-message-background-color: var(--yt-live-chat-paid-message-primary-color);
--yt-live-chat-paid-message-header-background-color: var(--yt-live-chat-paid-message-secondary-color);
--yt-live-chat-text-input-field-placeholder-color: var(--yt-live-chat-paid-message-color);
--yt-live-chat-item-timestamp-display: var(--yt-live-chat-paid-message-timestamp-display, none);
}
yt-live-chat-paid-message-renderer[dashboard-money-feed] {
padding: 0;
padding-bottom: 8px;
--yt-live-chat-paid-message-background-color: transparent;
--yt-live-chat-paid-message-header-background-color: transparent;
--yt-live-chat-item-timestamp-display: block;
}
#card.yt-live-chat-paid-message-renderer {
border-radius: 4px;
display: flex;
-ms-flex-direction: column;
-webkit-flex-direction: column;
flex-direction: column;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2);
}
yt-live-chat-paid-message-renderer[dashboard-money-feed] #card.yt-live-chat-paid-message-renderer {
border-radius: 0;
box-shadow: none;
}
#header.yt-live-chat-paid-message-renderer {
position: relative;
background-color: var(--yt-live-chat-paid-message-header-background-color, #125aac);
color: var(--yt-live-chat-paid-message-header-color, #fff);
font-weight: 500;
padding: 8px 16px;
min-height: 20px;
display: flex;
-ms-flex-direction: row;
-webkit-flex-direction: row;
flex-direction: row;
-ms-flex-align: center;
-webkit-align-items: center;
align-items: center;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
yt-live-chat-paid-message-renderer[show-only-header] #header.yt-live-chat-paid-message-renderer {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
yt-live-chat-paid-message-renderer[dashboard-money-feed] #header.yt-live-chat-paid-message-renderer {
color: var(--yt-live-chat-secondary-text-color);
padding-bottom: 0;
}
#author-photo.yt-live-chat-paid-message-renderer {
width: 40px;
height: 40px;
}
yt-icon#author-photo.yt-live-chat-paid-message-renderer {
display: none;
}
yt-live-chat-paid-message-renderer[dashboard-money-feed] yt-icon#author-photo.yt-live-chat-paid-message-renderer {
display: block;
}
#header-content.yt-live-chat-paid-message-renderer {
display: flex;
-ms-flex-direction: row;
-webkit-flex-direction: row;
flex-direction: row;
-ms-flex-pack: justify;
-webkit-justify-content: space-between;
justify-content: space-between;
-ms-flex: 1 1 0.000000001px;
-webkit-flex: 1;
flex: 1;
-webkit-flex-basis: 0.000000001px;
flex-basis: 0.000000001px;
-ms-flex-align: baseline;
-webkit-align-items: baseline;
align-items: baseline;
}
yt-live-chat-paid-message-renderer[dashboard-money-feed] #header-content.yt-live-chat-paid-message-renderer {
display: flex;
-ms-flex-direction: column;
-webkit-flex-direction: column;
flex-direction: column;
}
#header-content-primary-column.yt-live-chat-paid-message-renderer {
display: flex;
-ms-flex-direction: column;
-webkit-flex-direction: column;
flex-direction: column;
-ms-flex-pack: justify;
-webkit-justify-content: space-between;
justify-content: space-between;
-ms-flex: 1 1 0.000000001px;
-webkit-flex: 1;
flex: 1;
-webkit-flex-basis: 0.000000001px;
flex-basis: 0.000000001px;
}
yt-live-chat-paid-message-renderer[dashboard-money-feed] #header-content-primary-column.yt-live-chat-paid-message-renderer {
margin-bottom: 4px;
display: flex;
-ms-flex-direction: row;
-webkit-flex-direction: row;
flex-direction: row;
-ms-flex-align: baseline;
-webkit-align-items: baseline;
align-items: baseline;
-ms-flex: none;
-webkit-flex: none;
flex: none;
}
#author-name.yt-live-chat-paid-message-renderer {
color: var(--yt-live-chat-paid-message-author-name-color, rgba(255, 255, 255, 0.7));
font-size: 14px;
}
yt-live-chat-paid-message-renderer[dashboard-money-feed] #author-name.yt-live-chat-paid-message-renderer {
margin-right: 8px;
}
yt-live-chat-paid-message-renderer[dashboard-money-feed] #purchase-amount.yt-live-chat-paid-message-renderer {
display: inline;
height: 24px;
min-width: 16px;
border-radius: 12px;
margin-right: 8px;
padding: 0 12px;
background-color: var(--yt-live-chat-paid-message-primary-color);
color: var(--yt-live-chat-paid-message-color);
display: inline-flex;
-ms-flex-align: center;
-webkit-align-items: center;
align-items: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
font-size: 1.2rem;
font-weight: 500;
line-height: 1.2rem;
}
yt-live-chat-paid-message-renderer[dashboard-money-feed] #author-name.yt-live-chat-paid-message-renderer {
display: block;
color: var(--yt-live-chat-secondary-text-color);
}
yt-live-chat-paid-message-renderer[dashboard-money-feed] #author-name.yt-live-chat-paid-message-renderer[is-deleted] {
margin: 4px 0 0;
order: 1;
}
yt-live-chat-paid-message-renderer[dashboard-money-feed] #author-name.yt-live-chat-paid-message-renderer[is-deleted].yt-live-chat-paid-message-renderer::before {
content: '​';
display: inline;
width: 0;
}
#menu.yt-live-chat-paid-message-renderer {
background: linear-gradient(to right, transparent, var(--yt-live-chat-paid-message-header-background-color, #125aac) 100%);
border-radius: 0 4px 4px 0;
}
yt-live-chat-paid-message-renderer[dashboard-money-feed] #menu.yt-live-chat-paid-message-renderer {
margin-top: 8px;
background: linear-gradient(to right, transparent, var(--yt-live-chat-background-color) 40%);
}
yt-live-chat-paid-message-renderer[allow-animations] #content.yt-live-chat-paid-message-renderer, yt-live-chat-paid-message-renderer[allow-animations] #header.yt-live-chat-paid-message-renderer {
transition-property: background-color, color;
transition-duration: 0.2s;
}
#content.yt-live-chat-paid-message-renderer {
background-color: var(--yt-live-chat-paid-message-background-color, #1565c0);
color: var(--yt-live-chat-paid-message-color, #fff);
padding: 8px 16px;
word-wrap: break-word;
word-break: break-word;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
#content.yt-live-chat-paid-message-renderer img.yt-live-chat-paid-message-renderer {
/* B站表情有不是正方形的 */
/* width: var(--yt-live-chat-emoji-size); */
width: auto;
height: var(--yt-live-chat-emoji-size);
margin: -1px 2px 1px 2px;
vertical-align: middle;
}
yt-live-chat-paid-message-renderer[dashboard-money-feed] #content.yt-live-chat-paid-message-renderer {
padding: 0;
padding-left: 72px;
color: var(--yt-live-chat-secondary-text-color);
}
yt-live-chat-paid-message-renderer[show-only-header] #content.yt-live-chat-paid-message-renderer {
visibility: hidden;
padding: 0;
}
a.yt-live-chat-paid-message-renderer {
display: inline;
text-decoration: underline;
}
#message.yt-live-chat-paid-message-renderer a.yt-live-chat-paid-message-renderer {
word-break: break-all;
}
yt-live-chat-paid-message-renderer[dashboard-money-feed] #message.yt-live-chat-paid-message-renderer {
margin-top: 4px;
}
#message.yt-live-chat-paid-message-renderer a.yt-live-chat-paid-message-renderer .mention.yt-live-chat-paid-message-renderer {
text-decoration: underline;
}
#message.yt-live-chat-paid-message-renderer:empty, #author-name.yt-live-chat-paid-message-renderer:empty:not([is-deleted]).yt-live-chat-paid-message-renderer, #deleted-state.yt-live-chat-paid-message-renderer:empty {
display: none;
}
#deleted-state.yt-live-chat-paid-message-renderer {
display: block;
font-style: italic;
opacity: 0.7;
}
yt-live-chat-paid-message-renderer[dashboard-money-feed] #deleted-state.yt-live-chat-paid-message-renderer[is-deleted] {
margin-top: 4px;
}
yt-live-chat-paid-message-renderer[dashboard-money-feed] #author-name.yt-live-chat-paid-message-renderer {
-ms-flex: none;
-webkit-flex: none;
flex: none;
}
yt-live-chat-paid-message-renderer[show-footer-divider] #footer.yt-live-chat-paid-message-renderer::before {
content: "";
display: flex;
border-top: 1px solid rgba(0, 0, 0, 0.12);
margin: 8px 0;
}
yt-live-chat-paid-message-renderer[is-user-editable] #footer.yt-live-chat-paid-message-renderer:not(:empty) {
padding-top: 8px;
}

View File

@@ -0,0 +1,252 @@
yt-live-chat-renderer, yt-live-chat-item-list-renderer #item-scroller {
height: 100%;
}
yt-live-chat-renderer ::-webkit-scrollbar {
content: '';
}
yt-live-chat-renderer ::-webkit-scrollbar-thumb {
background-color: hsla(0, 0%, 53.3%, .2);
border: 2px solid #fcfcfc;
min-height: 30px;
}
yt-live-chat-renderer ::-webkit-scrollbar-track {
background-color: #fcfcfc;
}
canvas.yt-live-chat-renderer, caption.yt-live-chat-renderer, center.yt-live-chat-renderer, cite.yt-live-chat-renderer, code.yt-live-chat-renderer, dd.yt-live-chat-renderer, del.yt-live-chat-renderer, dfn.yt-live-chat-renderer, div.yt-live-chat-renderer, dl.yt-live-chat-renderer, dt.yt-live-chat-renderer, em.yt-live-chat-renderer, embed.yt-live-chat-renderer, fieldset.yt-live-chat-renderer, font.yt-live-chat-renderer, form.yt-live-chat-renderer, h1.yt-live-chat-renderer, h2.yt-live-chat-renderer, h3.yt-live-chat-renderer, h4.yt-live-chat-renderer, h5.yt-live-chat-renderer, h6.yt-live-chat-renderer, hr.yt-live-chat-renderer, i.yt-live-chat-renderer, iframe.yt-live-chat-renderer, img.yt-live-chat-renderer, ins.yt-live-chat-renderer, kbd.yt-live-chat-renderer, label.yt-live-chat-renderer, legend.yt-live-chat-renderer, li.yt-live-chat-renderer, menu.yt-live-chat-renderer, object.yt-live-chat-renderer, ol.yt-live-chat-renderer, p.yt-live-chat-renderer, pre.yt-live-chat-renderer, q.yt-live-chat-renderer, s.yt-live-chat-renderer, samp.yt-live-chat-renderer, small.yt-live-chat-renderer, span.yt-live-chat-renderer, strike.yt-live-chat-renderer, strong.yt-live-chat-renderer, sub.yt-live-chat-renderer, sup.yt-live-chat-renderer, table.yt-live-chat-renderer, tbody.yt-live-chat-renderer, td.yt-live-chat-renderer, tfoot.yt-live-chat-renderer, th.yt-live-chat-renderer, thead.yt-live-chat-renderer, tr.yt-live-chat-renderer, tt.yt-live-chat-renderer, u.yt-live-chat-renderer, ul.yt-live-chat-renderer, var.yt-live-chat-renderer {
margin: 0;
padding: 0;
border: 0;
background: transparent;
}
.yt-live-chat-renderer[hidden] {
display: none !important;
}
yt-live-chat-renderer {
font-size: 13px;
--yt-emoji-picker-renderer-height: 180px;
--yt-button-default-text-color: var(--yt-live-chat-button-default-text-color);
--yt-button-default-background-color: var(--yt-live-chat-button-default-background-color);
--yt-button-dark-text-color: var(--yt-live-chat-button-dark-text-color);
--yt-button-dark-background-color: var(--yt-live-chat-button-dark-background-color);
--yt-button-payment-background-color: var(--yt-live-chat-sponsor-color);
}
yt-live-chat-renderer {
position: relative;
background: var(--yt-live-chat-background-color);
color: var(--yt-live-chat-primary-text-color);
overflow: hidden;
z-index: 0;
display: flex;
-ms-flex-direction: column;
-webkit-flex-direction: column;
flex-direction: column;
}
yt-live-chat-renderer[hide-timestamps] {
--yt-live-chat-item-timestamp-display: none;
}
#separator.yt-live-chat-renderer {
border-bottom: var(--yt-live-chat-header-bottom-border, none);
}
#content-pages.yt-live-chat-renderer {
display: flex;
-ms-flex-direction: column;
-webkit-flex-direction: column;
flex-direction: column;
-ms-flex: 1 1 0.000000001px;
-webkit-flex: 1;
flex: 1;
-webkit-flex-basis: 0.000000001px;
flex-basis: 0.000000001px;
}
#panel-pages.yt-live-chat-renderer {
max-height: 100%;
overflow-x: hidden;
overflow-y: auto;
}
#contents.yt-live-chat-renderer {
overflow: hidden;
position: relative;
z-index: 0;
}
#chat-messages.yt-live-chat-renderer, #contents.yt-live-chat-renderer, #item-list.yt-live-chat-renderer {
display: flex;
-ms-flex-direction: column;
-webkit-flex-direction: column;
flex-direction: column;
-ms-flex: 1 1 0.000000001px;
-webkit-flex: 1;
flex: 1;
-webkit-flex-basis: 0.000000001px;
flex-basis: 0.000000001px;
}
#ticker.yt-live-chat-renderer {
z-index: 1;
}
#chat.yt-live-chat-renderer {
position: relative;
display: flex;
-ms-flex-direction: column;
-webkit-flex-direction: column;
flex-direction: column;
-ms-flex: 1 1 0.000000001px;
-webkit-flex: 1;
flex: 1;
-webkit-flex-basis: 0.000000001px;
flex-basis: 0.000000001px;
}
#chat.yt-live-chat-renderer::after {
content: '';
display: none;
animation: gradient-slide 1.2s ease infinite;
animation-name: gradient-slide;
background-color: var(--yt-live-chat-shimmer-background-color);
background-image: var(--yt-live-chat-shimmer-linear-gradient);
background-size: 300% 300%;
transform: rotateX(180deg);
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
yt-live-chat-renderer[loading] #chat.yt-live-chat-renderer::after {
display: block;
}
yt-live-chat-pinned-message-renderer.yt-live-chat-renderer {
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
}
yt-live-chat-item-list-renderer.yt-live-chat-renderer, yt-live-chat-ninja-message-renderer.yt-live-chat-renderer {
-ms-flex: 1 1 0.000000001px;
-webkit-flex: 1;
flex: 1;
-webkit-flex-basis: 0.000000001px;
flex-basis: 0.000000001px;
}
#action-panel.yt-live-chat-renderer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
overflow: hidden;
}
yt-live-chat-renderer[has-action-panel-renderer] yt-live-chat-action-panel-renderer.yt-live-chat-renderer {
animation: slideUp cubic-bezier(0.05, 0.00, 0.00, 1.00) forwards;
animation-duration: 0.5s;
}
#action-panel-backdrop.yt-live-chat-renderer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
visibility: hidden;
}
yt-live-chat-renderer[has-action-panel-renderer] #action-panel-backdrop.yt-live-chat-renderer {
visibility: visible;
animation: fadeIn cubic-bezier(0.05, 0.00, 0.00, 1.00) forwards;
animation-duration: 0.5s;
}
#input-panel.yt-live-chat-renderer {
-ms-flex: none;
-webkit-flex: none;
flex: none;
}
#input-panel.yt-live-chat-renderer:not(:empty) {
border-top: var(--yt-live-chat-action-panel-top-border, none);
}
.hide-on-collapse.yt-live-chat-renderer {
transition: opacity 0.3s;
}
yt-live-chat-renderer[collapsed] .hide-on-collapse.yt-live-chat-renderer {
opacity: 0;
}
#loading.yt-live-chat-renderer {
height: 387px;
background-color: var(--yt-live-chat-action-panel-background-color);
display: flex;
-ms-flex-direction: column;
-webkit-flex-direction: column;
flex-direction: column;
-ms-flex-align: center;
-webkit-align-items: center;
align-items: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
}
#loading.yt-live-chat-renderer>paper-spinner-lite.yt-live-chat-renderer {
--paper-spinner-color: var(--yt-live-chat-primary-text-color);
}
#nitrate-promo.yt-live-chat-renderer>*.yt-live-chat-renderer {
background: var(--yt-live-chat-overlay-color);
z-index: 3;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
@keyframes gradient-slide {
0% {
background-position: 100% 100%;
}
to {
background-position: 0% 0%;
}
}
@keyframes slideUp {
0% {
transform: translateY(15%);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes fadeIn {
0% {
background-color: transparent;
}
100% {
background-color: rgba(0, 0, 0, 0.60);
}
}

View File

@@ -0,0 +1,232 @@
canvas.yt-live-chat-text-message-renderer, caption.yt-live-chat-text-message-renderer, center.yt-live-chat-text-message-renderer, cite.yt-live-chat-text-message-renderer, code.yt-live-chat-text-message-renderer, dd.yt-live-chat-text-message-renderer, del.yt-live-chat-text-message-renderer, dfn.yt-live-chat-text-message-renderer, div.yt-live-chat-text-message-renderer, dl.yt-live-chat-text-message-renderer, dt.yt-live-chat-text-message-renderer, em.yt-live-chat-text-message-renderer, embed.yt-live-chat-text-message-renderer, fieldset.yt-live-chat-text-message-renderer, font.yt-live-chat-text-message-renderer, form.yt-live-chat-text-message-renderer, h1.yt-live-chat-text-message-renderer, h2.yt-live-chat-text-message-renderer, h3.yt-live-chat-text-message-renderer, h4.yt-live-chat-text-message-renderer, h5.yt-live-chat-text-message-renderer, h6.yt-live-chat-text-message-renderer, hr.yt-live-chat-text-message-renderer, i.yt-live-chat-text-message-renderer, iframe.yt-live-chat-text-message-renderer, img.yt-live-chat-text-message-renderer, ins.yt-live-chat-text-message-renderer, kbd.yt-live-chat-text-message-renderer, label.yt-live-chat-text-message-renderer, legend.yt-live-chat-text-message-renderer, li.yt-live-chat-text-message-renderer, menu.yt-live-chat-text-message-renderer, object.yt-live-chat-text-message-renderer, ol.yt-live-chat-text-message-renderer, p.yt-live-chat-text-message-renderer, pre.yt-live-chat-text-message-renderer, q.yt-live-chat-text-message-renderer, s.yt-live-chat-text-message-renderer, samp.yt-live-chat-text-message-renderer, small.yt-live-chat-text-message-renderer, span.yt-live-chat-text-message-renderer, strike.yt-live-chat-text-message-renderer, strong.yt-live-chat-text-message-renderer, sub.yt-live-chat-text-message-renderer, sup.yt-live-chat-text-message-renderer, table.yt-live-chat-text-message-renderer, tbody.yt-live-chat-text-message-renderer, td.yt-live-chat-text-message-renderer, tfoot.yt-live-chat-text-message-renderer, th.yt-live-chat-text-message-renderer, thead.yt-live-chat-text-message-renderer, tr.yt-live-chat-text-message-renderer, tt.yt-live-chat-text-message-renderer, u.yt-live-chat-text-message-renderer, ul.yt-live-chat-text-message-renderer, var.yt-live-chat-text-message-renderer {
margin: 0;
padding: 0;
border: 0;
background: transparent;
}
.yt-live-chat-text-message-renderer[hidden] {
display: none !important;
}
#timestamp.yt-live-chat-text-message-renderer {
display: var(--yt-live-chat-item-timestamp-display, inline);
margin: var(--yt-live-chat-item-timestamp-margin, 0 8px 0 0);
color: var(--yt-live-chat-tertiary-text-color);
font-size: 11px;
}
#author-photo.yt-live-chat-text-message-renderer {
display: block;
margin-right: 16px;
overflow: hidden;
border-radius: 50%;
-ms-flex: none;
-webkit-flex: none;
flex: none;
}
#menu-button.yt-live-chat-text-message-renderer {
width: 40px;
height: 40px;
padding: 8px;
}
#menu.yt-live-chat-text-message-renderer {
position: absolute;
top: 0;
bottom: 0;
right: 0;
transform: translateX(100px);
}
yt-live-chat-text-message-renderer:hover #menu.yt-live-chat-text-message-renderer, yt-live-chat-text-message-renderer[menu-visible] #menu.yt-live-chat-text-message-renderer {
transform: none;
}
yt-live-chat-text-message-renderer:focus-within #menu.yt-live-chat-text-message-renderer {
transform: none;
}
#inline-action-button-container.yt-live-chat-text-message-renderer {
position: absolute;
top: -4px;
right: 0;
bottom: -4px;
left: 0;
background-color: var(--yt-live-chat-moderation-mode-hover-background-color);
display: none;
-ms-flex-align: center;
-webkit-align-items: center;
align-items: center;
-ms-flex-pack: center;
-webkit-justify-content: center;
justify-content: center;
}
yt-live-chat-text-message-renderer[has-inline-action-buttons]:hover #inline-action-button-container.yt-live-chat-text-message-renderer {
display: flex;
-ms-flex-direction: row;
-webkit-flex-direction: row;
flex-direction: row;
display: var(--yt-live-chat-inline-action-button-container-display, none);
}
yt-live-chat-text-message-renderer[has-inline-action-buttons][hide-inline-action-buttons]:hover #inline-action-button-container.yt-live-chat-text-message-renderer {
display: none;
}
yt-live-chat-text-message-renderer[has-inline-action-buttons]:hover #menu.yt-live-chat-text-message-renderer {
display: var(--yt-live-chat-item-with-inline-actions-context-menu-display, block);
}
#inline-action-buttons.yt-live-chat-text-message-renderer>*.yt-live-chat-text-message-renderer, #additional-inline-action-buttons.yt-live-chat-text-message-renderer>*.yt-live-chat-text-message-renderer {
--yt-button-icon-size: 36px;
--yt-button-icon-padding: 6px;
color: hsl(0, 0%, 100%);
border-radius: 2px;
}
#inline-action-buttons.yt-live-chat-text-message-renderer>*.yt-live-chat-text-message-renderer {
background: hsla(0, 0%, 6.7%, .8);
}
#inline-action-buttons.yt-live-chat-text-message-renderer>.yt-live-chat-text-message-renderer:hover {
background: hsl(0, 0%, 6.7%);
}
#additional-inline-action-buttons.yt-live-chat-text-message-renderer>*.yt-live-chat-text-message-renderer {
color: var(--yt-live-chat-additional-inline-action-button-color);
background: var(--yt-live-chat-additional-inline-action-button-background-color);
}
#additional-inline-action-buttons.yt-live-chat-text-message-renderer>.yt-live-chat-text-message-renderer:hover {
background: var(--yt-live-chat-additional-inline-action-button-background-color-hover);
}
#additional-inline-action-buttons.yt-live-chat-text-message-renderer:not(:empty) {
margin-left: 32px;
}
#inline-action-buttons.yt-live-chat-text-message-renderer>*.yt-live-chat-text-message-renderer:not(:first-child), #additional-inline-action-buttons.yt-live-chat-text-message-renderer>*.yt-live-chat-text-message-renderer:not(:first-child) {
margin-left: 8px;
}
yt-live-chat-text-message-renderer {
position: relative;
font-size: 13px;
padding: 4px 24px;
overflow: hidden;
--yt-endpoint-color: var(--yt-live-chat-primary-text-color, hsl(0, 0%, 6.7%));
--yt-endpoint-hover-color: var(--yt-live-chat-primary-text-color, var(--yt-endpoint-color));
display: flex;
-ms-flex-direction: row;
-webkit-flex-direction: row;
flex-direction: row;
-ms-flex-align: start;
-webkit-align-items: flex-start;
align-items: flex-start;
}
yt-live-chat-text-message-renderer:hover {
overflow: initial;
}
yt-live-chat-text-message-renderer[author-is-owner] {
background-color: var(--yt-live-chat-message-highlight-background-color);
}
#content.yt-live-chat-text-message-renderer {
-ms-align-self: center;
-webkit-align-self: center;
align-self: center;
min-width: 0;
}
yt-live-chat-author-chip.yt-live-chat-text-message-renderer {
margin-right: 8px;
}
#message.yt-live-chat-text-message-renderer {
color: var(--yt-live-chat-primary-text-color, var(--yt-primary-text-color));
line-height: 16px;
overflow: hidden;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
font-style: var(--yt-live-chat-text-message-renderer-message-message-style_-_font-style);
opacity: var(--yt-live-chat-text-message-renderer-message-message-style_-_opacity);
}
#message.yt-live-chat-text-message-renderer .emoji.yt-live-chat-text-message-renderer {
/* B站表情有不是正方形的 */
/* width: var(--yt-live-chat-emoji-size); */
width: auto;
height: var(--yt-live-chat-emoji-size);
margin: -1px 2px 1px 2px;
vertical-align: middle;
}
a.yt-live-chat-text-message-renderer {
display: inline;
text-decoration: underline;
}
#message.yt-live-chat-text-message-renderer a.yt-live-chat-text-message-renderer {
display: inline;
text-decoration: underline;
word-break: break-all;
}
#message.yt-live-chat-text-message-renderer a.yt-live-chat-text-message-renderer .mention.yt-live-chat-text-message-renderer {
text-decoration: underline;
}
#show-original.yt-live-chat-text-message-renderer {
margin-left: 2px;
}
#message.yt-live-chat-text-message-renderer:empty, #deleted-state.yt-live-chat-text-message-renderer:empty, #show-original.yt-live-chat-text-message-renderer:empty, yt-live-chat-text-message-renderer[show-original] #deleted-state.yt-live-chat-text-message-renderer, yt-live-chat-text-message-renderer[show-original] #show-original.yt-live-chat-text-message-renderer, yt-live-chat-text-message-renderer[is-deleted]:not([show-original]) #message.yt-live-chat-text-message-renderer {
display: none;
}
#menu.yt-live-chat-text-message-renderer {
color: var(--yt-live-chat-secondary-text-color);
background: linear-gradient(to right, transparent, var(--yt-live-chat-background-color, hsl(0, 0%, 100%)) 100%);
}
.mention.yt-live-chat-text-message-renderer {
background: var(--yt-live-chat-mention-background-color);
color: var(--yt-live-chat-mention-text-color);
padding: 2px 4px;
border-radius: 2px;
}
#deleted-state.yt-live-chat-text-message-renderer, #show-original.yt-live-chat-text-message-renderer, yt-live-chat-text-message-renderer[is-deleted] #message.yt-live-chat-text-message-renderer {
font-style: italic;
color: var(--yt-live-chat-deleted-message-color, rgba(0, 0, 0, 0.5));
}
yt-live-chat-text-message-renderer[show-bar]::before {
content: '';
position: absolute;
display: block;
left: 8px;
top: 4px;
bottom: 4px;
width: 4px;
box-sizing: border-box;
border-radius: 2px;
}
yt-live-chat-text-message-renderer[is-deleted]::before {
background: var(--yt-live-chat-deleted-message-bar-color, rgba(0, 0, 0, 0.5));
}
yt-live-chat-text-message-renderer[is-dimmed] #message.yt-live-chat-text-message-renderer {
opacity: 0.25;
}
yt-live-chat-text-message-renderer[is-dimmed]::before {
background: var(--yt-live-chat-error-message-color, #f44336);
}

View File

@@ -0,0 +1,80 @@
canvas.yt-live-chat-ticker-paid-message-item-renderer, caption.yt-live-chat-ticker-paid-message-item-renderer, center.yt-live-chat-ticker-paid-message-item-renderer, cite.yt-live-chat-ticker-paid-message-item-renderer, code.yt-live-chat-ticker-paid-message-item-renderer, dd.yt-live-chat-ticker-paid-message-item-renderer, del.yt-live-chat-ticker-paid-message-item-renderer, dfn.yt-live-chat-ticker-paid-message-item-renderer, div.yt-live-chat-ticker-paid-message-item-renderer, dl.yt-live-chat-ticker-paid-message-item-renderer, dt.yt-live-chat-ticker-paid-message-item-renderer, em.yt-live-chat-ticker-paid-message-item-renderer, embed.yt-live-chat-ticker-paid-message-item-renderer, fieldset.yt-live-chat-ticker-paid-message-item-renderer, font.yt-live-chat-ticker-paid-message-item-renderer, form.yt-live-chat-ticker-paid-message-item-renderer, h1.yt-live-chat-ticker-paid-message-item-renderer, h2.yt-live-chat-ticker-paid-message-item-renderer, h3.yt-live-chat-ticker-paid-message-item-renderer, h4.yt-live-chat-ticker-paid-message-item-renderer, h5.yt-live-chat-ticker-paid-message-item-renderer, h6.yt-live-chat-ticker-paid-message-item-renderer, hr.yt-live-chat-ticker-paid-message-item-renderer, i.yt-live-chat-ticker-paid-message-item-renderer, iframe.yt-live-chat-ticker-paid-message-item-renderer, img.yt-live-chat-ticker-paid-message-item-renderer, ins.yt-live-chat-ticker-paid-message-item-renderer, kbd.yt-live-chat-ticker-paid-message-item-renderer, label.yt-live-chat-ticker-paid-message-item-renderer, legend.yt-live-chat-ticker-paid-message-item-renderer, li.yt-live-chat-ticker-paid-message-item-renderer, menu.yt-live-chat-ticker-paid-message-item-renderer, object.yt-live-chat-ticker-paid-message-item-renderer, ol.yt-live-chat-ticker-paid-message-item-renderer, p.yt-live-chat-ticker-paid-message-item-renderer, pre.yt-live-chat-ticker-paid-message-item-renderer, q.yt-live-chat-ticker-paid-message-item-renderer, s.yt-live-chat-ticker-paid-message-item-renderer, samp.yt-live-chat-ticker-paid-message-item-renderer, small.yt-live-chat-ticker-paid-message-item-renderer, span.yt-live-chat-ticker-paid-message-item-renderer, strike.yt-live-chat-ticker-paid-message-item-renderer, strong.yt-live-chat-ticker-paid-message-item-renderer, sub.yt-live-chat-ticker-paid-message-item-renderer, sup.yt-live-chat-ticker-paid-message-item-renderer, table.yt-live-chat-ticker-paid-message-item-renderer, tbody.yt-live-chat-ticker-paid-message-item-renderer, td.yt-live-chat-ticker-paid-message-item-renderer, tfoot.yt-live-chat-ticker-paid-message-item-renderer, th.yt-live-chat-ticker-paid-message-item-renderer, thead.yt-live-chat-ticker-paid-message-item-renderer, tr.yt-live-chat-ticker-paid-message-item-renderer, tt.yt-live-chat-ticker-paid-message-item-renderer, u.yt-live-chat-ticker-paid-message-item-renderer, ul.yt-live-chat-ticker-paid-message-item-renderer, var.yt-live-chat-ticker-paid-message-item-renderer {
margin: 0;
padding: 0;
border: 0;
background: transparent;
}
.yt-live-chat-ticker-paid-message-item-renderer[hidden] {
display: none !important;
}
canvas.yt-live-chat-ticker-paid-message-item-renderer, caption.yt-live-chat-ticker-paid-message-item-renderer, center.yt-live-chat-ticker-paid-message-item-renderer, cite.yt-live-chat-ticker-paid-message-item-renderer, code.yt-live-chat-ticker-paid-message-item-renderer, dd.yt-live-chat-ticker-paid-message-item-renderer, del.yt-live-chat-ticker-paid-message-item-renderer, dfn.yt-live-chat-ticker-paid-message-item-renderer, div.yt-live-chat-ticker-paid-message-item-renderer, dl.yt-live-chat-ticker-paid-message-item-renderer, dt.yt-live-chat-ticker-paid-message-item-renderer, em.yt-live-chat-ticker-paid-message-item-renderer, embed.yt-live-chat-ticker-paid-message-item-renderer, fieldset.yt-live-chat-ticker-paid-message-item-renderer, font.yt-live-chat-ticker-paid-message-item-renderer, form.yt-live-chat-ticker-paid-message-item-renderer, h1.yt-live-chat-ticker-paid-message-item-renderer, h2.yt-live-chat-ticker-paid-message-item-renderer, h3.yt-live-chat-ticker-paid-message-item-renderer, h4.yt-live-chat-ticker-paid-message-item-renderer, h5.yt-live-chat-ticker-paid-message-item-renderer, h6.yt-live-chat-ticker-paid-message-item-renderer, hr.yt-live-chat-ticker-paid-message-item-renderer, i.yt-live-chat-ticker-paid-message-item-renderer, iframe.yt-live-chat-ticker-paid-message-item-renderer, img.yt-live-chat-ticker-paid-message-item-renderer, ins.yt-live-chat-ticker-paid-message-item-renderer, kbd.yt-live-chat-ticker-paid-message-item-renderer, label.yt-live-chat-ticker-paid-message-item-renderer, legend.yt-live-chat-ticker-paid-message-item-renderer, li.yt-live-chat-ticker-paid-message-item-renderer, menu.yt-live-chat-ticker-paid-message-item-renderer, object.yt-live-chat-ticker-paid-message-item-renderer, ol.yt-live-chat-ticker-paid-message-item-renderer, p.yt-live-chat-ticker-paid-message-item-renderer, pre.yt-live-chat-ticker-paid-message-item-renderer, q.yt-live-chat-ticker-paid-message-item-renderer, s.yt-live-chat-ticker-paid-message-item-renderer, samp.yt-live-chat-ticker-paid-message-item-renderer, small.yt-live-chat-ticker-paid-message-item-renderer, span.yt-live-chat-ticker-paid-message-item-renderer, strike.yt-live-chat-ticker-paid-message-item-renderer, strong.yt-live-chat-ticker-paid-message-item-renderer, sub.yt-live-chat-ticker-paid-message-item-renderer, sup.yt-live-chat-ticker-paid-message-item-renderer, table.yt-live-chat-ticker-paid-message-item-renderer, tbody.yt-live-chat-ticker-paid-message-item-renderer, td.yt-live-chat-ticker-paid-message-item-renderer, tfoot.yt-live-chat-ticker-paid-message-item-renderer, th.yt-live-chat-ticker-paid-message-item-renderer, thead.yt-live-chat-ticker-paid-message-item-renderer, tr.yt-live-chat-ticker-paid-message-item-renderer, tt.yt-live-chat-ticker-paid-message-item-renderer, u.yt-live-chat-ticker-paid-message-item-renderer, ul.yt-live-chat-ticker-paid-message-item-renderer, var.yt-live-chat-ticker-paid-message-item-renderer {
margin: 0;
padding: 0;
border: 0;
background: transparent;
}
.yt-live-chat-ticker-paid-message-item-renderer[hidden] {
display: none !important;
}
yt-live-chat-ticker-paid-message-item-renderer {
display: inline-block;
font-size: 14px;
outline: none;
transition: width 0.2s;
vertical-align: top;
cursor: pointer;
-moz-user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
user-select: none;
}
#container.yt-live-chat-ticker-paid-message-item-renderer {
border-radius: 999px;
padding: 4px;
}
yt-live-chat-ticker-paid-message-item-renderer.sliding-down #container.yt-live-chat-ticker-paid-message-item-renderer {
opacity: 0.5;
transform: translateY(44px);
transition: opacity 0.2s, transform 0.2s cubic-bezier(0.4, 0.0, 1, 1);
}
yt-live-chat-ticker-paid-message-item-renderer.collapsing {
margin-right: 0;
transition: margin-right 0.2s cubic-bezier(0.4, 0.0, 0.2, 1), width 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
}
yt-live-chat-ticker-paid-message-item-renderer[dimmed] {
opacity: 0.5;
}
yt-img-shadow.yt-live-chat-ticker-paid-message-item-renderer {
margin-right: -4px;
overflow: hidden;
border-radius: 50%;
}
#content.yt-live-chat-ticker-paid-message-item-renderer {
height: 24px;
display: flex;
-ms-flex-direction: row;
-webkit-flex-direction: row;
flex-direction: row;
-ms-flex-align: center;
-webkit-align-items: center;
align-items: center;
}
#text.yt-live-chat-ticker-paid-message-item-renderer {
margin: 0 8px;
font-weight: 500;
}
yt-live-chat-ticker-paid-message-item-renderer[is-deleted] #author-photo.yt-live-chat-ticker-paid-message-item-renderer {
display: none;
}

View File

@@ -0,0 +1,65 @@
canvas.yt-live-chat-ticker-renderer, caption.yt-live-chat-ticker-renderer, center.yt-live-chat-ticker-renderer, cite.yt-live-chat-ticker-renderer, code.yt-live-chat-ticker-renderer, dd.yt-live-chat-ticker-renderer, del.yt-live-chat-ticker-renderer, dfn.yt-live-chat-ticker-renderer, div.yt-live-chat-ticker-renderer, dl.yt-live-chat-ticker-renderer, dt.yt-live-chat-ticker-renderer, em.yt-live-chat-ticker-renderer, embed.yt-live-chat-ticker-renderer, fieldset.yt-live-chat-ticker-renderer, font.yt-live-chat-ticker-renderer, form.yt-live-chat-ticker-renderer, h1.yt-live-chat-ticker-renderer, h2.yt-live-chat-ticker-renderer, h3.yt-live-chat-ticker-renderer, h4.yt-live-chat-ticker-renderer, h5.yt-live-chat-ticker-renderer, h6.yt-live-chat-ticker-renderer, hr.yt-live-chat-ticker-renderer, i.yt-live-chat-ticker-renderer, iframe.yt-live-chat-ticker-renderer, img.yt-live-chat-ticker-renderer, ins.yt-live-chat-ticker-renderer, kbd.yt-live-chat-ticker-renderer, label.yt-live-chat-ticker-renderer, legend.yt-live-chat-ticker-renderer, li.yt-live-chat-ticker-renderer, menu.yt-live-chat-ticker-renderer, object.yt-live-chat-ticker-renderer, ol.yt-live-chat-ticker-renderer, p.yt-live-chat-ticker-renderer, pre.yt-live-chat-ticker-renderer, q.yt-live-chat-ticker-renderer, s.yt-live-chat-ticker-renderer, samp.yt-live-chat-ticker-renderer, small.yt-live-chat-ticker-renderer, span.yt-live-chat-ticker-renderer, strike.yt-live-chat-ticker-renderer, strong.yt-live-chat-ticker-renderer, sub.yt-live-chat-ticker-renderer, sup.yt-live-chat-ticker-renderer, table.yt-live-chat-ticker-renderer, tbody.yt-live-chat-ticker-renderer, td.yt-live-chat-ticker-renderer, tfoot.yt-live-chat-ticker-renderer, th.yt-live-chat-ticker-renderer, thead.yt-live-chat-ticker-renderer, tr.yt-live-chat-ticker-renderer, tt.yt-live-chat-ticker-renderer, u.yt-live-chat-ticker-renderer, ul.yt-live-chat-ticker-renderer, var.yt-live-chat-ticker-renderer {
margin: 0;
padding: 0;
border: 0;
background: transparent;
}
.yt-live-chat-ticker-renderer[hidden] {
display: none !important;
}
yt-live-chat-ticker-renderer {
display: block;
background-color: var(--yt-live-chat-header-background-color);
}
#container.yt-live-chat-ticker-renderer {
position: relative;
}
#items.yt-live-chat-ticker-renderer {
height: 32px;
overflow: hidden;
white-space: nowrap;
padding: 0 24px 8px 24px;
}
#items.yt-live-chat-ticker-renderer>*.yt-live-chat-ticker-renderer {
margin-right: 8px;
}
#left-arrow-container.yt-live-chat-ticker-renderer {
background: linear-gradient(to right, var(--yt-live-chat-ticker-arrow-background) 0, var(--yt-live-chat-ticker-arrow-background) 52px, transparent 60px);
left: 0;
padding: 0 16px 0 12px;
}
#right-arrow-container.yt-live-chat-ticker-renderer {
background: linear-gradient(to left, var(--yt-live-chat-ticker-arrow-background) 0, var(--yt-live-chat-ticker-arrow-background) 52px, transparent 60px);
right: 0;
padding: 0 12px 0 16px;
}
#container.yt-live-chat-ticker-renderer:hover #left-arrow-container.yt-live-chat-ticker-renderer, #container.yt-live-chat-ticker-renderer:hover #right-arrow-container.yt-live-chat-ticker-renderer {
opacity: 1;
}
#left-arrow-container.yt-live-chat-ticker-renderer, #right-arrow-container.yt-live-chat-ticker-renderer {
height: 32px;
opacity: 0;
position: absolute;
text-align: center;
top: 0;
transition: opacity 0.3s 0.1s;
}
yt-icon.yt-live-chat-ticker-renderer {
background-color: #2196f3;
border-radius: 999px;
color: #fff;
cursor: pointer;
height: 24px;
padding: 4px;
width: 24px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -1,426 +0,0 @@
yt-live-chat-text-message-renderer {
position: relative;
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
-ms-flex-direction: row;
-webkit-flex-direction: row;
flex-direction: row;
-ms-flex-align: start;
-webkit-align-items: flex-start;
align-items: flex-start;
padding: 4px 24px;
}
yt-live-chat-text-message-renderer #content {
-ms-align-self: center;
-webkit-align-self: center;
align-self: center;
overflow: hidden;
}
yt-live-chat-text-message-renderer #timestamp {
display: none;
margin-right: 8px;
}
yt-live-chat-text-message-renderer #author-name {
margin-right: 8px;
font-weight: 500;
}
yt-live-chat-text-message-renderer #author-photo {
display: inline-block;
background-color: transparent;
-ms-flex: none;
-webkit-flex: none;
flex: none;
}
yt-live-chat-text-message-renderer #author-badges {
vertical-align: text-bottom;
display: -ms-inline-flexbox;
display: -webkit-inline-flex;
display: inline-flex;
}
yt-live-chat-text-message-renderer #message {
color: #fff;
font-size: 12px;
line-height: 16px;
word-wrap: break-word;
}
yt-live-chat-author-badge-renderer {
display: block;
width: 16px;
height: 16px;
background-color: currentColor;
margin-right: 4px;
border-radius: 16px;
}
yt-live-chat-legacy-paid-message-renderer {
position: relative;
overflow: hidden;
margin: 8px 24px;
padding: 8px 16px;
background-color: #0f9d58;
border-radius: 4px;
color: #fff;
font-size: 14px;
min-height: 40px;
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
-ms-flex-direction: row;
-webkit-flex-direction: row;
flex-direction: row;
-ms-flex-align: center;
-webkit-align-items: center;
align-items: center;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2);
}
yt-live-chat-legacy-paid-message-renderer #author-photo {
background-color: transparent;
margin-right: 16px;
overflow: hidden;
-ms-flex: none;
-webkit-flex: none;
flex: none;
-ms-align-self: flex-start;
-webkit-align-self: flex-start;
align-self: flex-start;
}
yt-live-chat-legacy-paid-message-renderer #content {
-ms-flex: 1 1 0.000000001px;
-webkit-flex: 1;
flex: 1;
-webkit-flex-basis: 0.000000001px;
flex-basis: 0.000000001px;
}
yt-live-chat-legacy-paid-message-renderer #event-text {
font-weight: 500;
}
yt-live-chat-legacy-paid-message-renderer #detail-text {
word-wrap: break-word;
}
yt-live-chat-paid-message-renderer {
position: relative;
border-radius: 4px;
margin: 8px 24px;
font-size: 15px;
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
-ms-flex-direction: column;
-webkit-flex-direction: column;
flex-direction: column;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2);
}
yt-live-chat-paid-message-renderer #header {
position: relative;
font-weight: 500;
padding: 8px 16px;
min-height: 20px;
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
-ms-flex-direction: row;
-webkit-flex-direction: row;
flex-direction: row;
-ms-flex-align: center;
-webkit-align-items: center;
align-items: center;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
yt-live-chat-paid-message-renderer #header-content {
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
-ms-flex-direction: column;
-webkit-flex-direction: column;
flex-direction: column;
-ms-flex-pack: justify;
-webkit-justify-content: space-between;
justify-content: space-between;
-ms-flex: 1 1 0.000000001px;
-webkit-flex: 1;
flex: 1;
-webkit-flex-basis: 0.000000001px;
flex-basis: 0.000000001px;
}
yt-live-chat-paid-message-renderer #author-photo {
background-color: transparent;
-ms-flex: none;
-webkit-flex: none;
flex: none;
}
yt-live-chat-paid-message-renderer #author-name {
font-size: 14px;
}
yt-live-chat-paid-message-renderer #content {
padding: 4px 16px 8px 16px;
word-wrap: break-word;
word-break: break-word;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
yt-live-chat-ticker-paid-message-item-renderer {
display: inline-block;
height: 24px;
border-radius: 200px;
padding: 4px;
}
yt-live-chat-ticker-paid-message-item-renderer #content {
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
-ms-flex-direction: row;
-webkit-flex-direction: row;
flex-direction: row;
-ms-flex-align: center;
-webkit-align-items: center;
align-items: center;
margin-right: 4px;
}
yt-live-chat-ticker-paid-message-item-renderer #fake-avatar {
display: inline-block;
background-color: red;
width: 24px;
height: 24px;
margin-right: 4px;
border-radius: 24px;
}
/* 以下为自动生成https://chatv2.septapus.com/ */
/* @import url("https://fonts.googleapis.com/css?family=Changa%20One"); */
/* @import url("https://fonts.googleapis.com/css?family=Imprima"); */
@import url("https://fonts.lug.ustc.edu.cn/css?family=Changa%20One");
@import url("https://fonts.lug.ustc.edu.cn/css?family=Imprima");
/* Background colors*/
body {
overflow: hidden;
background-color: rgba(0,0,0,0);
}
/* Transparent background. */
yt-live-chat-renderer {
background-color: transparent !important;
}
yt-live-chat-text-message-renderer,
yt-live-chat-text-message-renderer[is-highlighted] {
background-color: transparent !important;
}
yt-live-chat-text-message-renderer[author-type="owner"],
yt-live-chat-text-message-renderer[author-type="owner"][is-highlighted] {
background-color: transparent !important;
}
yt-live-chat-text-message-renderer[author-type="moderator"],
yt-live-chat-text-message-renderer[author-type="moderator"][is-highlighted] {
background-color: transparent !important;
}
yt-live-chat-text-message-renderer[author-type="member"],
yt-live-chat-text-message-renderer[author-type="member"][is-highlighted] {
background-color: transparent !important;
}
yt-live-chat-author-chip #author-name {
background-color: transparent !important;
}
/* Outlines */
yt-live-chat-renderer * {
text-shadow: -2px -2px #000000,-2px -1px #000000,-2px 0px #000000,-2px 1px #000000,-2px 2px #000000,-1px -2px #000000,-1px -1px #000000,-1px 0px #000000,-1px 1px #000000,-1px 2px #000000,0px -2px #000000,0px -1px #000000,0px 0px #000000,0px 1px #000000,0px 2px #000000,1px -2px #000000,1px -1px #000000,1px 0px #000000,1px 1px #000000,1px 2px #000000,2px -2px #000000,2px -1px #000000,2px 0px #000000,2px 1px #000000,2px 2px #000000;
font-family: "Imprima";
font-size: 18px !important;
line-height: 18px !important;
}
yt-live-chat-text-message-renderer #content,
yt-live-chat-legacy-paid-message-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,
yt-live-chat-legacy-paid-message-renderer {
padding-left: 4px !important;
padding-right: 4px !important;
}
yt-live-chat-paid-message-renderer #header {
padding-left: 4px !important;
padding-right: 4px !important;
}
/* Avatars. */
yt-live-chat-text-message-renderer #author-photo,
yt-live-chat-paid-message-renderer #author-photo,
yt-live-chat-legacy-paid-message-renderer #author-photo {
width: 24px !important;
height: 24px !important;
border-radius: 24px !important;
margin-right: 6px !important;
background-size: cover;
}
/* Hide badges. */
yt-live-chat-text-message-renderer #author-badges {
display: none !important;
vertical-align: text-top !important;
}
/* Timestamps. */
yt-live-chat-text-message-renderer #timestamp {
color: #999999 !important;
font-family: "Imprima";
font-size: 16px !important;
line-height: 16px !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"] {
color: #ffd600 !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"] {
color: #5e84f1 !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"] {
color: #0f9d58 !important;
}
/* Channel names. */
yt-live-chat-text-message-renderer #author-name {
color: #cccccc !important;
font-family: "Changa One";
font-size: 20px !important;
line-height: 20px !important;
}
yt-live-chat-text-message-renderer #author-name::after {
content: ":";
margin-left: 2px;
}
/* Messages. */
yt-live-chat-text-message-renderer #message,
yt-live-chat-text-message-renderer #message * {
color: #ffffff !important;
font-family: "Imprima";
font-size: 18px !important;
line-height: 18px !important;
}
/* SuperChat/Fan Funding Messages. */
yt-live-chat-paid-message-renderer #author-name,
yt-live-chat-paid-message-renderer #author-name *,
yt-live-chat-legacy-paid-message-renderer #event-text,
yt-live-chat-legacy-paid-message-renderer #event-text * {
color: #ffffff !important;
font-family: "Changa One";
font-size: 20px !important;
line-height: 20px !important;
}
yt-live-chat-paid-message-renderer #purchase-amount,
yt-live-chat-paid-message-renderer #purchase-amount *,
yt-live-chat-legacy-paid-message-renderer #detail-text,
yt-live-chat-legacy-paid-message-renderer #detail-text * {
color: #ffffff !important;
font-family: "Imprima";
font-size: 18px !important;
line-height: 18px !important;
}
yt-live-chat-paid-message-renderer #content,
yt-live-chat-paid-message-renderer #content * {
color: #ffffff !important;
font-family: "Imprima";
font-size: 18px !important;
line-height: 18px !important;
}
yt-live-chat-paid-message-renderer {
margin: 4px 0 !important;
}
yt-live-chat-legacy-paid-message-renderer {
background-color: #0f9d58 !important;
margin: 4px 0 !important;
}
yt-live-chat-text-message-renderer a,
yt-live-chat-legacy-paid-message-renderer a {
text-decoration: none !important;
}
yt-live-chat-text-message-renderer[is-deleted],
yt-live-chat-legacy-paid-message-renderer[is-deleted] {
display: none !important;
}
yt-live-chat-ticker-renderer {
background-color: transparent !important;
box-shadow: none !important;
}
yt-live-chat-ticker-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 * {
color: #ffffff !important;
font-family: "Imprima";
}
yt-live-chat-mode-change-message-renderer,
yt-live-chat-viewer-engagement-message-renderer,
yt-live-chat-restricted-participation-renderer {
display: none !important;
}

View File

@@ -0,0 +1,51 @@
<template>
<yt-live-chat-author-badge-renderer :type="authorTypeText">
<el-tooltip :content="readableAuthorTypeText" placement="top">
<div id="image" class="style-scope yt-live-chat-author-badge-renderer">
<yt-icon v-if="isAdmin" class="style-scope yt-live-chat-author-badge-renderer">
<svg viewBox="0 0 16 16" class="style-scope yt-icon" preserveAspectRatio="xMidYMid meet" focusable="false"
style="pointer-events: none; display: block; width: 100%; height: 100%;"
>
<g class="style-scope yt-icon">
<path class="style-scope yt-icon"
d="M9.64589146,7.05569719 C9.83346524,6.562372 9.93617022,6.02722257 9.93617022,5.46808511 C9.93617022,3.00042984 7.93574038,1 5.46808511,1 C4.90894765,1 4.37379823,1.10270499 3.88047304,1.29027875 L6.95744681,4.36725249 L4.36725255,6.95744681 L1.29027875,3.88047305 C1.10270498,4.37379824 1,4.90894766 1,5.46808511 C1,7.93574038 3.00042984,9.93617022 5.46808511,9.93617022 C6.02722256,9.93617022 6.56237198,9.83346524 7.05569716,9.64589147 L12.4098057,15 L15,12.4098057 L9.64589146,7.05569719 Z"
></path>
</g>
</svg>
</yt-icon>
<img v-else :src="`/static/img/icons/guard-level-${privilegeType}.png`"
class="style-scope yt-live-chat-author-badge-renderer" :alt="readableAuthorTypeText"
>
</div>
</el-tooltip>
</yt-live-chat-author-badge-renderer>
</template>
<script>
import * as constants from './constants'
export default {
name: 'AuthorBadge',
props: {
isAdmin: Boolean,
privilegeType: Number
},
computed: {
authorTypeText() {
if (this.isAdmin) {
return 'moderator'
}
return this.privilegeType > 0 ? 'member' : ''
},
readableAuthorTypeText() {
if (this.isAdmin) {
return this.$t('chat.moderator')
}
return constants.getShowGuardLevelText(this.privilegeType)
}
}
}
</script>
<style src="@/assets/css/youtube/yt-live-chat-author-badge-renderer.css"></style>
<style src="@/assets/css/youtube/yt-icon.css"></style>

View File

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

View File

@@ -0,0 +1,37 @@
<template>
<yt-img-shadow class="no-transition" :height="height" :width="width" style="background-color: transparent;" loaded>
<img id="img" class="style-scope yt-img-shadow" alt="" :height="height" :width="width" :src="showImgUrl" @error="onLoadError">
</yt-img-shadow>
</template>
<script>
import * as chat from '@/api/chat'
export default {
name: 'ImgShadow',
props: {
imgUrl: String,
height: String,
width: String
},
data() {
return {
showImgUrl: this.imgUrl
}
},
watch: {
imgUrl(val) {
this.showImgUrl = val
}
},
methods: {
onLoadError() {
if (this.showImgUrl !== chat.DEFAULT_AVATAR_URL) {
this.showImgUrl = chat.DEFAULT_AVATAR_URL
}
}
}
}
</script>
<style src="@/assets/css/youtube/yt-img-shadow.css"></style>

View File

@@ -0,0 +1,50 @@
<template>
<yt-live-chat-membership-item-renderer class="style-scope yt-live-chat-item-list-renderer" show-only-header>
<div id="card" class="style-scope yt-live-chat-membership-item-renderer">
<div id="header" class="style-scope yt-live-chat-membership-item-renderer">
<img-shadow id="author-photo" height="40" width="40" class="style-scope yt-live-chat-membership-item-renderer"
:imgUrl="avatarUrl"
></img-shadow>
<div id="header-content" class="style-scope yt-live-chat-membership-item-renderer">
<div id="header-content-primary-column" class="style-scope yt-live-chat-membership-item-renderer">
<div id="header-content-inner-column" class="style-scope yt-live-chat-membership-item-renderer">
<author-chip class="style-scope yt-live-chat-membership-item-renderer"
isInMemberMessage :authorName="authorName" :authorType="0" :privilegeType="privilegeType"
></author-chip>
</div>
<div id="header-subtext" class="style-scope yt-live-chat-membership-item-renderer">{{ title }}</div>
</div>
<div id="timestamp" class="style-scope yt-live-chat-membership-item-renderer">{{ timeText }}</div>
</div>
</div>
</div>
</yt-live-chat-membership-item-renderer>
</template>
<script>
import ImgShadow from './ImgShadow'
import AuthorChip from './AuthorChip'
import * as utils from '@/utils'
export default {
name: 'MembershipItem',
components: {
ImgShadow,
AuthorChip
},
props: {
avatarUrl: String,
authorName: String,
privilegeType: Number,
title: String,
time: Date
},
computed: {
timeText() {
return utils.getTimeTextHourMin(this.time)
}
}
}
</script>
<style src="@/assets/css/youtube/yt-live-chat-membership-item-renderer.css"></style>

View File

@@ -0,0 +1,63 @@
<template>
<yt-live-chat-paid-message-renderer class="style-scope yt-live-chat-item-list-renderer" allow-animations
:show-only-header="!content" :style="{
'--yt-live-chat-paid-message-primary-color': color.contentBg,
'--yt-live-chat-paid-message-secondary-color': color.headerBg,
'--yt-live-chat-paid-message-header-color': color.header,
'--yt-live-chat-paid-message-author-name-color': color.authorName,
'--yt-live-chat-paid-message-timestamp-color': color.time,
'--yt-live-chat-paid-message-color': color.content
}"
>
<div id="card" class="style-scope yt-live-chat-paid-message-renderer">
<div id="header" class="style-scope yt-live-chat-paid-message-renderer">
<img-shadow id="author-photo" height="40" width="40" class="style-scope yt-live-chat-paid-message-renderer"
:imgUrl="avatarUrl"
></img-shadow>
<div id="header-content" class="style-scope yt-live-chat-paid-message-renderer">
<div id="header-content-primary-column" class="style-scope yt-live-chat-paid-message-renderer">
<div id="author-name" class="style-scope yt-live-chat-paid-message-renderer">{{ authorName }}</div>
<div id="purchase-amount" class="style-scope yt-live-chat-paid-message-renderer">{{ priceText }}</div>
</div>
<span id="timestamp" class="style-scope yt-live-chat-paid-message-renderer">{{ timeText }}</span>
</div>
</div>
<div id="content" class="style-scope yt-live-chat-paid-message-renderer">
<div id="message" dir="auto" class="style-scope yt-live-chat-paid-message-renderer">{{ content }}</div>
</div>
</div>
</yt-live-chat-paid-message-renderer>
</template>
<script>
import ImgShadow from './ImgShadow'
import * as constants from './constants'
import * as utils from '@/utils'
export default {
name: 'PaidMessage',
components: {
ImgShadow
},
props: {
avatarUrl: String,
authorName: String,
price: Number, // 价格,人民币
time: Date,
content: String
},
computed: {
color() {
return constants.getPriceConfig(this.price).colors
},
priceText() {
return `CN¥${utils.formatCurrency(this.price)}`
},
timeText() {
return utils.getTimeTextHourMin(this.time)
}
}
}
</script>
<style src="@/assets/css/youtube/yt-live-chat-paid-message-renderer.css"></style>

View File

@@ -0,0 +1,100 @@
<template>
<yt-live-chat-text-message-renderer :author-type="authorTypeText">
<img-shadow id="author-photo" height="24" width="24" class="style-scope yt-live-chat-text-message-renderer"
:imgUrl="avatarUrl"
></img-shadow>
<div id="content" class="style-scope yt-live-chat-text-message-renderer">
<span id="timestamp" class="style-scope yt-live-chat-text-message-renderer">{{ timeText }}</span>
<author-chip class="style-scope yt-live-chat-text-message-renderer"
:isInMemberMessage="false" :authorName="authorName" :authorType="authorType" :privilegeType="privilegeType"
></author-chip>
<span id="message" class="style-scope yt-live-chat-text-message-renderer">
<template v-for="(content, index) in richContent">
<span :key="index" v-if="content.type === CONTENT_TYPE_TEXT">{{ content.text }}</span>
<img :key="index" v-else-if="content.type === CONTENT_TYPE_IMAGE"
class="emoji yt-formatted-string style-scope yt-live-chat-text-message-renderer"
:src="content.url" :alt="content.text" :shared-tooltip-text="content.text" :id="`emoji-${content.text}`"
>
</template>
<el-badge :value="repeated" :max="99" v-if="repeated > 1" class="style-scope yt-live-chat-text-message-renderer"
:style="{ '--repeated-mark-color': repeatedMarkColor }"
></el-badge>
</span>
</div>
</yt-live-chat-text-message-renderer>
</template>
<script>
import ImgShadow from './ImgShadow'
import AuthorChip from './AuthorChip'
import * as constants from './constants'
import * as utils from '@/utils'
// HSL
const REPEATED_MARK_COLOR_START = [210, 100.0, 62.5]
const REPEATED_MARK_COLOR_END = [360, 87.3, 69.2]
export default {
name: 'TextMessage',
components: {
ImgShadow,
AuthorChip
},
props: {
avatarUrl: String,
time: Date,
authorName: String,
authorType: Number,
richContent: Array,
privilegeType: Number,
repeated: Number
},
data() {
return {
CONTENT_TYPE_TEXT: constants.CONTENT_TYPE_TEXT,
CONTENT_TYPE_IMAGE: constants.CONTENT_TYPE_IMAGE
}
},
computed: {
timeText() {
return utils.getTimeTextHourMin(this.time)
},
authorTypeText() {
return constants.AUTHOR_TYPE_TO_TEXT[this.authorType]
},
repeatedMarkColor() {
let color
if (this.repeated <= 2) {
color = REPEATED_MARK_COLOR_START
} else if (this.repeated >= 10) {
color = REPEATED_MARK_COLOR_END
} else {
color = [0, 0, 0]
let t = (this.repeated - 2) / (10 - 2)
for (let i = 0; i < 3; i++) {
color[i] = REPEATED_MARK_COLOR_START[i] + ((REPEATED_MARK_COLOR_END[i] - REPEATED_MARK_COLOR_START[i]) * t)
}
}
return `hsl(${color[0]}, ${color[1]}%, ${color[2]}%)`
}
}
}
</script>
<style>
yt-live-chat-text-message-renderer>#content>#message>.el-badge {
margin-left: 10px;
}
yt-live-chat-text-message-renderer>#content>#message>.el-badge .el-badge__content {
font-size: 12px !important;
line-height: 18px !important;
text-shadow: none !important;
font-family: sans-serif !important;
color: #FFF !important;
background-color: var(--repeated-mark-color) !important;
border: none;
}
</style>
<style src="@/assets/css/youtube/yt-live-chat-text-message-renderer.css"></style>

View File

@@ -0,0 +1,205 @@
<template>
<yt-live-chat-ticker-renderer :hidden="showMessages.length === 0">
<div id="container" dir="ltr" class="style-scope yt-live-chat-ticker-renderer">
<transition-group tag="div" :css="false" @enter="onTickerItemEnter" @leave="onTickerItemLeave"
id="items" class="style-scope yt-live-chat-ticker-renderer"
>
<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)"
>
<div id="container" dir="ltr" class="style-scope yt-live-chat-ticker-paid-message-item-renderer" :style="{
background: message.bgColor,
}">
<div id="content" class="style-scope yt-live-chat-ticker-paid-message-item-renderer" :style="{
color: message.color
}">
<img-shadow id="author-photo" height="24" width="24" class="style-scope yt-live-chat-ticker-paid-message-item-renderer"
:imgUrl="message.raw.avatarUrl"
></img-shadow>
<span id="text" dir="ltr" class="style-scope yt-live-chat-ticker-paid-message-item-renderer">{{ message.text }}</span>
</div>
</div>
</yt-live-chat-ticker-paid-message-item-renderer>
</transition-group>
</div>
<template v-if="pinnedMessage">
<membership-item :key="pinnedMessage.id" v-if="pinnedMessage.type === MESSAGE_TYPE_MEMBER"
class="style-scope yt-live-chat-ticker-renderer"
:avatarUrl="pinnedMessage.avatarUrl" :authorName="getShowAuthorName(pinnedMessage)" :privilegeType="pinnedMessage.privilegeType"
:title="pinnedMessage.title" :time="pinnedMessage.time"
></membership-item>
<paid-message :key="pinnedMessage.id" v-else
class="style-scope yt-live-chat-ticker-renderer"
:price="pinnedMessage.price" :avatarUrl="pinnedMessage.avatarUrl" :authorName="getShowAuthorName(pinnedMessage)"
:time="pinnedMessage.time" :content="pinnedMessageShowContent"
></paid-message>
</template>
</yt-live-chat-ticker-renderer>
</template>
<script>
import * as chatConfig from '@/api/chatConfig'
import { formatCurrency } from '@/utils'
import ImgShadow from './ImgShadow'
import MembershipItem from './MembershipItem'
import PaidMessage from './PaidMessage'
import * as constants from './constants'
export default {
name: 'Ticker',
components: {
ImgShadow,
MembershipItem,
PaidMessage
},
props: {
messages: Array,
showGiftName: {
type: Boolean,
default: chatConfig.DEFAULT_CONFIG.showGiftName
}
},
data() {
return {
MESSAGE_TYPE_MEMBER: constants.MESSAGE_TYPE_MEMBER,
curTime: new Date(),
updateTimerId: window.setInterval(this.updateProgress, 1000),
pinnedMessage: null
}
},
computed: {
showMessages() {
let res = []
for (let message of this.messages) {
if (!this.needToShow(message)) {
continue
}
res.push({
raw: message,
bgColor: this.getBgColor(message),
color: this.getColor(message),
text: this.getText(message)
})
}
return res
},
pinnedMessageShowContent() {
if (!this.pinnedMessage) {
return ''
}
if (this.pinnedMessage.type === constants.MESSAGE_TYPE_GIFT) {
return constants.getGiftShowContent(this.pinnedMessage, this.showGiftName)
} else {
return constants.getShowContent(this.pinnedMessage)
}
}
},
beforeDestroy() {
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)
return (new Date() - message.addTime) / (60 * 1000) < pinTime
},
getBgColor(message) {
let color1, color2
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
color1 = 'rgba(15,157,88,1)'
color2 = 'rgba(11,128,67,1)'
} else {
let config = constants.getPriceConfig(message.price)
color1 = config.colors.contentBg
color2 = config.colors.headerBg
}
let pinTime = this.getPinTime(message)
let progress = (1 - ((this.curTime - message.addTime) / (60 * 1000) / pinTime)) * 100
if (progress < 0) {
progress = 0
} else if (progress > 100) {
progress = 100
}
return `linear-gradient(90deg, ${color1}, ${color1} ${progress}%, ${color2} ${progress}%, ${color2})`
},
getColor(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
return 'rgb(255,255,255)'
}
return constants.getPriceConfig(message.price).colors.header
},
getText(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
return this.$t('chat.tickerMembership')
}
return `CN¥${formatCurrency(message.price)}`
},
getPinTime(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
return 2
}
return constants.getPriceConfig(message.price).pinTime
},
updateProgress() {
// 更新进度
this.curTime = new Date()
// 删除过期的消息
let filteredMessages = []
let messagesChanged = false
for (let message of this.messages) {
let pinTime = this.getPinTime(message)
if ((this.curTime - message.addTime) / (60 * 1000) >= pinTime) {
messagesChanged = true
if (this.pinnedMessage === message) {
this.pinnedMessage = null
}
continue
}
filteredMessages.push(message)
}
if (messagesChanged) {
this.$emit('update:messages', filteredMessages)
}
},
onItemClick(message) {
if (this.pinnedMessage == message) {
this.pinnedMessage = null
} else {
this.pinnedMessage = message
}
}
}
}
</script>
<style src="@/assets/css/youtube/yt-live-chat-ticker-renderer.css"></style>
<style src="@/assets/css/youtube/yt-live-chat-ticker-paid-message-item-renderer.css"></style>

View File

@@ -0,0 +1,168 @@
import * as i18n from '@/i18n'
export const AUTHRO_TYPE_NORMAL = 0
export const AUTHRO_TYPE_MEMBER = 1
export const AUTHRO_TYPE_ADMIN = 2
export const AUTHRO_TYPE_OWNER = 3
export const AUTHOR_TYPE_TO_TEXT = [
'',
'member', // 舰队
'moderator', // 房管
'owner' // 主播
]
const GUARD_LEVEL_TO_TEXT_KEY = [
'',
'chat.guardLevel1',
'chat.guardLevel2',
'chat.guardLevel3'
]
export function getShowGuardLevelText(guardLevel) {
let key = GUARD_LEVEL_TO_TEXT_KEY[guardLevel] || ''
if (key === '') {
return ''
}
return i18n.i18n.t(key)
}
export const MESSAGE_TYPE_TEXT = 0
export const MESSAGE_TYPE_GIFT = 1
export const MESSAGE_TYPE_MEMBER = 2
export const MESSAGE_TYPE_SUPER_CHAT = 3
export const MESSAGE_TYPE_DEL = 4
export const MESSAGE_TYPE_UPDATE = 5
export const CONTENT_TYPE_TEXT = 0
export const CONTENT_TYPE_IMAGE = 1
// 美元 -> 人民币 汇率
const EXCHANGE_RATE = 7
export const PRICE_CONFIGS = [
{ // $100红
price: 100 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(230,33,23,1)',
headerBg: 'rgba(208,0,0,1)',
header: 'rgba(255,255,255,1)',
authorName: 'rgba(255,255,255,0.701961)',
time: 'rgba(255,255,255,0.501961)',
content: 'rgba(255,255,255,1)'
},
pinTime: 60
},
{ // $50品红
price: 50 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(233,30,99,1)',
headerBg: 'rgba(194,24,91,1)',
header: 'rgba(255,255,255,1)',
authorName: 'rgba(255,255,255,0.701961)',
time: 'rgba(255,255,255,0.501961)',
content: 'rgba(255,255,255,1)'
},
pinTime: 30
},
{ // $20橙
price: 20 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(245,124,0,1)',
headerBg: 'rgba(230,81,0,1)',
header: 'rgba(255,255,255,0.87451)',
authorName: 'rgba(255,255,255,0.701961)',
time: 'rgba(255,255,255,0.501961)',
content: 'rgba(255,255,255,0.87451)'
},
pinTime: 10
},
{ // $10黄
price: 10 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(255,202,40,1)',
headerBg: 'rgba(255,179,0,1)',
header: 'rgba(0,0,0,0.87451)',
authorName: 'rgba(0,0,0,0.541176)',
time: 'rgba(0,0,0,0.501961)',
content: 'rgba(0,0,0,0.87451)'
},
pinTime: 5
},
{ // $5绿
price: 5 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(29,233,182,1)',
headerBg: 'rgba(0,191,165,1)',
header: 'rgba(0,0,0,1)',
authorName: 'rgba(0,0,0,0.541176)',
time: 'rgba(0,0,0,0.501961)',
content: 'rgba(0,0,0,1)'
},
pinTime: 2
},
{ // $2浅蓝
price: 2 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(0,229,255,1)',
headerBg: 'rgba(0,184,212,1)',
header: 'rgba(0,0,0,1)',
authorName: 'rgba(0,0,0,0.701961)',
time: 'rgba(0,0,0,0.501961)',
content: 'rgba(0,0,0,1)'
},
pinTime: 0
},
{ // $1蓝
price: EXCHANGE_RATE,
colors: {
contentBg: 'rgba(30,136,229,1)',
headerBg: 'rgba(21,101,192,1)',
header: 'rgba(255,255,255,1)',
authorName: 'rgba(255,255,255,0.701961)',
time: 'rgba(255,255,255,0.501961)',
content: 'rgba(255,255,255,1)'
},
pinTime: 0
}
]
export function getPriceConfig(price) {
for (const config of PRICE_CONFIGS) {
if (price >= config.price) {
return config
}
}
return PRICE_CONFIGS[PRICE_CONFIGS.length - 1]
}
export function getShowContent(message) {
if (message.translation) {
return `${message.content}${message.translation}`
}
return message.content
}
export function getShowRichContent(message) {
let richContent = [...message.richContent]
if (message.translation) {
richContent.push({
type: CONTENT_TYPE_TEXT,
text: `${message.translation}`
})
}
return richContent
}
export function getGiftShowContent(message, showGiftName) {
if (!showGiftName) {
return ''
}
return i18n.i18n.t('chat.sendGift', { giftName: message.giftName, num: message.num })
}
export function getShowAuthorName(message) {
if (message.authorNamePronunciation && message.authorNamePronunciation !== message.authorName) {
return `${message.authorName}(${message.authorNamePronunciation})`
}
return message.authorName
}

View File

@@ -0,0 +1,583 @@
<template>
<yt-live-chat-renderer class="style-scope yt-live-chat-app" style="--scrollbar-width:11px;" hide-timestamps
@mousemove="refreshCantScrollStartTime"
>
<ticker class="style-scope yt-live-chat-renderer" :messages.sync="paidMessages" :showGiftName="showGiftName"></ticker>
<yt-live-chat-item-list-renderer class="style-scope yt-live-chat-renderer" allow-scroll>
<div ref="scroller" id="item-scroller" class="style-scope yt-live-chat-item-list-renderer animated" @scroll="onScroll">
<div ref="itemOffset" id="item-offset" class="style-scope yt-live-chat-item-list-renderer" style="height: 0px;">
<div ref="items" id="items" class="style-scope yt-live-chat-item-list-renderer" style="overflow: hidden"
:style="{ transform: `translateY(${Math.floor(scrollPixelsRemaining)}px)` }"
>
<template v-for="message in messages">
<text-message :key="message.id" v-if="message.type === MESSAGE_TYPE_TEXT"
class="style-scope yt-live-chat-item-list-renderer"
:time="message.time"
:avatarUrl="message.avatarUrl"
:authorName="message.authorName"
:authorType="message.authorType"
:privilegeType="message.privilegeType"
:richContent="getShowRichContent(message)"
:repeated="message.repeated"
></text-message>
<paid-message :key="message.id" v-else-if="message.type === MESSAGE_TYPE_GIFT"
class="style-scope yt-live-chat-item-list-renderer"
:time="message.time"
:avatarUrl="message.avatarUrl"
:authorName="getShowAuthorName(message)"
:price="message.price"
:content="getGiftShowContent(message)"
></paid-message>
<membership-item :key="message.id" v-else-if="message.type === MESSAGE_TYPE_MEMBER"
class="style-scope yt-live-chat-item-list-renderer"
:time="message.time"
:avatarUrl="message.avatarUrl"
:authorName="getShowAuthorName(message)"
:privilegeType="message.privilegeType"
:title="message.title"
></membership-item>
<paid-message :key="message.id" v-else-if="message.type === MESSAGE_TYPE_SUPER_CHAT"
class="style-scope yt-live-chat-item-list-renderer"
:time="message.time"
:avatarUrl="message.avatarUrl"
:authorName="getShowAuthorName(message)"
:price="message.price"
:content="getShowContent(message)"
></paid-message>
</template>
</div>
</div>
</div>
</yt-live-chat-item-list-renderer>
</yt-live-chat-renderer>
</template>
<script>
import * as chatConfig from '@/api/chatConfig'
import Ticker from './Ticker'
import TextMessage from './TextMessage'
import MembershipItem from './MembershipItem'
import PaidMessage from './PaidMessage'
import * as constants from './constants'
// 只有要添加的消息需要平滑
const NEED_SMOOTH_MESSAGE_TYPES = [
constants.MESSAGE_TYPE_TEXT,
constants.MESSAGE_TYPE_GIFT,
constants.MESSAGE_TYPE_MEMBER,
constants.MESSAGE_TYPE_SUPER_CHAT
]
// 发送消息时间间隔范围
const MESSAGE_MIN_INTERVAL = 80
const MESSAGE_MAX_INTERVAL = 1000
// 每次发送消息后增加的动画时间要比MESSAGE_MIN_INTERVAL稍微大一点太小了动画不连续太大了发送消息时会中断动画
// 84 = ceil((1000 / 60) * 5)
const CHAT_SMOOTH_ANIMATION_TIME_MS = 84
// 滚动条距离底部小于多少像素则认为在底部
const SCROLLED_TO_BOTTOM_EPSILON = 15
export default {
name: 'ChatRenderer',
components: {
Ticker,
TextMessage,
MembershipItem,
PaidMessage
},
props: {
maxNumber: {
type: Number,
default: chatConfig.DEFAULT_CONFIG.maxNumber
},
showGiftName: {
type: Boolean,
default: chatConfig.DEFAULT_CONFIG.showGiftName
}
},
data() {
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,
messages: [], // 显示的消息
paidMessages: [], // 固定在上方的消息
smoothedMessageQueue: [], // 平滑消息队列由外部调用addMessages等方法添加
emitSmoothedMessageTimerId: null, // 消费平滑消息队列的定时器ID
enqueueIntervals: [], // 最近进队列的时间间隔,用来估计下次进队列的时间
lastEnqueueTime: null, // 上次进队列的时间
estimatedEnqueueInterval: null, // 估计的下次进队列时间间隔
messagesBuffer: [], // 暂时未显示的消息,当不能自动滚动时会积压在这
preinsertHeight: 0, // 插入新消息之前items的高度
isSmoothed: true, // 是否平滑滚动,当消息太快时不平滑滚动
chatRateMs: 1000, // 用来计算消息速度
scrollPixelsRemaining: 0, // 平滑滚动剩余像素
scrollTimeRemainingMs: 0, // 平滑滚动剩余时间
lastSmoothChatMessageAddMs: null, // 上次showNewMessages时间
smoothScrollRafHandle: null, // 平滑滚动requestAnimationFrame句柄
lastSmoothScrollUpdate: null, // 平滑滚动上一帧时间
atBottom: true, // 滚动到底部,用来判断能否自动滚动
cantScrollStartTime: null // 开始不能自动滚动的时间,用来防止卡住
}
},
computed: {
canScrollToBottom() {
return this.atBottom/* || this.allowScroll */
}
},
watch: {
canScrollToBottom(val) {
this.cantScrollStartTime = val ? null : new Date()
}
},
mounted() {
this.scrollToBottom()
},
beforeDestroy() {
if (this.emitSmoothedMessageTimerId) {
window.clearTimeout(this.emitSmoothedMessageTimerId)
this.emitSmoothedMessageTimerId = null
}
this.clearMessages()
},
methods: {
getGiftShowContent(message) {
return constants.getGiftShowContent(message, this.showGiftName)
},
getShowContent: constants.getShowContent,
getShowRichContent: constants.getShowRichContent,
getShowAuthorName: constants.getShowAuthorName,
addMessage(message) {
this.addMessages([message])
},
addMessages(messages) {
this.enqueueMessages(messages)
},
mergeSimilarText(content) {
content = content.trim().toLowerCase()
let res = false
this.forEachRecentMessage(5, message => {
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
return true
}
let messageContent = message.content.trim().toLowerCase()
let longer, shorter
if (messageContent.length > content.length) {
longer = messageContent
shorter = content
} else {
longer = content
shorter = messageContent
}
if (longer.indexOf(shorter) !== -1 // 长的包含短的
&& longer.length - shorter.length < shorter.length // 长度差较小
) {
// 其实有小概率导致弹幕卡住
message.repeated++
res = true
return false
}
return true
})
return res
},
mergeSimilarGift(authorName, price, giftName, num) {
let res = false
this.forEachRecentMessage(5, message => {
if (message.type === constants.MESSAGE_TYPE_GIFT
&& message.authorName === authorName
&& message.giftName === giftName
) {
message.price += price
message.num += num
res = true
return false
}
return true
})
return res
},
forEachRecentMessage(num, callback) {
// 从新到老遍历num条消息
for (let i = this.smoothedMessageQueue.length - 1; i >= 0 && num > 0; i--) {
let messageGroup = this.smoothedMessageQueue[i]
for (let j = messageGroup.length - 1; j >= 0 && num-- > 0; j--) {
if (!callback(messageGroup[j])) {
return
}
}
}
for (let arr of [this.messagesBuffer, this.messages]) {
for (let i = arr.length - 1; i >= 0 && num-- > 0; i--) {
if (!callback(arr[i])) {
return
}
}
}
},
delMessage(id) {
this.delMessages([id])
},
delMessages(ids) {
this.enqueueMessages(ids.map(
id => ({
type: constants.MESSAGE_TYPE_DEL,
id
})
))
},
clearMessages() {
this.messages = []
this.paidMessages = []
this.smoothedMessageQueue = []
this.messagesBuffer = []
this.isSmoothed = true
this.lastSmoothChatMessageAddMs = null
this.chatRateMs = 1000
this.lastSmoothScrollUpdate = null
this.scrollTimeRemainingMs = this.scrollPixelsRemaining = 0
this.smoothScrollRafHandle = null
this.preinsertHeight = 0
this.maybeResizeScrollContainer()
if (!this.atBottom) {
this.scrollToBottom()
}
},
updateMessage(id, newValuesObj) {
this.enqueueMessages([{
type: constants.MESSAGE_TYPE_UPDATE,
id,
newValuesObj
}])
},
enqueueMessages(messages) {
// 估计进队列时间间隔
if (!this.lastEnqueueTime) {
this.lastEnqueueTime = new Date()
} else {
let curTime = new Date()
let interval = curTime - this.lastEnqueueTime
// 真实的进队列时间间隔模式大概是这样2500, 300, 300, 300, 2500, 300, ...
// B站消息有缓冲会一次发多条消息。这里把波峰视为发送了一次真实的WS消息所以要过滤掉间隔太小的
if (interval > 1000 || this.enqueueIntervals.length < 5) {
this.enqueueIntervals.push(interval)
if (this.enqueueIntervals.length > 5) {
this.enqueueIntervals.splice(0, this.enqueueIntervals.length - 5)
}
// 这边估计得尽量大只要不太早把消息缓冲发完就是平滑的。有MESSAGE_MAX_INTERVAL保底不会让消息延迟太大
// 其实可以用单调队列求最大值,偷懒不写了
this.estimatedEnqueueInterval = Math.max(...this.enqueueIntervals)
}
// 上次入队时间还是要设置,否则会太早把消息缓冲发完,然后较长时间没有新消息
this.lastEnqueueTime = curTime
}
// 把messages分成messageGroup每个组里最多有1个需要平滑的消息
let messageGroup = []
for (let message of messages) {
messageGroup.push(message)
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) {
return
}
// 估计的下次进队列剩余时间
let estimatedNextEnqueueRemainTime = 10 * 1000
if (this.estimatedEnqueueInterval) {
estimatedNextEnqueueRemainTime = Math.max(this.lastEnqueueTime - new Date() + this.estimatedEnqueueInterval, 1)
}
// 计算发送的消息数,保证在下次进队列之前发完
// 下次进队列之前应该发多少条消息
let shouldEmitGroupNum = Math.max(this.smoothedMessageQueue.length, 0)
// 下次进队列之前最多能发多少次
let maxCanEmitCount = estimatedNextEnqueueRemainTime / MESSAGE_MIN_INTERVAL
// 这次发多少条消息
let groupNumToEmit
if (shouldEmitGroupNum < maxCanEmitCount) {
// 队列中消息数很少每次发1条也能发完
groupNumToEmit = 1
} else {
// 每次发1条以上保证按最快速度能发完
groupNumToEmit = Math.ceil(shouldEmitGroupNum / maxCanEmitCount)
}
// 发消息
let messageGroups = this.smoothedMessageQueue.splice(0, groupNumToEmit)
let mergedGroup = []
for (let messageGroup of messageGroups) {
for (let message of messageGroup) {
mergedGroup.push(message)
}
}
this.handleMessageGroup(mergedGroup)
if (this.smoothedMessageQueue.length <= 0) {
return
}
// 消息没发完,计算下次发消息时间
let sleepTime
if (groupNumToEmit === 1) {
// 队列中消息数很少,随便定个[MESSAGE_MIN_INTERVAL, MESSAGE_MAX_INTERVAL]的时间
sleepTime = estimatedNextEnqueueRemainTime / this.smoothedMessageQueue.length
sleepTime *= 0.5 + Math.random()
if (sleepTime > MESSAGE_MAX_INTERVAL) {
sleepTime = MESSAGE_MAX_INTERVAL
} else if (sleepTime < MESSAGE_MIN_INTERVAL) {
sleepTime = MESSAGE_MIN_INTERVAL
}
} else {
// 按最快速度发
sleepTime = MESSAGE_MIN_INTERVAL
}
this.emitSmoothedMessageTimerId = window.setTimeout(this.emitSmoothedMessages, sleepTime)
},
handleMessageGroup(messageGroup) {
if (messageGroup.length <= 0) {
return
}
for (let message of messageGroup) {
switch (message.type) {
case constants.MESSAGE_TYPE_TEXT:
case constants.MESSAGE_TYPE_GIFT:
case constants.MESSAGE_TYPE_MEMBER:
case constants.MESSAGE_TYPE_SUPER_CHAT:
this.handleAddMessage(message)
break
case constants.MESSAGE_TYPE_DEL:
this.handleDelMessage(message)
break
case constants.MESSAGE_TYPE_UPDATE:
this.handleUpdateMessage(message)
break
}
}
this.maybeResizeScrollContainer()
this.flushMessagesBuffer()
this.$nextTick(this.maybeScrollToBottom)
},
handleAddMessage(message) {
message = {
...message,
addTime: new Date() // 添加一个本地时间给Ticker用防止本地时间和服务器时间相差很大的情况
}
this.messagesBuffer.push(message)
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
this.paidMessages.unshift(message)
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 }) {
for (let arr of [this.messages, this.paidMessages, this.messagesBuffer]) {
for (let i = 0; i < arr.length; i++) {
if (arr[i].id === id) {
arr.splice(i, 1)
this.resetSmoothScroll()
break
}
}
}
},
handleUpdateMessage({ id, newValuesObj }) {
// 遍历滚动的消息
this.forEachRecentMessage(999999999, message => {
if (message.id !== id) {
return true
}
for (let name in newValuesObj) {
message[name] = newValuesObj[name]
}
return false
})
// 遍历固定的消息
for (let message of this.paidMessages) {
if (message.id !== id) {
continue
}
for (let name in newValuesObj) {
message[name] = newValuesObj[name]
}
break
}
this.resetSmoothScroll()
},
async flushMessagesBuffer() {
if (this.messagesBuffer.length <= 0) {
return
}
if (!this.canScrollToBottomOrTimedOut()) {
if (this.messagesBuffer.length > this.maxNumber) {
// 未显示消息数 > 最大可显示数,丢弃
this.messagesBuffer.splice(0, this.messagesBuffer.length - this.maxNumber)
}
return
}
let removeNum = Math.max(this.messages.length + this.messagesBuffer.length - this.maxNumber, 0)
if (removeNum > 0) {
this.messages.splice(0, removeNum)
// 防止同时添加和删除项目时所有的项目重新渲染 https://github.com/vuejs/vue/issues/6857
await this.$nextTick()
}
this.preinsertHeight = this.$refs.items.clientHeight
for (let message of this.messagesBuffer) {
this.messages.push(message)
}
this.messagesBuffer = []
// 等items高度变化
await this.$nextTick()
this.showNewMessages()
},
showNewMessages() {
let hasScrollBar = this.$refs.items.clientHeight > this.$refs.scroller.clientHeight
this.$refs.itemOffset.style.height = `${this.$refs.items.clientHeight}px`
if (!this.canScrollToBottomOrTimedOut() || !hasScrollBar) {
return
}
// 计算剩余像素
this.scrollPixelsRemaining += this.$refs.items.clientHeight - this.preinsertHeight
this.scrollToBottom()
// 计算是否平滑滚动、剩余时间
if (!this.lastSmoothChatMessageAddMs) {
this.lastSmoothChatMessageAddMs = performance.now()
}
let interval = performance.now() - this.lastSmoothChatMessageAddMs
this.chatRateMs = (0.9 * this.chatRateMs) + (0.1 * interval)
if (this.isSmoothed) {
if (this.chatRateMs < 400) {
this.isSmoothed = false
}
} else {
if (this.chatRateMs > 450) {
this.isSmoothed = true
}
}
this.scrollTimeRemainingMs += this.isSmoothed ? CHAT_SMOOTH_ANIMATION_TIME_MS : 0
if (!this.smoothScrollRafHandle) {
this.smoothScrollRafHandle = window.requestAnimationFrame(this.smoothScroll)
}
this.lastSmoothChatMessageAddMs = performance.now()
},
smoothScroll(time) {
if (!this.lastSmoothScrollUpdate) {
// 第一帧
this.lastSmoothScrollUpdate = time
this.smoothScrollRafHandle = window.requestAnimationFrame(this.smoothScroll)
return
}
let interval = time - this.lastSmoothScrollUpdate
if (
this.scrollPixelsRemaining <= 0 || this.scrollPixelsRemaining >= 400 // 已经滚动到底部或者离底部太远则结束
|| interval >= 1000 // 离上一帧时间太久,可能用户切换到其他网页
|| this.scrollTimeRemainingMs <= 0 // 时间已结束
) {
this.resetSmoothScroll()
return
}
let pixelsToScroll = interval / this.scrollTimeRemainingMs * this.scrollPixelsRemaining
this.scrollPixelsRemaining -= pixelsToScroll
if (this.scrollPixelsRemaining < 0) {
this.scrollPixelsRemaining = 0
}
this.scrollTimeRemainingMs -= interval
if (this.scrollTimeRemainingMs < 0) {
this.scrollTimeRemainingMs = 0
}
this.lastSmoothScrollUpdate = time
this.smoothScrollRafHandle = window.requestAnimationFrame(this.smoothScroll)
},
resetSmoothScroll() {
this.scrollTimeRemainingMs = this.scrollPixelsRemaining = 0
this.lastSmoothScrollUpdate = null
if (this.smoothScrollRafHandle) {
window.cancelAnimationFrame(this.smoothScrollRafHandle)
this.smoothScrollRafHandle = null
}
},
maybeResizeScrollContainer() {
this.$refs.itemOffset.style.height = `${this.$refs.items.clientHeight}px`
this.maybeScrollToBottom()
},
maybeScrollToBottom() {
if (this.canScrollToBottomOrTimedOut()) {
this.scrollToBottom()
}
},
scrollToBottom() {
this.$refs.scroller.scrollTop = Math.pow(2, 24)
this.atBottom = true
},
onScroll() {
this.refreshCantScrollStartTime()
let scroller = this.$refs.scroller
this.atBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < SCROLLED_TO_BOTTOM_EPSILON
this.flushMessagesBuffer()
},
canScrollToBottomOrTimedOut() {
if (this.canScrollToBottom) {
return true
}
// 防止在OBS中卡住超过一定时间也可以自动滚动
return new Date() - this.cantScrollStartTime >= 5 * 1000
},
refreshCantScrollStartTime() {
// 有鼠标事件时刷新,防止用户看弹幕时自动滚动
if (this.cantScrollStartTime) {
this.cantScrollStartTime = new Date()
}
}
}
}
</script>
<style src="@/assets/css/youtube/yt-html.css"></style>
<style src="@/assets/css/youtube/yt-live-chat-renderer.css"></style>
<style src="@/assets/css/youtube/yt-live-chat-item-list-renderer.css"></style>

View File

@@ -1,24 +0,0 @@
<template>
<div>
房间ID
<input type="number" min="1" v-model="roomId" />
<button @click="go">进入</button>
</div>
</template>
<script>
export default {
name: 'Home',
data() {
return {
roomId: window.localStorage.roomId || '1'
}
},
methods: {
go() {
window.localStorage.roomId = this.roomId
this.$router.push({name: 'room', params: {roomId: this.roomId}})
}
}
}
</script>

View File

@@ -1,20 +0,0 @@
<template>
<yt-live-chat-legacy-paid-message-renderer>
<div id="author-photo" :style="`background-image: url(${avatarUrl})`"></div>
<div id="content">
<div id="event-text">{{title}}</div>
<div id="detail-text">{{content}}</div>
</div>
</yt-live-chat-legacy-paid-message-renderer>
</template>
<script>
export default {
name: 'LegacyPaidMessage',
props: {
avatarUrl: String,
title: String,
content: String
}
}
</script>

View File

@@ -1,69 +0,0 @@
<template>
<yt-live-chat-paid-message-renderer>
<div id="header" :style="'background-color: ' + headerColor">
<div id="author-photo" :style="`background-image: url(${avatarUrl})`"></div>
<div id="header-content">
<div id="author-name">{{authorName}}</div>
<div id="purchase-amount">CN¥{{price}}</div>
</div>
</div>
<div id="content" :style="'background-color: ' + contentColor">
<div id="message" dir="auto">{{content}}</div>
</div>
</yt-live-chat-paid-message-renderer>
</template>
<script>
const LEVEL_TO_HEADER_COLOR = [
'rgba(21,101,192,1)', //$1蓝
'rgba(0,184,212,1)', // $2浅蓝
'rgba(0,191,165,1)', // $5绿
'rgba(255,179,0,1)', // $10黄
'rgba(230,81,0,1)', // $20橙
'rgba(194,24,91,1)', // $50品红
'rgba(208,0,0,1)' // $100红
]
const LEVEL_TO_CONTENT_COLOR = [
'rgba(30,136,229,1)', //$1蓝
'rgba(0,229,255,1)', // $2浅蓝
'rgba(29,233,182,1)', // $5绿
'rgba(255,202,40,1)', // $10黄
'rgba(245,124,0,1)', // $20橙
'rgba(233,30,99,1)', // $50品红
'rgba(230,33,23,1)' // $100红
]
export default {
name: 'PaidMessage',
props: {
price: Number, // 价格,人民币
avatarUrl: String,
authorName: String,
content: String
},
computed: {
level() {
if (this.price < 9.9) // 0~9.9,丢人
return 0
else if (this.price < 28) // 9.9~28B坷垃
return 1
else if (this.price < 52) // 28~52礼花
return 2
else if (this.price < 100) // 52~100疯狂打call
return 3
else if (this.price < 450) // 100~450节奏风暴、天空之翼
return 4
else if (this.price < 1245) // 450~1245摩天大楼
return 5
else // 1245小电视飞船
return 6
},
headerColor() {
return LEVEL_TO_HEADER_COLOR[this.level]
},
contentColor() {
return LEVEL_TO_CONTENT_COLOR[this.level]
}
}
}
</script>

View File

@@ -1,125 +0,0 @@
<template>
<yt-live-chat-renderer>
<!-- <yt-live-chat-ticker-renderer>
<yt-live-chat-ticker-paid-message-item-renderer style="background-color: rgba(0,184,212,1);">
<div id="content">
<span id="fake-avatar"></span>
<span>$5.00</span>
</div>
</yt-live-chat-ticker-paid-message-item-renderer>
<yt-live-chat-ticker-paid-message-item-renderer style="background-color: rgba(208,0,0,1);">
<div id="content">
<span id="fake-avatar"></span>
<span>$500.00</span>
</div>
</yt-live-chat-ticker-paid-message-item-renderer>
</yt-live-chat-ticker-renderer> -->
<yt-live-chat-item-list-renderer>
<template v-for="message in messages">
<text-message :key="message.id" v-if="message.type == 0"
:avatarUrl="message.avatarUrl" :time="message.time" :authorName="message.authorName"
:authorType="message.authorType" :content="message.content"
></text-message>
<legacy-paid-message :key="message.id" v-else-if="message.type == 1"
:avatarUrl="message.avatarUrl" :title="message.title" :content="message.content"
></legacy-paid-message>
<paid-message :key="message.id" v-else
:price="message.price" :avatarUrl="message.avatarUrl" :authorName="message.authorName"
:content="message.content"
></paid-message>
</template>
</yt-live-chat-item-list-renderer>
</yt-live-chat-renderer>
</template>
<script>
import TextMessage from './TextMessage.vue'
import LegacyPaidMessage from './LegacyPaidMessage.vue'
import PaidMessage from './PaidMessage.vue'
const COMMAND_JOIN_ROOM = 0
const COMMAND_ADD_TEXT = 1
const COMMAND_ADD_GIFT = 2
const COMMAND_ADD_VIP = 3
export default {
name: 'Room',
components: {
TextMessage,
LegacyPaidMessage,
PaidMessage
},
data() {
return {
websocket: null,
messages: [],
nextId: 0
}
},
created() {
this.websocket = new WebSocket(`ws://${window.location.host}/chat`)
// 测试用
// this.websocket = new WebSocket('ws://localhost/chat')
this.websocket.onopen = () => this.websocket.send(JSON.stringify({
cmd: COMMAND_JOIN_ROOM,
data: {
roomId: parseInt(this.$route.params.roomId)
}
}))
this.websocket.onmessage = (event) => {
let body = JSON.parse(event.data)
let message = null
let time, price
switch(body.cmd) {
case COMMAND_ADD_TEXT:
time = new Date(body.data.timestamp * 1000)
message = {
id: this.nextId++,
type: 0, // TextMessage
avatarUrl: body.data.avatarUrl,
time: `${time.getHours()}:${time.getMinutes()}`,
authorName: body.data.authorName,
authorType: body.data.authorType,
content: body.data.content
}
break
case COMMAND_ADD_GIFT:
price = body.data.totalCoin / 1000
if (price < 6.911) // 丢人
break
message = {
id: this.nextId++,
type: 2, // PaidMessage
price: price,
avatarUrl: body.data.avatarUrl,
authorName: body.data.authorName,
content: `Sent ${body.data.giftName}x${body.data.giftNum}`
}
break
case COMMAND_ADD_VIP:
message = {
id: this.nextId++,
type: 1, // LegacyPaidMessage
avatarUrl: body.data.avatarUrl,
title: 'NEW MEMBER!',
content: `Welcome ${body.data.authorName}`
}
break
}
if (message) {
this.messages.push(message)
if (this.messages.length > 50)
this.messages.shift()
}
}
},
beforeDestroy() {
this.websocket.close()
},
updated() {
window.scrollTo(0, document.body.scrollHeight)
}
}
</script>
<style src="../assets/room.css"></style>

View File

@@ -1,35 +0,0 @@
<template>
<yt-live-chat-text-message-renderer :author-type="authorTypeText">
<div id="author-photo" :style="`background-image: url(${avatarUrl})`"></div>
<div id="content">
<span id="timestamp">{{time}}</span>
<span id="author-name" :type="authorTypeText">{{authorName}}</span>
<span id="message">{{content}}</span>
</div>
</yt-live-chat-text-message-renderer>
</template>
<script>
const AUTHOR_TYPE_TO_TEXT = [
'',
'member', // 舰队
'moderator', // 房管
'owner' // 主播
]
export default {
name: 'TextMessage',
props: {
avatarUrl: String,
time: String,
authorName: String,
authorType: Number,
content: String
},
computed: {
authorTypeText() {
return AUTHOR_TYPE_TO_TEXT[this.authorType]
}
}
}
</script>

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

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

152
frontend/src/lang/en.js Normal file
View File

@@ -0,0 +1,152 @@
export default {
sidebar: {
home: 'Home',
stylegen: 'Style Generator',
help: 'Help',
projectAddress: 'Project Address',
giftRecordOfficial: 'Official Super Chat Record',
},
home: {
roomIdEmpty: "Room ID can't be empty",
roomIdInteger: 'Room ID must be positive integer',
authCodeEmpty: "Identity code can't be empty",
useAuthCodeWarning: 'Please prioritize the identity code',
general: 'General',
room: 'Room',
roomId: 'Room ID',
authCode: 'Identity code',
showDanmaku: 'Show messages',
showGift: 'Show Super Chats',
showGiftName: 'Show gift name',
mergeSimilarDanmaku: 'Merge similar messages',
mergeGift: 'Merge gifts',
minGiftPrice: 'Min price of Super Chats to show (CNY)',
maxNumber: 'Max number of messages',
block: 'Block',
giftDanmaku: 'Block system messages (gift effect)',
blockLevel: 'Block user level lower than',
informalUser: 'Block informal users',
unverifiedUser: 'Block unverified users',
blockKeywords: 'Block keywords',
onePerLine: 'One per line',
blockUsers: 'Block users',
blockMedalLevel: 'Block medal level lower than',
advanced: 'Advanced',
relayMessagesByServer: 'Relay messages by server',
autoTranslate: 'Auto translate messages to Japanese (requires relay messages by server)',
giftUsernamePronunciation: 'Pronunciation of gift username',
dontShow: 'None',
pinyin: 'Pinyin',
kana: 'Kana',
emoticon: 'Custom Emotes',
emoticonKeyword: 'Emote Code',
emoticonUrl: 'URL',
operation: 'Operation',
addEmoticon: 'Add emote',
emoticonFileTooLarge: 'File size is too large. Max size is 1MB',
roomUrl: 'Room URL',
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',
outlineColor: 'Outline color',
avatars: 'Avatars',
showAvatars: 'Show avatars',
avatarSize: 'Avatar size',
userNames: 'User Names',
showUserNames: 'Show user names',
font: 'Font',
fontSize: 'Font size',
lineHeight: 'Line height (0 for default)',
normalColor: 'Normal color',
ownerColor: 'Owner color',
moderatorColor: 'Moderator color',
memberColor: 'Member color',
showBadges: 'Show badges',
showColon: 'Show colon after name',
emoticonSize: 'Emoticon size',
messages: 'Messages',
color: 'Color',
onNewLine: 'On new line',
time: 'Timestamps',
showTime: 'Show timestamps',
backgrounds: 'Backgrounds',
bgColor: 'Background color',
useBarsInsteadOfBg: 'Use bars instead of backgrounds',
messageBgColor: 'Message background color',
ownerMessageBgColor: 'Owner background color',
moderatorMessageBgColor: 'Moderator background color',
memberMessageBgColor: 'Member background color',
scAndNewMember: 'Super Chat / New Member',
firstLineFont: 'First line font',
firstLineFontSize: 'First line font size',
firstLineLineHeight: 'First line line height (0 for default)',
firstLineColor: 'First line color',
secondLineFont: 'Second line font',
secondLineFontSize: 'Second line font size',
secondLineLineHeight: 'Second line line height (0 for default)',
secondLineColor: 'Second line color',
scContentLineFont: 'Super Chat content font',
scContentLineFontSize: 'Super Chat content font size',
scContentLineLineHeight: 'Super Chat content line height (0 for default)',
scContentLineColor: 'Super Chat content color',
showNewMemberBg: 'Show new member background',
showScTicker: 'Show Super Chat ticker',
showOtherThings: 'Show everything other than Super Chat ticker',
animation: 'Animation',
animateIn: 'Animate in',
fadeInTime: 'Fade in time (miliseconds)',
animateOut: 'Animate out (remove old messages)',
animateOutWaitTime: 'Wait time (seconds)',
fadeOutTime: 'Fade out time (miliseconds)',
slide: 'Slide',
reverseSlide: 'Reverse slide',
playAnimation: 'Play animation',
result: 'Result',
copy: 'Copy',
resetConfig: 'Reset config'
},
help: {
help: 'Help',
p1: '1. Copy the identity code (身份码) from the webpage where you start streaming',
p2: '2. Enter the identity code into the room configuration on the home page. Copy the room URL',
p3: '3. Generate styles with the style generator. Copy the CSS',
p4: '4. Add browser source in OBS',
p5: '5. Enter the previously copied room URL at URL, and enter the previously copied CSS at custom CSS'
},
chat: {
moderator: 'moderator',
guardLevel1: 'governor',
guardLevel2: 'admiral',
guardLevel3: 'captain',
sendGift: 'Sent {giftName}x{num}',
membershipTitle: 'New member',
tickerMembership: 'Member'
}
}

152
frontend/src/lang/ja.js Normal file
View File

@@ -0,0 +1,152 @@
export default {
sidebar: {
home: 'トップページ',
stylegen: 'スタイルジェネレータ',
help: 'ヘルプ',
projectAddress: 'プロジェクトアドレス',
giftRecordOfficial: '公式スーパーチャット記録',
},
home: {
roomIdEmpty: 'ルームのIDを空白にすることはできません',
roomIdInteger: 'ルームは正の整数でなければなりません',
authCodeEmpty: 'アイデンティティコードを空白にすることはできません',
useAuthCodeWarning: 'アイデンティティコードを優先的に使用してください',
general: '常規',
room: 'ルーム',
roomId: 'ルームID',
authCode: 'アイデンティティコード',
showDanmaku: 'コメントを表示する',
showGift: 'スーパーチャットと新メンバーを表示する',
showGiftName: 'ギフト名を表示する',
mergeSimilarDanmaku: '同じコメントを合併する',
mergeGift: 'ギフトを合併する',
minGiftPrice: '最低表示スーパーチャット価格CNY',
maxNumber: '最大コメント数',
block: 'ブロック',
giftDanmaku: 'ブロックシステムコメント(プレゼント効果)',
blockLevel: 'ブロックユーザーレベルがx未満',
informalUser: 'ブロック非公式ユーザー',
unverifiedUser: 'ブロック認証されていないユーザー',
blockKeywords: 'ブロックキーワード',
onePerLine: '1行に1つずつ',
blockUsers: 'ブロックユーザー',
blockMedalLevel: 'ブロック勲章等級がx未満',
advanced: 'アドバンスド',
relayMessagesByServer: 'サーバを介してメッセージを転送する',
autoTranslate: 'コメントを日本語に翻訳する(サーバを介してメッセージを転送する必要)',
giftUsernamePronunciation: 'スーパーチャットのユーザー名の発音',
dontShow: '非表示',
pinyin: 'ピンイン',
kana: '仮名',
emoticon: 'カスタムスタンプ',
emoticonKeyword: '置き換えるキーワード',
emoticonUrl: 'URL',
operation: '操作',
addEmoticon: 'スタンプを追加',
emoticonFileTooLarge: 'ファイルサイズが大きすぎます。最大サイズは1MBです',
roomUrl: 'ルームのURL',
enterRoom: 'ルームに入る',
enterTestRoom: 'テストルームに入る',
exportConfig: 'コンフィグの導出',
importConfig: 'コンフィグの導入',
failedToParseConfig: 'コンフィグ解析に失敗しました'
},
stylegen: {
legacy: '古典',
lineLike: 'Line風',
light: '明るい',
dark: '暗い',
outlines: 'アウトライン',
showOutlines: 'アウトラインを表示する',
outlineSize: 'アウトラインのサイズ',
outlineColor: 'アウトラインの色',
avatars: 'アイコン',
showAvatars: 'アイコンを表示する',
avatarSize: 'アイコンのサイズ',
userNames: 'ユーザー名',
showUserNames: 'ユーザー名を表示する',
font: 'フォント',
fontSize: 'フォントサイズ',
lineHeight: '行の高さ0はデフォルト',
normalColor: 'ノーマルの色',
ownerColor: 'オーナーの色',
moderatorColor: '管理者の色',
memberColor: 'メンバーの色',
showBadges: '勲章を見せる',
showColon: 'ユーザー名の後にコロンが表示されます',
emoticonSize: 'スタンプサイズ',
messages: 'コメント',
color: '色',
onNewLine: '新しい行に表示する',
time: '時間',
showTime: '時間を表示する',
backgrounds: '背景',
bgColor: '背景色',
useBarsInsteadOfBg: '背景に代わります',
messageBgColor: 'コメント背景色',
ownerMessageBgColor: 'オーナーコメント背景色',
moderatorMessageBgColor: '管理者コメント背景色',
memberMessageBgColor: 'メンバーコメント背景色',
scAndNewMember: 'スーパーチャット、新メンバー',
firstLineFont: '1行目のフォント',
firstLineFontSize: '1行目のフォントサイズ',
firstLineLineHeight: '1行目の高さ0はデフォルト',
firstLineColor: '1行目の色',
secondLineFont: '2行目のフォント',
secondLineFontSize: '2行目のフォントサイズ',
secondLineLineHeight: '2行目の高さ0はデフォルト',
secondLineColor: '2行目の色',
scContentLineFont: 'スーパーチャットのコンテンツフォント',
scContentLineFontSize: 'スーパーチャットコンテンツフォントサイズ',
scContentLineLineHeight: 'スーパーチャットコンテンツライン高さ0がデフォルト',
scContentLineColor: 'スーパーチャットコンテンツライン色',
showNewMemberBg: '新メンバーの背景を表示する',
showScTicker: 'スーパーチャットチカーの表示',
showOtherThings: 'スーパーチャットチカー以外のコンテンツを表示します',
animation: 'アニメーション',
animateIn: '入場アニメーション',
fadeInTime: 'フェードイン時間(ミリ秒)',
animateOut: '古いコメントを除去する',
animateOutWaitTime: '待ち時間(秒)',
fadeOutTime: 'フェードアウト時間(ミリ秒)',
slide: '滑る',
reverseSlide: '逆の滑る',
playAnimation: 'アニメーションを再生する',
result: '結果',
copy: 'コピー',
resetConfig: 'デフォルトに戻す'
},
help: {
help: 'ヘルプ',
p1: '1. ビリビリの生放送を始めるのウェブからアイデンティティコード(身份码)をこぴーする',
p2: '2. ホームページに先ほどコピーしたアイデンティティコードを入力して、ルームのURLをこぴーする',
p3: '3. スタイルジェネレータでお好みのコメント様子を選び、出力したCSSをコピーする',
p4: '4. OBSでブラウザを新規作成する',
p5: '5. プロパティでこぴーしたURLを入力し、カスタムCSSでスタイルジェネレータのCSSを入力する'
},
chat: {
moderator: 'モデレーター',
guardLevel1: '総督',
guardLevel2: '提督',
guardLevel3: '艦長',
sendGift: '{giftName}x{num} を贈りました',
membershipTitle: '新規メンバー',
tickerMembership: 'メンバー'
}
}

152
frontend/src/lang/zh.js Normal file
View File

@@ -0,0 +1,152 @@
export default {
sidebar: {
home: '首页',
stylegen: '样式生成器',
help: '帮助',
projectAddress: '项目地址',
giftRecordOfficial: '官方打赏记录',
},
home: {
roomIdEmpty: '房间ID不能为空',
roomIdInteger: '房间ID必须为正整数',
authCodeEmpty: '身份码不能为空',
useAuthCodeWarning: '请优先使用身份码',
general: '常规',
room: '房间',
roomId: '房间ID',
authCode: '身份码',
showDanmaku: '显示弹幕',
showGift: '显示打赏和新舰长',
showGiftName: '显示礼物名',
mergeSimilarDanmaku: '合并相似弹幕',
mergeGift: '合并礼物',
minGiftPrice: '最低显示打赏价格(元)',
maxNumber: '最大弹幕数',
block: '屏蔽',
giftDanmaku: '屏蔽礼物弹幕',
blockLevel: '屏蔽用户等级低于',
informalUser: '屏蔽非正式会员',
unverifiedUser: '屏蔽未绑定手机用户',
blockKeywords: '屏蔽关键词',
onePerLine: '一行一个',
blockUsers: '屏蔽用户',
blockMedalLevel: '屏蔽当前直播间勋章等级低于',
advanced: '高级',
relayMessagesByServer: '通过服务器转发消息',
autoTranslate: '自动翻译弹幕到日语(需要通过服务器转发消息)',
giftUsernamePronunciation: '标注打赏用户名读音',
dontShow: '不显示',
pinyin: '拼音',
kana: '日文假名',
emoticon: '自定义表情',
emoticonKeyword: '替换关键词',
emoticonUrl: 'URL',
operation: '操作',
addEmoticon: '添加表情',
emoticonFileTooLarge: '文件尺寸太大最大1MB',
roomUrl: '房间URL',
enterRoom: '进入房间',
enterTestRoom: '进入测试房间',
exportConfig: '导出配置',
importConfig: '导入配置',
failedToParseConfig: '配置解析失败:'
},
stylegen: {
legacy: '经典',
lineLike: '仿微信',
light: '明亮',
dark: '黑暗',
outlines: '描边',
showOutlines: '显示描边',
outlineSize: '描边尺寸',
outlineColor: '描边颜色',
avatars: '头像',
showAvatars: '显示头像',
avatarSize: '头像尺寸',
userNames: '用户名',
showUserNames: '显示用户名',
font: '字体',
fontSize: '字体尺寸',
lineHeight: '行高0为默认',
normalColor: '普通颜色',
ownerColor: '主播颜色',
moderatorColor: '房管颜色',
memberColor: '舰长颜色',
showBadges: '显示勋章',
showColon: '用户名后显示冒号',
emoticonSize: '表情大小',
messages: '消息',
color: '颜色',
onNewLine: '在新行显示',
time: '时间',
showTime: '显示时间',
backgrounds: '背景',
bgColor: '背景色',
useBarsInsteadOfBg: '用条代替消息背景',
messageBgColor: '消息背景色',
ownerMessageBgColor: '主播消息背景色',
moderatorMessageBgColor: '房管消息背景色',
memberMessageBgColor: '舰长消息背景色',
scAndNewMember: '打赏、舰长',
firstLineFont: '第一行字体',
firstLineFontSize: '第一行字体尺寸',
firstLineLineHeight: '第一行行高0为默认',
firstLineColor: '第一行颜色',
secondLineFont: '第二行字体',
secondLineFontSize: '第二行字体尺寸',
secondLineLineHeight: '第二行行高0为默认',
secondLineColor: '第二行颜色',
scContentLineFont: 'Super Chat内容字体',
scContentLineFontSize: 'Super Chat内容字体尺寸',
scContentLineLineHeight: 'Super Chat内容行高0为默认',
scContentLineColor: 'Super Chat内容颜色',
showNewMemberBg: '显示新舰长背景',
showScTicker: '显示Super Chat固定栏',
showOtherThings: '显示Super Chat固定栏之外的内容',
animation: '动画',
animateIn: '进入动画',
fadeInTime: '淡入时间(毫秒)',
animateOut: '移除旧消息',
animateOutWaitTime: '移除前等待时间(秒)',
fadeOutTime: '淡出时间(毫秒)',
slide: '滑动',
reverseSlide: '反向滑动',
playAnimation: '播放动画',
result: '结果',
copy: '复制',
resetConfig: '恢复默认设置'
},
help: {
help: '帮助',
p1: '1. 从开始直播的页面复制身份码',
p2: '2. 把身份码输入到首页的房间配置复制房间URL',
p3: '3. 使用样式生成器生成样式复制CSS',
p4: '4. 在OBS中添加浏览器源',
p5: '5. URL处输入之前复制的房间URL自定义CSS处输入之前复制的CSS'
},
chat: {
moderator: '管理员',
guardLevel1: '总督',
guardLevel2: '提督',
guardLevel3: '舰长',
sendGift: '赠送 {giftName}x{num}',
membershipTitle: '新会员',
tickerMembership: '会员'
}
}

View File

@@ -0,0 +1,75 @@
<template>
<el-scrollbar wrap-class="scrollbar-wrapper">
<el-menu
router
background-color="#304156"
text-color="#bfcbd9"
active-text-color="rgb(64, 158, 255)"
:default-active="$route.path"
>
<el-menu-item index="/">
<i class="el-icon-s-home"></i>{{ $t('sidebar.home') }}
</el-menu-item>
<el-menu-item :index="$router.resolve({ name: 'stylegen' }).href">
<i class="el-icon-brush"></i>{{ $t('sidebar.stylegen') }}
</el-menu-item>
<el-menu-item :index="$router.resolve({ name: 'help' }).href">
<i class="el-icon-question"></i>{{ $t('sidebar.help') }}
</el-menu-item>
<a href="https://github.com/xfgryujk/blivechat" target="_blank">
<el-menu-item>
<i class="el-icon-share"></i>{{ $t('sidebar.projectAddress') }}
</el-menu-item>
</a>
<a href="http://link.bilibili.com/ctool/vtuber" target="_blank">
<el-menu-item>
<i class="el-icon-link"></i>{{ $t('sidebar.giftRecordOfficial') }}
</el-menu-item>
</a>
<el-submenu index="null">
<template slot="title">
<i class="el-icon-chat-line-square"></i>Language
</template>
<el-menu-item v-for="locale in LOCALES" :key="locale.locale" @click="onSelectLanguage(locale.locale)">
<template>{{ locale.name }}</template>
</el-menu-item>
</el-submenu>
</el-menu>
</el-scrollbar>
</template>
<script>
import * as i18n from '@/i18n'
export default {
name: 'Sidebar',
data() {
return {
LOCALES: [
{ locale: 'zh', name: '中文' },
{ locale: 'ja', name: '日本語' },
{ locale: 'en', name: 'English' }
]
}
},
methods: {
onSelectLanguage(locale) {
i18n.setLocale(locale)
}
}
}
</script>
<style>
.el-scrollbar {
height: 100%;
}
.scrollbar-wrapper {
overflow-x: hidden !important;
}
.scrollbar-wrapper .el-menu {
border: none;
}
</style>

View File

@@ -0,0 +1,141 @@
<template>
<el-container class="app-wrapper" :class="{ mobile: isMobile }">
<div v-show="isMobile && !hideSidebar" class="drawer-bg" @click="hideSidebar = true"></div>
<el-aside width="230px" class="sidebar-container" :class="{ 'hide-sidebar': hideSidebar }">
<div class="logo-container">
<router-link :to="{ name: 'home' }">
<img src="@/assets/img/logo.png" class="sidebar-logo">
<h1 class="sidebar-title">blivechat</h1>
</router-link>
</div>
<div class="version">{{ APP_VERSION }}</div>
<sidebar></sidebar>
</el-aside>
<el-main>
<el-button v-show="isMobile" class="menu-button" icon="el-icon-s-unfold" @click="hideSidebar = false"></el-button>
<keep-alive>
<router-view></router-view>
</keep-alive>
</el-main>
</el-container>
</template>
<script>
import Sidebar from './Sidebar'
export default {
name: 'Layout',
components: {
Sidebar
},
data() {
return {
APP_VERSION: process.env.APP_VERSION,
isMobile: false,
hideSidebar: true
}
},
mounted() {
window.addEventListener('resize', this.onResize)
this.onResize()
},
beforeDestroy() {
window.removeEventListener('resize', this.onResize)
},
methods: {
onResize() {
this.isMobile = document.body.clientWidth <= 992
}
}
}
</script>
<style>
html {
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 {
height: 100%;
}
body {
margin: 0;
background-color: #f6f8fa;
}
a, a:focus, a:hover {
text-decoration: none;
}
.drawer-bg {
background: #000;
opacity: 0.3;
width: 100%;
top: 0;
height: 100%;
position: absolute;
z-index: 999;
}
.sidebar-container {
background-color: #304156;
overflow: hidden;
}
.app-wrapper.mobile .sidebar-container {
position: fixed;
top: 0;
left: 0;
transition-duration: 0.3s;
z-index: 1001;
}
.app-wrapper.mobile .sidebar-container.hide-sidebar {
pointer-events: none;
transition-duration: 0.3s;
transform: translate3d(-230px, 0, 0);
}
.sidebar-container .logo-container {
width: 100%;
height: 50px;
line-height: 50px;
background: #2b2f3a;
text-align: center;
}
.sidebar-container .logo-container .sidebar-logo {
width: 32px;
height: 32px;
vertical-align: middle;
margin-right: 12px;
}
.sidebar-container .logo-container .sidebar-title {
display: inline-block;
margin: 0;
color: #fff;
font-weight: 600;
line-height: 50px;
font-size: 14px;
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
vertical-align: middle;
}
.sidebar-container .version {
height: 30px;
background: #2b2f3a;
color: #aaa;
font-weight: 600;
line-height: 30px;
font-size: 14px;
vertical-align: middle;
text-align: center;
}
.sidebar-container .is-horizontal {
display: none;
}
</style>

View File

@@ -1,23 +1,106 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import {
Aside, Autocomplete, Badge, Button, ButtonGroup, Card, Col, ColorPicker, Container, Divider, Form, FormItem, Image,
Input, Main, Menu, MenuItem, Message, Option, OptionGroup, Radio, RadioGroup, Row, Select, Scrollbar,
Slider, Submenu, Switch, Table, TableColumn, TabPane, Tabs, Tooltip
} from 'element-ui'
import axios from 'axios'
import App from './App.vue'
import Home from './components/Home.vue'
import Room from './components/Room.vue'
import NotFound from './components/NotFound.vue'
import * as i18n from './i18n'
import App from './App'
import Layout from './layout'
import Home from './views/Home'
import StyleGenerator from './views/StyleGenerator'
import Help from './views/Help'
import Room from './views/Room'
import NotFound from './views/NotFound'
axios.defaults.timeout = 10 * 1000
Vue.use(VueRouter)
// 初始化element
Vue.use(Aside)
Vue.use(Autocomplete)
Vue.use(Badge)
Vue.use(Button)
Vue.use(ButtonGroup)
Vue.use(Card)
Vue.use(Col)
Vue.use(ColorPicker)
Vue.use(Container)
Vue.use(Divider)
Vue.use(Form)
Vue.use(FormItem)
Vue.use(Image)
Vue.use(Input)
Vue.use(Main)
Vue.use(Menu)
Vue.use(MenuItem)
Vue.use(Option)
Vue.use(OptionGroup)
Vue.use(Radio)
Vue.use(RadioGroup)
Vue.use(Row)
Vue.use(Select)
Vue.use(Scrollbar)
Vue.use(Slider)
Vue.use(Submenu)
Vue.use(Switch)
Vue.use(Table)
Vue.use(TableColumn)
Vue.use(TabPane)
Vue.use(Tabs)
Vue.use(Tooltip)
Vue.prototype.$message = Message
Vue.config.ignoredElements = [
/^yt-/
]
const router = new VueRouter({
mode: 'history',
routes: [
{path: '/', component: Home},
{path: '/room/:roomId', name: 'room', component: Room},
{path: '*', component: NotFound}
{
path: '/',
component: Layout,
children: [
{ path: '', name: 'home', component: Home },
{ path: 'stylegen', name: 'stylegen', component: StyleGenerator },
{ path: 'help', name: 'help', component: Help }
]
},
{
path: '/room/test',
name: 'test_room',
component: Room,
props: route => ({ strConfig: route.query })
},
{
path: '/room/:roomKeyValue',
name: 'room',
component: Room,
props(route) {
let roomKeyType = parseInt(route.query.roomKeyType) || 1
if (roomKeyType < 1 || roomKeyType > 2) {
roomKeyType = 1
}
let roomKeyValue = route.params.roomKeyValue
if (roomKeyType === 1) {
roomKeyValue = parseInt(roomKeyValue) || null
} else {
roomKeyValue = roomKeyValue || null
}
return { roomKeyType, roomKeyValue, strConfig: route.query }
}
},
{ path: '*', component: NotFound }
]
})
new Vue({
render: h => h(App),
router
router,
i18n: i18n.i18n
}).$mount('#app')

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
export const DICT_PINYIN = 'pinyin'
export const DICT_KANA = 'kana'
export class PronunciationConverter {
constructor() {
this.pronunciationMap = new Map()
}
async loadDict(dictName) {
let promise
switch (dictName) {
case DICT_PINYIN:
promise = import('./dictPinyin')
break
case DICT_KANA:
promise = import('./dictKana')
break
default:
return
}
let dictTxt = (await promise).default
let pronunciationMap = new Map()
for (let item of dictTxt.split('\n')) {
if (item.length === 0) {
continue
}
pronunciationMap.set(item.substring(0, 1), item.substring(1))
}
this.pronunciationMap = pronunciationMap
}
getPronunciation(text) {
let res = []
let lastHasPronunciation = null
for (let char of text) {
let pronunciation = this.pronunciationMap.get(char)
if (pronunciation === undefined) {
if (lastHasPronunciation !== null && lastHasPronunciation) {
res.push(' ')
}
lastHasPronunciation = false
res.push(char)
} else {
if (lastHasPronunciation !== null) {
res.push(' ')
}
lastHasPronunciation = true
res.push(pronunciation)
}
}
return res.join('')
}
}

View File

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

View File

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

357
frontend/src/views/Home.vue Normal file
View File

@@ -0,0 +1,357 @@
<template>
<div>
<p>
<el-form :model="form" ref="form" label-width="150px">
<el-tabs type="border-card">
<el-tab-pane :label="$t('home.general')">
<el-form-item :label="$t('home.room')" required :prop="form.roomKeyType === 1 ? 'roomId' : 'authCode'">
<template slot="label">{{ $t('home.room') }}
<router-link :to="{ name: 'help' }">
<i class="el-icon-question"></i>
</router-link>
</template>
<el-row>
<el-col :span="6">
<el-select v-model="form.roomKeyType" style="width: 100%">
<el-option :label="$t('home.authCode')" :value="2"></el-option>
<el-option :label="$t('home.roomId')" :value="1"></el-option>
</el-select>
</el-col>
<el-col :span="18">
<el-input v-if="form.roomKeyType === 1"
v-model.number="form.roomId" type="number" min="1" :rules="[
{required: true, message: $t('home.roomIdEmpty'), trigger: 'blur'},
{type: 'integer', min: 1, message: $t('home.roomIdInteger'), trigger: 'blur'}
]"
></el-input>
<el-input v-else
v-model.number="form.authCode" :rules="[
{required: true, message: $t('home.authCodeEmpty'), trigger: 'blur'}
]"
></el-input>
</el-col>
</el-row>
<el-row v-if="form.roomKeyType === 1" style="color: red">{{ $t('home.useAuthCodeWarning') }}</el-row>
</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-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-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-tab-pane :label="$t('home.emoticon')">
<el-table :data="form.emoticons">
<el-table-column prop="keyword" :label="$t('home.emoticonKeyword')" width="170">
<template slot-scope="scope">
<el-input v-model="scope.row.keyword"></el-input>
</template>
</el-table-column>
<el-table-column prop="url" :label="$t('home.emoticonUrl')">
<template slot-scope="scope">
<el-input v-model="scope.row.url"></el-input>
</template>
</el-table-column>
<el-table-column :label="$t('home.operation')" width="170">
<template slot-scope="scope">
<el-button-group>
<el-button type="primary" icon="el-icon-upload2" :disabled="!serverConfig.enableUploadFile"
@click="uploadEmoticon(scope.row)"
></el-button>
<el-button type="danger" icon="el-icon-minus" @click="delEmoticon(scope.$index)"></el-button>
</el-button-group>
</template>
</el-table-column>
</el-table>
<p>
<el-button type="primary" icon="el-icon-plus" @click="addEmoticon">{{$t('home.addEmoticon')}}</el-button>
</p>
</el-tab-pane>
</el-tabs>
</el-form>
</p>
<p>
<el-card>
<el-form :model="form" label-width="150px">
<el-form-item :label="$t('home.roomUrl')">
<el-input ref="roomUrlInput" readonly :value="obsRoomUrl" style="width: calc(100% - 8em); margin-right: 1em;"></el-input>
<el-button type="primary" icon="el-icon-copy-document" @click="copyUrl"></el-button>
</el-form-item>
<el-form-item>
<el-button type="primary" :disabled="!roomUrl" @click="enterRoom">{{$t('home.enterRoom')}}</el-button>
<el-button @click="enterTestRoom">{{$t('home.enterTestRoom')}}</el-button>
<el-button @click="exportConfig">{{$t('home.exportConfig')}}</el-button>
<el-button @click="importConfig">{{$t('home.importConfig')}}</el-button>
</el-form-item>
</el-form>
</el-card>
</p>
</div>
</template>
<script>
import _ from 'lodash'
import download from 'downloadjs'
import { mergeConfig } from '@/utils'
import * as mainApi from '@/api/main'
import * as chatConfig from '@/api/chatConfig'
export default {
name: 'Home',
data() {
return {
serverConfig: {
enableTranslate: true,
enableUploadFile: true,
loaderUrl: ''
},
form: {
...chatConfig.getLocalConfig(),
roomKeyType: parseInt(window.localStorage.roomKeyType || '2'),
roomId: parseInt(window.localStorage.roomId || '1'),
authCode: window.localStorage.authCode || '',
}
}
},
computed: {
roomKeyValue() {
if (this.form.roomKeyType === 1) {
return this.form.roomId
} else {
return this.form.authCode
}
},
roomUrl() {
return this.getRoomUrl(false)
},
obsRoomUrl() {
if (this.roomUrl === '') {
return ''
}
if (this.serverConfig.loaderUrl === '') {
return this.roomUrl
}
let url = new URL(this.serverConfig.loaderUrl)
url.searchParams.append('url', this.roomUrl)
return url.href
}
},
watch: {
roomUrl: _.debounce(function() {
window.localStorage.roomKeyType = this.form.roomKeyType
window.localStorage.roomId = this.form.roomId
window.localStorage.authCode = this.form.authCode
chatConfig.setLocalConfig(this.form)
}, 500)
},
mounted() {
this.updateServerConfig()
},
methods: {
async updateServerConfig() {
try {
this.serverConfig = (await mainApi.getServerInfo()).config
} catch (e) {
this.$message.error(`Failed to fetch server information: ${e}`)
throw e
}
},
addEmoticon() {
this.form.emoticons.push({
keyword: '[Kappa]',
url: ''
})
},
delEmoticon(index) {
this.form.emoticons.splice(index, 1)
},
uploadEmoticon(emoticon) {
let input = document.createElement('input')
input.type = 'file'
input.accept = 'image/png, image/jpeg, image/jpg, image/gif'
input.onchange = async() => {
let file = input.files[0]
if (file.size > 1024 * 1024) {
this.$message.error(this.$t('home.emoticonFileTooLarge'))
return
}
let res
try {
res = await mainApi.uploadEmoticon(file)
} catch (e) {
this.$message.error(`Failed to upload: ${e}`)
throw e
}
emoticon.url = res.url
}
input.click()
},
enterRoom() {
window.open(this.roomUrl, `room ${this.roomKeyValue}`, '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.roomKeyValue) {
return ''
}
let query = {
...this.form,
emoticons: JSON.stringify(this.form.emoticons),
lang: this.$i18n.locale
}
delete query.roomId
delete query.authCode
let resolved
if (isTestRoom) {
resolved = this.$router.resolve({ name: 'test_room', query })
} else {
resolved = this.$router.resolve({ name: 'room', params: { roomKeyValue: this.roomKeyValue }, query })
}
return `${window.location.protocol}//${window.location.host}${resolved.href}`
},
copyUrl() {
this.$refs.roomUrlInput.select()
document.execCommand('Copy')
},
exportConfig() {
let cfg = mergeConfig(this.form, chatConfig.DEFAULT_CONFIG)
download(JSON.stringify(cfg, null, 2), 'blivechat.json', 'application/json')
},
importConfig() {
let input = document.createElement('input')
input.type = 'file'
input.accept = 'application/json'
input.onchange = () => {
let reader = new window.FileReader()
reader.onload = () => {
let cfg
try {
cfg = JSON.parse(reader.result)
} catch (e) {
this.$message.error(this.$t('home.failedToParseConfig') + e)
return
}
this.importConfigFromObj(cfg)
}
reader.readAsText(input.files[0])
}
input.click()
},
importConfigFromObj(cfg) {
cfg = mergeConfig(cfg, chatConfig.deepCloneDefaultConfig())
chatConfig.sanitizeConfig(cfg)
this.form = {
...cfg,
roomKeyType: this.form.roomKeyType,
roomId: this.form.roomId,
authCode: this.form.authCode
}
}
}
}
</script>

383
frontend/src/views/Room.vue Normal file
View File

@@ -0,0 +1,383 @@
<template>
<chat-renderer ref="renderer" :maxNumber="config.maxNumber" :showGiftName="config.showGiftName"></chat-renderer>
</template>
<script>
import * as i18n from '@/i18n'
import { mergeConfig, toBool, toInt } from '@/utils'
import * as trie from '@/utils/trie'
import * as pronunciation from '@/utils/pronunciation'
import * as chatConfig from '@/api/chatConfig'
import * as chat from '@/api/chat'
import ChatClientTest from '@/api/chat/ChatClientTest'
import ChatClientDirectWeb from '@/api/chat/ChatClientDirectWeb'
import ChatClientDirectOpenLive from '@/api/chat/ChatClientDirectOpenLive'
import ChatClientRelay from '@/api/chat/ChatClientRelay'
import ChatRenderer from '@/components/ChatRenderer'
import * as constants from '@/components/ChatRenderer/constants'
export default {
name: 'Room',
components: {
ChatRenderer
},
props: {
roomKeyType: {
type: Number,
default: 1
},
roomKeyValue: {
type: [Number, String],
default: null
},
strConfig: {
type: Object,
default: () => ({})
}
},
data() {
return {
config: chatConfig.deepCloneDefaultConfig(),
chatClient: null,
pronunciationConverter: null,
textEmoticons: [], // 官方的文本表情
}
},
computed: {
blockKeywordsTrie() {
let blockKeywords = this.config.blockKeywords.split('\n')
let res = new trie.Trie()
for (let keyword of blockKeywords) {
if (keyword !== '') {
res.set(keyword, true)
}
}
return res
},
blockUsersTrie() {
let blockUsers = this.config.blockUsers.split('\n')
let res = new trie.Trie()
for (let user of blockUsers) {
if (user !== '') {
res.set(user, true)
}
}
return res
},
emoticonsTrie() {
let res = new trie.Trie()
for (let emoticons of [this.config.emoticons, this.textEmoticons]) {
for (let emoticon of emoticons) {
if (emoticon.keyword !== '' && emoticon.url !== '') {
res.set(emoticon.keyword, emoticon)
}
}
}
return res
}
},
mounted() {
this.initConfig()
this.initChatClient()
this.initTextEmoticons()
if (this.config.giftUsernamePronunciation !== '') {
this.pronunciationConverter = new pronunciation.PronunciationConverter()
this.pronunciationConverter.loadDict(this.config.giftUsernamePronunciation)
}
// 提示用户已加载
this.$message({
message: 'Loaded',
duration: '500'
})
},
beforeDestroy() {
if (this.chatClient) {
this.chatClient.stop()
}
},
methods: {
initConfig() {
let locale = this.strConfig.lang
if (locale) {
i18n.setLocale(locale)
}
let cfg = {}
// 留空的使用默认值
for (let i in this.strConfig) {
if (this.strConfig[i] !== '') {
cfg[i] = this.strConfig[i]
}
}
cfg = mergeConfig(cfg, chatConfig.deepCloneDefaultConfig())
cfg.minGiftPrice = toInt(cfg.minGiftPrice, chatConfig.DEFAULT_CONFIG.minGiftPrice)
cfg.showDanmaku = toBool(cfg.showDanmaku)
cfg.showGift = toBool(cfg.showGift)
cfg.showGiftName = toBool(cfg.showGiftName)
cfg.mergeSimilarDanmaku = toBool(cfg.mergeSimilarDanmaku)
cfg.mergeGift = toBool(cfg.mergeGift)
cfg.maxNumber = toInt(cfg.maxNumber, chatConfig.DEFAULT_CONFIG.maxNumber)
cfg.blockGiftDanmaku = toBool(cfg.blockGiftDanmaku)
cfg.blockLevel = toInt(cfg.blockLevel, chatConfig.DEFAULT_CONFIG.blockLevel)
cfg.blockNewbie = toBool(cfg.blockNewbie)
cfg.blockNotMobileVerified = toBool(cfg.blockNotMobileVerified)
cfg.blockMedalLevel = toInt(cfg.blockMedalLevel, chatConfig.DEFAULT_CONFIG.blockMedalLevel)
cfg.relayMessagesByServer = toBool(cfg.relayMessagesByServer)
cfg.autoTranslate = toBool(cfg.autoTranslate)
cfg.emoticons = this.toObjIfJson(cfg.emoticons)
chatConfig.sanitizeConfig(cfg)
this.config = cfg
},
toObjIfJson(str) {
if (typeof str !== 'string') {
return str
}
try {
return JSON.parse(str)
} catch {
return {}
}
},
initChatClient() {
if (this.roomKeyValue === null) {
this.chatClient = new ChatClientTest()
} else if (this.config.relayMessagesByServer) {
let roomKey = {
type: this.roomKeyType,
value: this.roomKeyValue
}
this.chatClient = new ChatClientRelay(roomKey, this.config.autoTranslate)
} else {
if (this.roomKeyType === 1) {
this.chatClient = new ChatClientDirectWeb(this.roomKeyValue)
} else {
this.chatClient = new ChatClientDirectOpenLive(this.roomKeyValue)
}
}
this.chatClient.onAddText = this.onAddText
this.chatClient.onAddGift = this.onAddGift
this.chatClient.onAddMember = this.onAddMember
this.chatClient.onAddSuperChat = this.onAddSuperChat
this.chatClient.onDelSuperChat = this.onDelSuperChat
this.chatClient.onUpdateTranslation = this.onUpdateTranslation
this.chatClient.start()
},
async initTextEmoticons() {
this.textEmoticons = await chat.getTextEmoticons()
},
start() {
this.chatClient.start()
},
stop() {
this.chatClient.stop()
},
onAddText(data) {
if (!this.config.showDanmaku || !this.filterTextMessage(data) || this.mergeSimilarText(data.content)) {
return
}
let message = {
id: data.id,
type: constants.MESSAGE_TYPE_TEXT,
avatarUrl: data.avatarUrl,
time: new Date(data.timestamp * 1000),
authorName: data.authorName,
authorType: data.authorType,
content: data.content,
richContent: this.getRichContent(data),
privilegeType: data.privilegeType,
repeated: 1,
translation: data.translation
}
this.$refs.renderer.addMessage(message)
},
onAddGift(data) {
if (!this.config.showGift) {
return
}
let price = data.totalCoin / 1000
if (this.mergeSimilarGift(data.authorName, price, data.giftName, data.num)) {
return
}
if (price < this.config.minGiftPrice) { // 丢人
return
}
let message = {
id: data.id,
type: constants.MESSAGE_TYPE_GIFT,
avatarUrl: data.avatarUrl,
time: new Date(data.timestamp * 1000),
authorName: data.authorName,
authorNamePronunciation: this.getPronunciation(data.authorName),
price: price,
giftName: data.giftName,
num: data.num
}
this.$refs.renderer.addMessage(message)
},
onAddMember(data) {
if (!this.config.showGift || !this.filterNewMemberMessage(data)) {
return
}
let message = {
id: data.id,
type: constants.MESSAGE_TYPE_MEMBER,
avatarUrl: data.avatarUrl,
time: new Date(data.timestamp * 1000),
authorName: data.authorName,
authorNamePronunciation: this.getPronunciation(data.authorName),
privilegeType: data.privilegeType,
title: this.$t('chat.membershipTitle')
}
this.$refs.renderer.addMessage(message)
},
onAddSuperChat(data) {
if (!this.config.showGift || !this.filterSuperChatMessage(data)) {
return
}
if (data.price < this.config.minGiftPrice) { // 丢人
return
}
let message = {
id: data.id,
type: constants.MESSAGE_TYPE_SUPER_CHAT,
avatarUrl: data.avatarUrl,
authorName: data.authorName,
authorNamePronunciation: this.getPronunciation(data.authorName),
price: data.price,
time: new Date(data.timestamp * 1000),
content: data.content.trim(),
translation: data.translation
}
this.$refs.renderer.addMessage(message)
},
onDelSuperChat(data) {
this.$refs.renderer.delMessages(data.ids)
},
onUpdateTranslation(data) {
if (!this.config.autoTranslate) {
return
}
this.$refs.renderer.updateMessage(data.id, { translation: data.translation })
},
filterTextMessage(data) {
if (this.config.blockGiftDanmaku && data.isGiftDanmaku) {
return false
} else if (this.config.blockLevel > 0 && data.authorLevel < this.config.blockLevel) {
return false
} else if (this.config.blockNewbie && data.isNewbie) {
return false
} else if (this.config.blockNotMobileVerified && !data.isMobileVerified) {
return false
} else if (this.config.blockMedalLevel > 0 && data.medalLevel < this.config.blockMedalLevel) {
return false
}
return this.filterByContent(data.content) && this.filterByAuthorName(data.authorName)
},
filterSuperChatMessage(data) {
return this.filterByContent(data.content) && this.filterByAuthorName(data.authorName)
},
filterNewMemberMessage(data) {
return this.filterByAuthorName(data.authorName)
},
filterByContent(content) {
let blockKeywordsTrie = this.blockKeywordsTrie
for (let i = 0; i < content.length; i++) {
let remainContent = content.substring(i)
if (blockKeywordsTrie.lazyMatch(remainContent) !== null) {
return false
}
}
return true
},
filterByAuthorName(authorName) {
return !this.blockUsersTrie.has(authorName)
},
mergeSimilarText(content) {
if (!this.config.mergeSimilarDanmaku) {
return false
}
return this.$refs.renderer.mergeSimilarText(content)
},
mergeSimilarGift(authorName, price, giftName, num) {
if (!this.config.mergeGift) {
return false
}
return this.$refs.renderer.mergeSimilarGift(authorName, price, giftName, num)
},
getPronunciation(text) {
if (this.pronunciationConverter === null) {
return ''
}
return this.pronunciationConverter.getPronunciation(text)
},
getRichContent(data) {
let richContent = []
// B站官方表情
if (data.emoticon !== null) {
richContent.push({
type: constants.CONTENT_TYPE_IMAGE,
text: data.content,
url: data.emoticon
})
return richContent
}
// 没有文本表情,只能是纯文本
if (this.config.emoticons.length === 0 && this.textEmoticons.length === 0) {
richContent.push({
type: constants.CONTENT_TYPE_TEXT,
text: data.content
})
return richContent
}
// 可能含有文本表情,需要解析
let emoticonsTrie = this.emoticonsTrie
let startPos = 0
let pos = 0
while (pos < data.content.length) {
let remainContent = data.content.substring(pos)
let matchEmoticon = emoticonsTrie.lazyMatch(remainContent)
if (matchEmoticon === null) {
pos++
continue
}
// 加入之前的文本
if (pos !== startPos) {
richContent.push({
type: constants.CONTENT_TYPE_TEXT,
text: data.content.slice(startPos, pos)
})
}
// 加入表情
richContent.push({
type: constants.CONTENT_TYPE_IMAGE,
text: matchEmoticon.keyword,
url: matchEmoticon.url
})
pos += matchEmoticon.keyword.length
startPos = pos
}
// 加入尾部的文本
if (pos !== startPos) {
richContent.push({
type: constants.CONTENT_TYPE_TEXT,
text: data.content.slice(startPos, pos)
})
}
return richContent
}
}
}
</script>

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,187 @@
<template>
<el-row :gutter="20">
<el-col :sm="24" :md="16">
<el-tabs v-model="activeTab">
<el-tab-pane :label="$t('stylegen.legacy')" name="legacy">
<legacy ref="legacy" v-model="subComponentResults.legacy"></legacy>
</el-tab-pane>
<el-tab-pane :label="$t('stylegen.lineLike')" name="lineLike">
<line-like ref="lineLike" v-model="subComponentResults.lineLike"></line-like>
</el-tab-pane>
</el-tabs>
<el-form label-width="150px" size="mini">
<h3>{{ $t('stylegen.result') }}</h3>
<el-card shadow="never">
<el-form-item label="CSS">
<el-input v-model="inputResult" ref="result" type="textarea" :rows="20"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="copyResult">{{ $t('stylegen.copy') }}</el-button>
<el-button @click="resetConfig">{{ $t('stylegen.resetConfig') }}</el-button>
</el-form-item>
</el-card>
</el-form>
</el-col>
<el-col :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>
</el-row>
</template>
<script>
import _ from 'lodash'
import Legacy from './Legacy'
import LineLike from './LineLike'
import Room from '@/views/Room'
export default {
name: 'StyleGenerator',
components: {
Legacy, LineLike, Room
},
data() {
let styleElement = document.createElement('style')
document.head.appendChild(styleElement)
// 数据流:
// 输入框 --\
// 子组件 -> subComponentResults -> subComponentResult -> inputResult -> 防抖延迟0.5s后 -> debounceResult -> exampleCss
return {
// 子组件的结果
subComponentResults: {
legacy: '',
lineLike: ''
},
activeTab: 'legacy',
// 输入框的结果
inputResult: '',
// 防抖后延迟变化的结果
debounceResult: '',
styleElement,
exampleTop: 0,
playAnimation: true,
exampleBgLight: false
}
},
computed: {
// 子组件的结果
subComponentResult() {
return this.subComponentResults[this.activeTab]
},
// 应用到预览上的CSS
exampleCss() {
return this.debounceResult.replace(/^body\b/gm, '#fakebody')
}
},
watch: {
subComponentResult(val) {
this.inputResult = val
},
inputResult: _.debounce(function(val) {
this.debounceResult = val
}, 500),
exampleCss(val) {
this.styleElement.innerText = val
}
},
mounted() {
this.debounceResult = this.inputResult = this.subComponentResult
this.$parent.$el.addEventListener('scroll', this.onParentScroll)
},
beforeDestroy() {
this.$parent.$el.removeEventListener('scroll', this.onParentScroll)
document.head.removeChild(this.styleElement)
},
methods: {
onParentScroll(event) {
if (document.body.clientWidth <= 992) {
this.exampleTop = 0
} else {
this.exampleTop = event.target.scrollTop
}
},
onPlayAnimationChange(value) {
if (value) {
this.$refs.room.start()
} else {
this.$refs.room.stop()
}
},
copyResult() {
this.$refs.result.select()
document.execCommand('Copy')
},
resetConfig() {
this.$refs[this.activeTab].resetConfig()
this.inputResult = this.subComponentResult
}
}
}
</script>
<style scoped>
#example-container {
height: calc(100vh - 150px);
background-color: #444;
background-image:
-moz-linear-gradient(45deg, #333 25%, transparent 25%),
-moz-linear-gradient(-45deg, #333 25%, transparent 25%),
-moz-linear-gradient(45deg, transparent 75%, #333 75%),
-moz-linear-gradient(-45deg, transparent 75%, #333 75%);
background-image:
-webkit-gradient(linear, 0 100%, 100% 0, color-stop(.25, #333), color-stop(.25, transparent)),
-webkit-gradient(linear, 0 0, 100% 100%, color-stop(.25, #333), color-stop(.25, transparent)),
-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;
background-position: 0 0, 16px 0, 16px -16px, 0px 16px;
padding: 25px;
resize: both;
overflow: hidden;
}
#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 {
outline: 1px #999 dashed;
height: 100%;
}
</style>

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

@@ -0,0 +1,27 @@
// 不能用localhosthttps://forum.dfinity.org/t/development-workflow-quickly-test-code-modifications/1793/21
const API_BASE_URL = 'http://127.0.0.1:12450'
module.exports = {
devServer: {
proxy: {
'/api': {
target: API_BASE_URL,
ws: true
},
'/emoticons': {
target: API_BASE_URL
}
}
},
chainWebpack: config => {
const APP_VERSION = `v${process.env.npm_package_version}`
config.plugin('define')
.tap(args => {
let defineMap = args[0]
let env = defineMap['process.env']
env['APP_VERSION'] = JSON.stringify(APP_VERSION)
return args
})
}
}

0
log/.gitkeep Normal file
View File

167
main.py
View File

@@ -1,55 +1,168 @@
# -*- coding: utf-8 -*-
import argparse
import asyncio
import logging
import logging.handlers
import os
import signal
import sys
import webbrowser
from typing import *
import tornado.ioloop
import tornado.web
import chat
import api.chat
import api.main
import api.open_live
import config
import models.database
import services.avatar
import services.chat
import services.translate
import update
import utils.request
logger = logging.getLogger(__name__)
WEB_ROOT = os.path.join(os.path.dirname(__file__), 'frontend', 'dist')
ROUTES = [
*api.main.ROUTES,
*api.chat.ROUTES,
*api.open_live.ROUTES,
*api.main.LAST_ROUTES,
]
server: Optional[tornado.httpserver.HTTPServer] = None
shut_down_event: Optional[asyncio.Event] = None
# noinspection PyAbstractClass
class MainHandler(tornado.web.StaticFileHandler):
"""为了使用Vue Router的history模式把所有请求转发到index.html"""
async def get(self, *args, **kwargs):
await super().get('index.html', *args, **kwargs)
async def main():
if not init():
return 1
try:
await run()
finally:
await shut_down()
return 0
def main():
parser = argparse.ArgumentParser(description='用于OBS的仿YouTube风格的bilibili直播聊天层')
parser.add_argument('--host', help='服务器host默认为127.0.0.1', default='127.0.0.1')
parser.add_argument('--port', help='服务器端口默认为80', type=int, default=80)
def init():
init_signal_handlers()
args = parse_args()
init_logging(args.debug)
logger.info('App started, initializing')
config.init()
utils.request.init()
models.database.init(args.debug)
services.avatar.init()
services.translate.init()
services.chat.init()
update.check_update()
init_server(args.host, args.port, args.debug)
return server is not None
def init_signal_handlers():
global shut_down_event
shut_down_event = asyncio.Event()
signums = (signal.SIGINT, signal.SIGTERM)
try:
loop = asyncio.get_running_loop()
for signum in signums:
loop.add_signal_handler(signum, on_shut_down_signal)
except NotImplementedError:
# 不太安全但Windows只能用这个
for signum in signums:
signal.signal(signum, on_shut_down_signal)
def on_shut_down_signal(*_args):
shut_down_event.set()
def parse_args():
parser = argparse.ArgumentParser(description='用于OBS的仿YouTube风格的bilibili直播评论栏')
parser.add_argument('--host', help='服务器host默认和配置中的一样', default=None)
parser.add_argument('--port', help='服务器端口,默认和配置中的一样', type=int, default=None)
parser.add_argument('--debug', help='调试模式', action='store_true')
args = parser.parse_args()
return parser.parse_args()
def init_logging(debug):
filename = os.path.join(config.BASE_PATH, 'log', 'blivechat.log')
stream_handler = logging.StreamHandler()
file_handler = logging.handlers.TimedRotatingFileHandler(
filename, encoding='utf-8', when='midnight', backupCount=7, delay=True
)
logging.basicConfig(
format='{asctime} {levelname} [{name}]: {message}',
datefmt='%Y-%m-%d %H:%M:%S',
style='{',
level=logging.INFO if not args.debug else logging.DEBUG
level=logging.INFO if not debug else logging.DEBUG,
handlers=[stream_handler, file_handler]
)
# 屏蔽访问日志
logging.getLogger('tornado.access').setLevel(logging.WARNING)
def init_server(host, port, debug):
cfg = config.get_config()
if host is None:
host = cfg.host
if port is None:
port = cfg.port
app = tornado.web.Application(
[
(r'/chat', chat.ChatHandler),
(r'/((css|img|js)/.*)', tornado.web.StaticFileHandler, {'path': WEB_ROOT}),
(r'/(favicon\.ico)', tornado.web.StaticFileHandler, {'path': WEB_ROOT}),
(r'/.*', MainHandler, {'path': WEB_ROOT})
],
websocket_ping_interval=30,
debug=args.debug,
ROUTES,
websocket_ping_interval=10,
debug=debug,
autoreload=False
)
app.listen(args.port, args.host)
logger.info('服务器启动:%s:%d', args.host, args.port)
tornado.ioloop.IOLoop.current().start()
try:
global server
server = app.listen(
port,
host,
xheaders=cfg.tornado_xheaders,
max_body_size=1024 * 1024,
max_buffer_size=1024 * 1024
)
except OSError:
logger.warning('Address is used %s:%d', host, port)
return
finally:
if cfg.open_browser_at_startup:
url = 'http://localhost/' if port == 80 else f'http://localhost:{port}/'
webbrowser.open(url)
logger.info('Server started: %s:%d', host, port)
async def run():
logger.info('Running event loop')
await shut_down_event.wait()
logger.info('Received shutdown signal')
async def shut_down():
logger.info('Closing server')
server.stop()
await server.close_all_connections()
logger.info('Closing websocket connections')
await services.chat.shut_down()
await utils.request.shut_down()
logger.info('App shut down')
if __name__ == '__main__':
main()
sys.exit(asyncio.run(main()))

14
models/bilibili.py Normal file
View File

@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
import datetime
import sqlalchemy
from sqlalchemy.orm import Mapped, mapped_column
import models.database
class BilibiliUser(models.database.OrmBase):
__tablename__ = 'bilibili_users'
uid: Mapped[int] = mapped_column(sqlalchemy.BigInteger, primary_key=True) # 创建表后最好手动改成unsigned
avatar_url: Mapped[str] = mapped_column(sqlalchemy.String(100))
update_time: Mapped[datetime.datetime]

32
models/database.py Normal file
View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
from typing import *
import sqlalchemy.orm
import config
_engine: Optional[sqlalchemy.Engine] = None
class OrmBase(sqlalchemy.orm.DeclarativeBase):
pass
def init(_debug):
cfg = config.get_config()
global _engine
_engine = sqlalchemy.create_engine(
cfg.database_url,
pool_size=5, # 保持的连接数
max_overflow=5, # 临时的额外连接数
pool_timeout=3, # 连接数达到最大时获取新连接的超时时间
# pool_pre_ping=True, # 获取连接时先检测是否可用
pool_recycle=60 * 60, # 回收超过1小时的连接防止数据库服务器主动断开不活跃的连接
# echo=debug, # 输出SQL语句
)
OrmBase.metadata.create_all(_engine)
def get_session() -> sqlalchemy.orm.Session:
return sqlalchemy.orm.Session(_engine)

View File

@@ -1,2 +1,5 @@
aiohttp==3.5.4
tornado==6.0.2
-r blivedm/requirements.txt
cachetools==5.3.1
pycryptodome==3.10.1
sqlalchemy==2.0.19
tornado==6.3.2

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

BIN
screenshots/chrome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
screenshots/obs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Some files were not shown because too many files have changed in this diff Show More