tweaked v1

This commit is contained in:
2026-07-15 22:14:58 -07:00
parent c9d497153f
commit b8850c87a4
23 changed files with 4567 additions and 119 deletions
+10
View File
@@ -0,0 +1,10 @@
config.toml
.env
.env.*
.git
.gitignore
**/node_modules
**/dist
**/target
coverage
*.log
+8
View File
@@ -1,3 +1,10 @@
FROM node:22-bookworm-slim AS web-build
WORKDIR /app/apps/overlay
COPY apps/overlay/package.json apps/overlay/package-lock.json ./
RUN npm ci
COPY apps/overlay ./
RUN npm run build
FROM rust:1.97-bookworm AS rust-build
RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev libasound2-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -17,5 +24,6 @@ WORKDIR /app
# account metadata and short-lived connection tokens. Keep application lifecycle
# logs while suppressing dependency INFO output by default.
COPY --from=rust-build /app/lxc-stream-server /app/lxc-stream-server
COPY --from=web-build /app/apps/overlay/dist /app/web
EXPOSE 9719
CMD ["/app/lxc-stream-server", "--config", "/app/config.toml"]
+19 -6
View File
@@ -1,6 +1,6 @@
# 洛星瓷直播转盘
# 洛星瓷直播弹幕姬
运行前复制带注释的 `config.toml.example` 为 `config.toml`,填入 CookieCloud、业务数据库、歌单只读数据库和访问密钥,然后执行:
运行前复制带注释的 `config.toml.example` 为 `config.toml`,填入 CookieCloud、设置数据库和访问密钥,然后执行:
```sh
cp config.toml.example config.toml
@@ -9,10 +9,23 @@ docker compose up --build -d
应用配置遵循 `blivedm_rs` 的 TOML/`--config` 形式;Compose 会以只读方式将 `config.toml` 挂载到容器并传入 `--config /app/config.toml`。配置段及字段说明直接写在 [config.toml.example](config.toml.example) 的注释中。
应用容器使用 host 网络并监听 `9719`。它通过宿主机 `127.0.0.1:5432` 直连 PostgreSQL:转盘业务使用独立的 `wheel` 数据库,歌单读取使用现有 `lxc_songlist` 数据库。应用会自动创建转盘业务表;`[connection].room_id`、CookieCloud Key/UUID 与密码均为必填配置。
将 `[reply].enabled` 设为 `true` 可启用聊天回复;发送前会从 CookieCloud 读取最新的 Bilibili Cookie,并从其中取得 `bili_jct` CSRF 值。若 Cookie 未同步 `bili_jct`,转盘逻辑仍会正常运行,只会停用聊天回复。
应用容器使用 host 网络并监听 `9719`。它通过 `[database].url` 连接 PostgreSQL,仅用于持久化 OBS 弹幕姬设置。应用会自动创建所需表;`[connection].room_id`、CookieCloud Key/UUID 与密码均为必填配置。
服务端已迁移为 Rust/Axum,并使用 [`blivedm_rs`](https://github.com/isomoes/blivedm_rs) 发布的 `blivedm` crate 建立 Bilibili 认证弹幕连接;不再使用 Node 服务端或 `@laplace.live/ws`。为确保 UID、礼物价格和上游事件 ID 不会被库的简化消息结构丢弃,项目在 `vendor/blivedm` 固定了一个仅保留原始 JSON 的小补丁。应用从 CookieCloud 获取 Bilibili Cookie(必须包含 `SESSDATA`),用于获取认证 UID、直播间网关与 WebSocket 鉴权包。
当前版本只提供 Rust 后端 API 与 WebSocket,不再托管任何前端静态文件或 SPA 路由。未匹配的 HTTP 路径将返回 `404`;新的前端可独立部署并连接 `/ws` 及 `/api/*`。
## OBS 弹幕姬
镜像会在构建阶段编译 `apps/overlay`,最终由 Rust/Axum **同域托管**:
- `/obs?token=你的_OBS_访问令牌`:透明背景的 OBS 浏览器源。它没有固定画布,按浏览器源的实际宽高自动在窄侧栏、常规卡片和低高度模式之间切换。
- `/control`:管理员控制台。使用 `admin.password` 登录后可改标题、字号、事件类别、最大条数、自动收缩、卷轴展开时长、动效、每卡粒子数量与速度、低性能模式和礼物高亮阈值;保存会实时广播给已打开的 OBS 源。
控制台的“复制 OBS 地址”会生成含只读令牌的完整地址。该令牌仅能订阅 `/ws`,不能访问管理或写入接口。常用 OBS 浏览器源尺寸可从 `360×600`、`440×760` 或 `600×1080` 开始;可按实际版面自由拖拽缩放,不会出现 1920×1080 的固定画布留白。
每张消息卡片都会从六套花纹组合中稳定选取一套,轮换使用对称花枝、横向自然藤纹和雏菊花簇,并改变上下、左右、镜像、配色与局部背景;连续的新卡会主动避开相同款式,礼物连击更新则保持原样式不跳动。透明消息墙本身不铺设全局装饰背景。粒子数量与速度可在控制台调整;窄尺寸会自动减少粒子,低性能模式会关闭动态粒子。花边 SVG 已本地打包,来源和公版/CC0 许可记录在 `apps/overlay/public/assets/NOTICE.md`,OBS 运行时不会访问素材站点。
后端会在启动时、之后每十分钟调用 Bilibili 的无需 Cookie 礼物面板接口缓存礼物图片、GIF、币种和价格。目录刷新失败时保留上一次成功缓存;缺失目录条目会安全降级为直播事件自带的名称与价格。
直播弹幕中的普通混排表情和整条大表情会作为安全的文字/图片分段通过 WebSocket 推送。消息自带的图片地址优先;服务还会使用 CookieCloud 的 `SESSDATA` 刷新直播间表情目录,在消息只提供表情唯一标识时补齐图片。图片加载失败时前端会退回原始表情文字。
刷新间隔和请求超时可通过 `[gifts]` 调整。`[overlay]` 与 `[overlay.events]` 是某个直播间第一次运行时的展示默认值;在 `/control` 保存后,数据库中的设置优先,因此更新 TOML 不会覆盖管理员已经调好的 OBS 样式。若希望重新采用 TOML 默认值,可删除该房间在 `overlay_settings` 表中的记录后重启。
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>洛星瓷弹幕猪</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
+1823
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
{
"name": "lxc-stream-overlay",
"private": true,
"version": "0.1.0",
"scripts": { "build": "tsc -b && vite build", "dev": "vite" },
"dependencies": { "react": "19.1.1", "react-dom": "19.1.1" },
"devDependencies": { "@types/react": "19.1.10", "@types/react-dom": "19.1.7", "@vitejs/plugin-react": "5.0.2", "typescript": "5.9.2", "vite": "7.3.6" }
}
+29
View File
@@ -0,0 +1,29 @@
# Decorative SVG assets
`floral-divider.svg` is a locally bundled copy of “Floral Divider”.
- Source: https://openclipart.org/detail/311692/floral-divider
- Author: GDJ
- License: CC0 1.0 / public domain
- Retrieved: 2026-07-15
`floral-vine.svg` is a metadata-stripped copy of “Floral border 04 element by
Paul Bürck”.
- Source: https://commons.wikimedia.org/wiki/File:Floral_border_04_element_by_Paul_B%C3%BCrck.svg
- Original artist: Paul Bürck, 1899
- SVG adaptation: Cbaile19
- License: original public domain; SVG adaptation CC0 1.0
- Retrieved: 2026-07-15
`floral-cluster.svg` is a metadata-stripped copy of “Floral border 01 corner by
Paul Bürck”.
- Source: https://commons.wikimedia.org/wiki/File:Floral_border_01_corner_by_Paul_B%C3%BCrck.svg
- Original artist: Paul Bürck, 1899
- SVG adaptation: Cbaile19
- License: original public domain; SVG adaptation CC0 1.0
- Retrieved: 2026-07-15
The overlay uses these SVGs as recolorable CSS masks inside message cards and
bundles them locally, so OBS never needs to load the source websites.
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,68 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2623.634 1629.216">
<g>
<g>
<path d="M2591.817,854.099c-106.299,41.083,34.166-19.38-216.053,131.02c-152.844,85.717-324.936,132.328-498.938,118.815
c27.783-7.013,55.606-13.86,83.442-20.66c168.031-41.049,188.4-174.552,204.687-222.33c20.661-42.14,26.64-36.897,47.929-54.234
c-24.096,3.278-49.587-7.216-156.361,57.313c-56.639,34.23-113.35,83.281-138.14,145.065c-4.823,12.022-0.061,26.477-6.377,38.009
c-10.223,18.664-38.738,34.08-59.648,35.21c-53.282,2.879-175.06-11.491-177.556-11.563
c117.522-86.815,227.898-244.511,273.451-303.123c14.369-11.317,54.143-10.623,72.147-76.833
c0.388-1.124,15.041,2.31,16.484,2.947c10.632,4.693,28.343,33.203,32.912,43.806c5.294,12.288,5.523,25.332,8.348,38.224
c5.049-5.299,11.196-10.313,13.222-17.719c6.814-24.834-2.916-49.532-7.943-73.688c4.451-3.16,35.407-20.989,34.951-44.151
c2.648-55.85-1.972-98.013,34.286-179.255c39.354-65.051,54.666-64.396,86.102-67.19c-0.825,0.073-12.155-30.415-13.682-33.627
c-31.583-66.457-64.487-59.855-87.924-65.035c-7.753-11.792-17.318-22.824-30.536-28.46
c32.044-98.235,17.692-52.407,36.405-93.111c59.038-125.495,54.118-88.921,69.966-107.637c8.051-9.508-2.802-31.613-16.34-29.303
c-36.767,6.272-109.202,179.776-116.536,224.201c-15.062,1.501-30.665,1.938-44.263,9.334
c28.823-107.204,28.6-157.689,20.143-227.941c-4.189-26.562-26.045-13.167-29.494-3.934c-2.539,4.97-0.454,10.544,0.409,15.67
c21.517,91.146-16.288,211.701-18.613,231.479c-41.25-29.052-42.552-20.37-67.128-12.719
c-8.285-40.622-69.328-107.684-70.791-109.522c-23.351-32.118-80.917-31.048-73.122-2.474
c5.546,20.292,24.404,9.795,38.337,14.478c14.069,4.729,63.347,72.367,81.282,118.21c-14.037,17.529-27.055,36.35-34.1,57.897
c-54.997-1.462-62.038,18.349-97.97,33.666c10.608,2.878,23.039,4.565,33.027,9.397
c90.813,43.934,108.404,212.067,108.404,212.067c-43.937-4.864-78.797-11.227-97.44,9.708
c-28.734,26.698-13.854,74.272,6.17,91.955c-8.831-14.222,2.06-30.634,14.068-38.508c14.874-6.323,29.322-14.574,45.516-17.026
c-10.405,33.703-12.082,23.19,20.101,101.615c-53.667,117.73-102.099,189.386-275.328,307.047
c-7.802,6.965-10.035-0.327-94.977-8.587c-81.808-5.848-4.521-11.631-225.968-19.776
c148.993-64.111,178.088-66.734,243.118-239.078c-27.949,3.905-55.873,7.987-83.804,11.803
c-19.791,2.568-93.853,36.098-107.698,65.802c-5.778,11.572-13.521,22.235-17.823,34.524
c-19.564,66.842-15.454,68.6-38.393,95.301c-23.752-25.981-19.698-27.865-41.314-94.072c-4.678-12.15-12.747-22.57-18.879-33.958
c-14.755-29.262-89.815-60.493-109.676-62.449c-28.035-2.952-56.071-6.172-84.128-9.213
c70.314,170.257,99.476,171.982,250.375,231.468c-221.09,14.97-143.661,18.367-225.251,26.735
c-84.646,10.875-86.653,18.232-94.667,11.511C813.467,958.937,762.849,888.809,705.577,772.79
c29.749-79.38,28.397-68.821,16.958-102.187c16.262,1.951,30.957,9.753,46.019,15.615c12.245,7.5,23.638,23.569,15.249,38.056
c19.47-18.292,32.875-66.302,3.332-92.101c-19.28-20.35-53.927-12.915-97.693-6.699c0,0,12.398-168.596,101.813-215.309
c9.834-5.138,22.208-7.208,32.722-10.411c-36.387-14.202-44.036-33.786-98.961-30.629c-7.706-21.32-21.298-39.73-35.869-56.818
c16.513-46.374,63.681-115.5,77.598-120.66c13.782-5.11,32.954,4.8,37.872-15.653c6.91-28.801-50.661-28.095-73.011,4.728
c-1.405,1.882-60.351,70.794-67.379,111.653c-24.801-6.89-26.369-15.528-66.704,14.783
c-2.934-19.697-44.438-139.029-25.742-230.795c0.705-5.15,2.617-10.785-0.075-15.675c-3.732-9.123-25.991-21.837-29.359,4.841
c-6.286,70.479-4.952,120.947,27.163,227.211c-13.819-6.973-29.429-6.928-44.529-7.965
c-8.7-44.178-86.451-215.366-123.394-220.501c-13.602-1.891-23.769,20.538-15.428,29.793
c16.417,18.218,10.372-18.186,73.252,105.428c19.959,40.108,4.201-5.255,39.259,91.945c-13.038,6.04-22.258,17.363-29.644,29.388
c-23.266,5.9-56.357,0.317-85.876,67.716c-1.427,3.258-11.811,34.081-12.638,34.033c31.507,1.824,46.791,0.697,88.133,64.503
c38.746,80.086,35.428,122.371,39.797,178.113c0.258,23.165,31.749,40.031,36.296,43.052c-4.28,24.299-13.244,49.285-5.667,73.898
c2.254,7.339,8.553,12.162,13.763,17.303c2.426-12.972,2.252-26.018,7.166-38.463c4.239-10.738,21.063-39.781,31.545-44.8
c1.422-0.681,15.963-4.565,16.385-3.454c20.038,65.624,59.771,63.704,74.482,74.572
c47.339,57.179,162.525,211.396,282.668,294.546c-2.492,0.149-123.769,18.267-177.115,17.032
c-20.935-0.485-49.912-15.014-60.705-33.354c-6.668-11.332-2.354-25.927-7.546-37.795
c-26.683-60.991-84.88-108.27-142.548-140.737c-108.713-61.206-133.868-49.931-158.054-52.464
c21.813,16.672,27.627,11.247,49.578,52.73c17.752,47.253,42.228,180.064,211.445,215.912
c28.032,5.938,56.053,11.924,84.039,18.077c-173.503,18.873-346.949-22.409-502.365-103.373
C-2.93,875.263,139.332,931.365,31.817,893.58c112.428,129.538,278.068,193.444,446.347,229.957
c-170.956,5.132-351.802,4.44-358.256,175.02c10.003-13.262,17.496-28.452,32.401-36.956
c38.668-22.062,116.454,3.981,222.473,9.342c217.353,10.992,206.215-105.139,367.67-135.11
c92.934-11.935,167.59-11.652,245.652-30.366c5.182,3.258,10.428,6.485,15.536,9.794c-2.993-1.937-86.755,39.764-134.163,154.22
c-32.854,79.319-34.074,186.864,13.874,261.202c27.724,42.984,50.152,46.943,107.555,71.961
c-29.372-70.374-44.224-85.875-46.269-151.525c-5.109-164.025,130.962-291.485,134.299-289.973
c142.692,66.803,130.95,56.035,185.138,76.385c2.102,15.889,1.24,32.237,5.51,47.784c-2.344,0.628,41.927,66.617,49.732,74.763
c7.55-8.383,49.265-75.824,47.403-76.261c3.788-15.67,2.423-31.984,4.034-47.931c53.535-22.012,42.13-10.886,182.694-82.058
c3.288-1.614,143.225,121.589,143.177,285.693c-0.02,65.682-14.387,81.634-41.574,152.88
c56.604-26.776,78.9-31.425,105.285-75.244c45.632-75.782,41.097-183.238,5.812-261.506
c-50.915-112.939-135.923-152.037-138.854-150.009c5.004-3.465,10.148-6.853,15.227-10.269
c78.602,16.298,153.214,13.713,246.471,22.776c162.302,24.978,154.751,141.397,371.661,123.708
c105.804-8.628,182.75-37.057,222.079-16.198c15.16,8.041,23.118,22.992,33.526,35.94
c-11.711-170.3-192.449-164.032-363.482-163.89C2319.846,1056.026,2483.437,987.042,2591.817,854.099z M1369.21,1204.213
c-24.383-69.052-18.288-63.536-53.931-105.851c-34.322,43.394-28.399,37.693-50.641,107.464
c-53.376-20.669-105.77-43.891-157.203-69.058c93.396-60.905,46.708-36.088,129.035-67.402c31.69-10.363,9.596-8.74,78.123-15.531
c68.704,4.675,46.57,3.734,78.564,13.115c83.254,28.76,35.823,5.395,131.052,63.391
C1473.577,1157.081,1421.924,1181.908,1369.21,1204.213z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.5 KiB

+535
View File
@@ -0,0 +1,535 @@
.overlay {
align-items: flex-start;
background: transparent;
}
html,
body,
#root,
.copy {
font-family: "Noto Serif SC", "Microsoft YaHei", "Noto Color Emoji", "Segoe UI Emoji", "Apple Color Emoji", serif;
}
.wall {
justify-content: flex-start;
}
.card {
isolation: isolate;
}
.card-decor {
--decor-primary-image: url("/assets/floral-divider.svg");
--decor-primary-position: center 44%;
--decor-primary-size: 92% auto;
--decor-primary-transform: none;
--decor-primary-opacity: .11;
--decor-primary-color: linear-gradient(90deg, #e7d0f4 4%, #75e6cc 35%, #d5fff1 50%, #6adcc6 68%, #f4bfd4 96%);
--decor-secondary-image: url("/assets/floral-cluster.svg");
--decor-secondary-position: right -10px bottom -22px;
--decor-secondary-size: 34% auto;
--decor-secondary-transform: none;
--decor-secondary-opacity: .065;
--decor-secondary-color: linear-gradient(135deg, #e9c7f1, #79ead3 62%, #fff0bc);
--decor-surface: radial-gradient(ellipse at 50% 0, rgba(180, 255, 237, .09), transparent 58%), linear-gradient(90deg, rgba(230, 203, 241, .04), transparent 18% 82%, rgba(244, 190, 214, .04));
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
border-radius: inherit;
pointer-events: none;
user-select: none;
}
.card-decor::before,
.card-decor::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
background: var(--decor-primary-color);
-webkit-mask-image: var(--decor-primary-image);
mask-image: var(--decor-primary-image);
-webkit-mask-position: var(--decor-primary-position);
mask-position: var(--decor-primary-position);
-webkit-mask-size: var(--decor-primary-size);
mask-size: var(--decor-primary-size);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
opacity: var(--decor-primary-opacity);
transform: var(--decor-primary-transform);
transform-origin: center;
filter: drop-shadow(0 0 6px rgba(105, 255, 224, .28));
}
.card-decor::after {
background: var(--decor-secondary-color);
-webkit-mask-image: var(--decor-secondary-image);
mask-image: var(--decor-secondary-image);
-webkit-mask-position: var(--decor-secondary-position);
mask-position: var(--decor-secondary-position);
-webkit-mask-size: var(--decor-secondary-size);
mask-size: var(--decor-secondary-size);
opacity: var(--decor-secondary-opacity);
transform: var(--decor-secondary-transform);
}
.card-decor-surface {
position: absolute;
inset: 2px;
z-index: 0;
border: 1px solid rgba(157, 255, 232, .14);
border-radius: inherit;
background: var(--decor-surface);
box-shadow:
inset 0 1px rgba(225, 255, 247, .1),
inset 0 -1px rgba(82, 224, 198, .08);
}
.decor-v1 {
--decor-primary-position: center 68%;
--decor-primary-size: 86% auto;
--decor-primary-transform: rotate(180deg);
--decor-primary-color: linear-gradient(90deg, #ffeab1, #79e2cc 45%, #d7fff3 70%, #e8c6f2);
--decor-secondary-position: right -12px bottom -20px;
--decor-secondary-size: 31% auto;
--decor-secondary-transform: rotate(180deg);
--decor-secondary-opacity: .075;
--decor-secondary-color: linear-gradient(145deg, #ffe9aa, #83e9d2 62%, #efd0f5);
--decor-surface: radial-gradient(ellipse at 52% 100%, rgba(255, 226, 163, .08), transparent 56%), linear-gradient(90deg, rgba(112, 232, 208, .045), transparent 34% 76%, rgba(236, 200, 243, .04));
}
.decor-v2 {
--decor-primary-image: url("/assets/floral-vine.svg");
--decor-primary-position: left -12px bottom -13px;
--decor-primary-size: 73% auto;
--decor-primary-opacity: .105;
--decor-primary-color: linear-gradient(110deg, #83ead5, #d8fff3 54%, #cbb9e9);
--decor-secondary-image: url("/assets/floral-divider.svg");
--decor-secondary-position: right -22px top -18px;
--decor-secondary-size: 62% auto;
--decor-secondary-opacity: .055;
--decor-secondary-color: linear-gradient(90deg, #f5c9dc, #8be9d7 68%, #fff1bd);
--decor-surface: radial-gradient(ellipse at 0 74%, rgba(105, 231, 206, .09), transparent 54%), linear-gradient(105deg, rgba(217, 198, 241, .045), transparent 58%);
}
.decor-v3 {
--decor-primary-image: url("/assets/floral-vine.svg");
--decor-primary-position: left -15px bottom -15px;
--decor-primary-size: 77% auto;
--decor-primary-transform: scaleX(-1);
--decor-primary-opacity: .115;
--decor-primary-color: linear-gradient(100deg, #f2bfd7, #8aead8 48%, #d6fff0);
--decor-secondary-position: right -12px bottom -20px;
--decor-secondary-size: 32% auto;
--decor-secondary-opacity: .07;
--decor-secondary-color: linear-gradient(145deg, #d9c1ed, #6fe0c9 58%, #ffe8ad);
--decor-surface: radial-gradient(ellipse at 100% 24%, rgba(235, 190, 218, .075), transparent 54%), linear-gradient(270deg, rgba(103, 228, 204, .05), transparent 64%);
}
.decor-v4 {
--decor-primary-image: url("/assets/floral-cluster.svg");
--decor-primary-position: left -18px bottom -28px;
--decor-primary-size: 45% auto;
--decor-primary-transform: rotate(-5deg);
--decor-primary-opacity: .09;
--decor-primary-color: linear-gradient(135deg, #9cecd8, #fff0bd 57%, #edc5e4);
--decor-secondary-position: left -15px bottom -25px;
--decor-secondary-size: 37% auto;
--decor-secondary-transform: rotate(180deg);
--decor-secondary-opacity: .065;
--decor-secondary-color: linear-gradient(145deg, #dec4ee, #72dfc7 66%, #fff0b6);
--decor-surface: radial-gradient(ellipse at 18% 100%, rgba(110, 232, 207, .085), transparent 47%), radial-gradient(ellipse at 84% 0, rgba(239, 199, 223, .06), transparent 44%);
}
.decor-v5 {
--decor-primary-image: url("/assets/floral-vine.svg");
--decor-primary-position: center top -17px;
--decor-primary-size: 90% auto;
--decor-primary-transform: scaleY(-1);
--decor-primary-opacity: .095;
--decor-primary-color: linear-gradient(90deg, #d9c4ed, #82e5d2 44%, #f8e7b5 78%, #efc2d8);
--decor-secondary-image: url("/assets/floral-divider.svg");
--decor-secondary-position: left 18% top -15px;
--decor-secondary-size: 55% auto;
--decor-secondary-opacity: .06;
--decor-secondary-color: linear-gradient(90deg, #fff0bc, #86e7d5 64%, #e4c6f0);
--decor-surface: radial-gradient(ellipse at 50% 50%, rgba(207, 247, 236, .065), transparent 56%), linear-gradient(90deg, rgba(232, 199, 239, .04), transparent 52%, rgba(255, 229, 168, .035));
}
.card::before {
z-index: 1;
pointer-events: none;
}
.card > .gift-art,
.card > .copy,
.card > .particles {
position: relative;
z-index: 3;
}
.card-particle-layer {
position: absolute;
inset: 0;
z-index: 2;
opacity: .5;
transition: opacity .3s ease;
}
.decor-v1 .card-particle-layer,
.decor-v3 .card-particle-layer {
transform: scaleX(-1);
}
.decor-v2 .card-particle-layer {
transform: scaleY(-1);
}
.decor-v5 .card-particle-layer {
transform: rotate(180deg);
}
.card.expanded .card-particle-layer {
opacity: 1;
}
.card-particle {
--particle-size: 8px;
position: absolute;
display: block;
width: var(--particle-size);
height: var(--particle-size);
opacity: 0;
will-change: transform, opacity;
animation: card-sparkle var(--particle-duration, 4000ms) ease-in-out infinite;
}
.card-particle.star {
background: linear-gradient(135deg, #fff7ca, #b7fff0 58%, #f5d4ff);
clip-path: polygon(50% 0, 60% 39%, 100% 50%, 60% 61%, 50% 100%, 40% 61%, 0 50%, 40% 39%);
filter: drop-shadow(0 0 4px rgba(190, 255, 240, .9));
}
.card-particle.floret {
border-radius: 50%;
background:
radial-gradient(circle at 50% 17%, #ffd7e5 0 19%, transparent 22%),
radial-gradient(circle at 82% 50%, #ffd7e5 0 19%, transparent 22%),
radial-gradient(circle at 50% 83%, #ffd7e5 0 19%, transparent 22%),
radial-gradient(circle at 18% 50%, #ffd7e5 0 19%, transparent 22%),
radial-gradient(circle at 50% 50%, #fff0a9 0 18%, transparent 21%);
filter: drop-shadow(0 0 4px rgba(255, 202, 226, .72));
}
.card-particle:nth-child(1) { left: 2%; top: 14%; animation-delay: -.35s; }
.card-particle:nth-child(2) { right: 3%; top: 16%; --particle-size: 10px; animation-delay: -1.15s; }
.card-particle:nth-child(3) { left: 4%; bottom: 13%; --particle-size: 7px; animation-delay: -2.4s; }
.card-particle:nth-child(4) { right: 2%; bottom: 15%; --particle-size: 8px; animation-delay: -.75s; }
.card-particle:nth-child(5) { left: 22%; top: 5%; --particle-size: 9px; animation-delay: -3.15s; }
.card-particle:nth-child(6) { right: 24%; top: 8%; --particle-size: 6px; animation-delay: -1.65s; }
.card-particle:nth-child(7) { left: 47%; bottom: 4%; --particle-size: 8px; animation-delay: -2.75s; }
.card-particle:nth-child(8) { right: 43%; top: 4%; --particle-size: 7px; animation-delay: -.15s; }
.card-particle:nth-child(9) { left: 34%; top: 46%; --particle-size: 6px; animation-delay: -1.95s; }
.card-particle:nth-child(10) { right: 32%; bottom: 30%; --particle-size: 9px; animation-delay: -3.55s; }
.card-particle:nth-child(11) { left: 12%; top: 48%; --particle-size: 7px; animation-delay: -.95s; }
.card-particle:nth-child(12) { right: 13%; top: 52%; --particle-size: 6px; animation-delay: -2.2s; }
@keyframes card-sparkle {
0%, 100% { opacity: .04; transform: translate3d(0, 4px, 0) rotate(0) scale(.45); }
38% { opacity: .82; transform: translate3d(2px, -2px, 0) rotate(38deg) scale(1.08); }
68% { opacity: .22; transform: translate3d(-1px, -7px, 0) rotate(72deg) scale(.7); }
}
.narrow .card-particle-layer > :nth-child(n+9),
.short .card-particle-layer > :nth-child(n+7) {
display: none;
}
.low-motion .card-particle-layer {
display: none;
}
.wall header {
max-width: 100%;
padding: 9px 14px;
font-size: var(--font-title, 25px);
}
.wall header span {
min-width: 0;
overflow: visible;
overflow-wrap: anywhere;
text-overflow: clip;
white-space: normal;
}
.wall:not(.awake) header,
.wall header span,
.copy,
.copy b,
.copy span,
.copy em {
opacity: 1;
}
.copy {
font-size: var(--font-body, 25px);
}
.copy span {
color: #e7fff9;
}
.danmaku-content {
line-height: 1.35;
}
.card.danmaku .copy .danmaku-content .danmaku-text {
display: inline;
white-space: inherit;
}
.danmaku-emoticon {
display: inline-block;
width: auto;
height: 1.4em;
max-width: 6em;
margin-inline: .08em;
object-fit: contain;
vertical-align: -.32em;
opacity: 1;
filter: none;
}
.danmaku-emoticon.standalone {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: 4.8em;
margin: .12em 0;
vertical-align: top;
}
.card.danmaku.compact .danmaku-emoticon.standalone {
display: inline-block;
max-width: 5em;
max-height: 2.2em;
margin: 0 .1em;
vertical-align: -.55em;
}
.copy b,
.copy span {
max-width: none;
overflow: visible;
overflow-wrap: anywhere;
text-overflow: clip;
white-space: normal;
}
.card.danmaku {
isolation: isolate;
transform-origin: center center;
transition: min-height .24s ease, padding .24s ease, border-radius .24s ease, background .24s ease;
}
.card.danmaku.expanded {
min-height: 92px;
padding: 14px;
border-color: rgba(133, 255, 232, .58);
background:
linear-gradient(90deg, rgba(91, 224, 199, .13), transparent 14% 86%, rgba(91, 224, 199, .13)),
linear-gradient(115deg, rgba(4, 43, 59, .94), rgba(9, 82, 81, .78));
box-shadow:
inset 12px 0 18px -15px rgba(142, 255, 230, .95),
inset -12px 0 18px -15px rgba(142, 255, 230, .95),
0 10px 28px rgba(0, 15, 25, .32);
animation: scroll-unfurl var(--unfold-duration, 1000ms) cubic-bezier(.25, .45, .45, .95) both;
}
.card.danmaku.expanded::after {
content: "";
position: absolute;
inset-block: 4px;
inset-inline: -1px;
z-index: 2;
pointer-events: none;
border-inline: 4px solid rgba(123, 238, 214, .72);
border-radius: 11px;
background:
linear-gradient(90deg,
rgba(201, 255, 240, .32) 0,
rgba(63, 180, 165, .26) 5px,
transparent 5px,
transparent calc(100% - 5px),
rgba(63, 180, 165, .26) calc(100% - 5px),
rgba(201, 255, 240, .32) 100%);
box-shadow:
inset 6px 0 7px -7px rgba(216, 255, 246, .72),
inset -6px 0 7px -7px rgba(216, 255, 246, .72),
-2px 0 0 rgba(7, 46, 53, .72),
2px 0 0 rgba(7, 46, 53, .72);
animation: scroll-rails-open var(--unfold-duration, 1000ms) cubic-bezier(.25, .45, .45, .95) both;
}
.card.danmaku.expanded .copy {
z-index: 3;
width: 100%;
padding-inline: 6px;
flex-direction: column;
align-items: stretch;
gap: 8px;
font-size: var(--font-expanded, 36px);
}
.card.danmaku.expanded .copy b {
color: #f0fffb;
font-size: .78em;
letter-spacing: .06em;
}
.card.danmaku.expanded .copy span {
display: block;
overflow: visible;
white-space: normal;
overflow-wrap: anywhere;
-webkit-line-clamp: unset;
}
.card.danmaku.compact {
min-height: 38px;
padding: 5px 10px;
border-radius: 10px;
background: linear-gradient(115deg, rgba(4, 34, 49, .72), rgba(8, 69, 72, .48));
}
.card.danmaku.compact .copy {
width: 100%;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
font-size: var(--font-compact, 21px);
}
.card.danmaku.compact .copy b {
max-width: none;
flex: 0 0 auto;
color: #aeece1;
}
.card.danmaku.compact .copy span {
min-width: 0;
flex: 1 1 auto;
overflow: visible;
overflow-wrap: anywhere;
text-overflow: clip;
white-space: normal;
}
.short .cards .card:nth-child(n+4) {
display: flex;
}
@keyframes scroll-unfurl {
from {
clip-path: inset(0 49.5% round 18px);
}
to {
clip-path: inset(0 round 14px);
}
}
@keyframes scroll-rails-open {
from {
transform: scaleX(.025);
}
to {
transform: scaleX(1);
}
}
@keyframes arrive {
from { transform: translateX(38px) scale(.96); }
to { transform: none; }
}
.overlay.narrow { padding: 6px; }
.narrow .card.danmaku.expanded {
min-height: 82px;
padding: 12px;
}
.gift-art {
width: 96px;
height: 96px;
}
.narrow .gift-art {
width: 72px;
height: 72px;
}
.narrow .gift.featured .gift-art {
width: 100px;
height: 100px;
}
.buttons,
.preset-buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 20px;
}
.preset-buttons button {
display: flex;
flex-direction: column;
gap: 2px;
}
.preset-buttons small {
font-weight: normal;
opacity: .72;
}
.preset-buttons .active {
box-shadow: 0 0 0 2px rgba(137, 255, 226, .28);
}
.control .obs-address {
margin-top: 16px;
}
.control .obs-address input {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 12px;
}
.control .success {
color: #77f5c9;
}
.preview-viewport {
max-width: 100%;
max-height: 75vh;
overflow: auto;
margin-top: 14px;
border: 1px solid rgba(89, 183, 173, .25);
background: #04131b;
}
.preview-frame {
max-width: none;
height: auto;
resize: both;
overflow: hidden;
}
@media (max-width: 720px) {
.preview-viewport { max-height: 560px; }
}
+68
View File
@@ -0,0 +1,68 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import './style.css'
import './control.css'
type Settings = { title:string; fontScale:number; showDanmaku:boolean; showEnter:boolean; showGift:boolean; showSuperchat:boolean; showGuard:boolean; showLike:boolean; showShare:boolean; maxVisible:number; collapseAfterSeconds:number; unfoldDurationMs:number; motionIntensity:number; particleCount:number; particleSpeed:number; lowPerformanceMode:boolean; highValueThreshold:number; featuredValueThreshold:number }
type Envelope = { id:string; type:string; payload:any }
type Item = Envelope & { key:string; received:number; decorVariant:number }
type DanmakuSegment = { type:'text'; text:string } | { type:'emoticon'; text:string; unique?:string; url:string; width?:number; height?:number; isDynamic?:boolean; standalone?:boolean }
const defaults: Settings = { title:'洛星瓷专用弹幕猪!', fontScale:140, showDanmaku:true, showEnter:true, showGift:true, showSuperchat:true, showGuard:true, showLike:false, showShare:false, maxVisible:5, collapseAfterSeconds:12, unfoldDurationMs:1000, motionIntensity:70, particleCount:8, particleSpeed:100, lowPerformanceMode:false, highValueThreshold:10000, featuredValueThreshold:100000 }
const cardParticles=['star','floret','star','star','floret','star','floret','star','star','floret','star','floret'] as const
const decorVariantCount=6
function stableHash(value:string) { let hash=2166136261; for(let index=0;index<value.length;index++){hash^=value.charCodeAt(index);hash=Math.imul(hash,16777619)} return hash>>>0 }
function chooseDecorVariant(seed:string,previous?:number) { const hash=stableHash(seed); const base=hash%decorVariantCount; if(previous===undefined||base!==previous)return base; return (base+1+((hash>>>8)%(decorVariantCount-1)))%decorVariantCount }
function CardDecor({count,variant}:{count:number;variant:number}) { const visible=Math.min(cardParticles.length,Math.max(0,Math.round(count||0))); const normalized=((variant%decorVariantCount)+decorVariantCount)%decorVariantCount; return <div className={`card-decor decor-v${normalized}`} aria-hidden="true"><i className="card-decor-surface"/><div className="card-particle-layer">{cardParticles.slice(0,visible).map((kind,index)=><i className={`card-particle ${kind}`} key={`${kind}-${index}`}/>)}</div></div> }
function wsUrl() { const p = new URLSearchParams(location.search); const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${location.host}/ws?token=${encodeURIComponent(p.get('token') || '')}` }
function enabled(type:string, s:Settings) { return (type==='live.danmaku'&&s.showDanmaku)||(type==='live.enter'&&s.showEnter)||(type.startsWith('live.gift')&&s.showGift)||(type==='live.superchat'&&s.showSuperchat)||(type==='live.guard.buy'&&s.showGuard)||(type==='live.like'&&s.showLike)||(type==='live.share'&&s.showShare) }
function useEvents(disabled=false) {
const [settings,setSettings]=useState<Settings>(defaults); const [items,setItems]=useState<Item[]>([]); const [connected,setConnected]=useState(false); const settingsRef=useRef(settings)
useEffect(()=>{settingsRef.current=settings},[settings])
useEffect(()=>{ if(disabled)return; let dead=false; let socket:WebSocket|undefined; let timer=0
const open=()=>{ socket=new WebSocket(wsUrl()); socket.onopen=()=>setConnected(true); socket.onclose=()=>{setConnected(false); if(!dead) timer=window.setTimeout(open,1500)}; socket.onmessage=e=>{ try { const x:Envelope=JSON.parse(e.data); if(x.type==='overlay.settings.snapshot'||x.type==='overlay.settings.updated'){setSettings(x.payload.settings);return} setItems(old=>{ const current=settingsRef.current; if(!enabled(x.type,current))return old; const combo=x.type==='live.gift.combo'&&x.payload.comboId; const key=combo?`combo:${combo}`:x.id; const existing=old.find(v=>v.key===key); const decorVariant=existing?.decorVariant??chooseDecorVariant(`${x.type}:${key}`,old[0]?.decorVariant); const next=[{...x,key,received:Date.now(),decorVariant},...old.filter(v=>v.key!==key)].slice(0,current.maxVisible); return next }) }catch{} } }
open(); return()=>{dead=true;window.clearTimeout(timer);socket?.close()}
},[disabled])
useEffect(()=>{setItems(items=>items.slice(0,settings.maxVisible))},[settings.maxVisible])
return {settings,items,connected,setItems}
}
function giftTier(item:Item,s:Settings){const price=item.payload?.gift?.totalPrice||0;return price>=s.featuredValueThreshold?'featured':price>=s.highValueThreshold?'high':'normal'}
const eventRenderers:Record<string,(payload:any)=>string>={
'live.enter':()=> '踏入了云台','live.superchat':p=>p.message,
'live.guard.buy':p=>`开通 ${p.guardName||'舰长'}`,'live.like':()=> '点亮了一颗星','live.share':()=> '分享了直播间'
}
function DanmakuEmoticon({segment}:{segment:Extract<DanmakuSegment,{type:'emoticon'}>}) { const [failed,setFailed]=useState(false); if(failed)return <>{segment.text}</>; return <img className={`danmaku-emoticon${segment.standalone?' standalone':''}`} src={segment.url} width={segment.width||undefined} height={segment.height||undefined} alt={segment.text} title={segment.text} referrerPolicy="no-referrer" decoding="async" draggable={false} onError={()=>setFailed(true)}/> }
function DanmakuBody({payload}:{payload:any}) { const segments=Array.isArray(payload.segments)?payload.segments as DanmakuSegment[]:undefined; if(!segments?.length)return <>{payload.text||''}</>; return <>{segments.map((segment,index)=>segment.type==='emoticon'&&segment.url?<DanmakuEmoticon segment={segment} key={`${segment.unique||segment.url}:${index}`}/>:<span className="danmaku-text" key={`text:${index}`}>{segment.text}</span>)}</> }
function Card({item,settings,expanded}:{item:Item;settings:Settings;expanded:boolean}) { const p=item.payload||{}; const v=p.viewer||{}; const gift=p.gift; const isDanmaku=item.type==='live.danmaku'; const tier=gift?giftTier(item,settings):''; const body=gift?`献上 ${gift.name} ×${p.quantity||1}`:isDanmaku?<DanmakuBody payload={p}/>:eventRenderers[item.type]?.(p)||'送来了一份互动';
return <article className={`card ${gift?'gift':''} ${item.type==='live.danmaku'?'danmaku':''} ${expanded?'expanded':'compact'} ${tier}`} key={item.key}>
<CardDecor count={settings.particleCount} variant={item.decorVariant}/>
{gift&&<div className="gift-art">{gift.animationUrl||gift.imageUrl?<img src={gift.animationUrl||gift.imageUrl} onError={e=>{const image=e.currentTarget;if(gift.imageUrl&&!image.src.endsWith(gift.imageUrl))image.src=gift.imageUrl;else image.style.display='none'}}/>:<span>✦</span>}</div>}
<div className="copy"><b>{v.name||'直播间观众'}</b><span className={isDanmaku?'danmaku-content':undefined}>{body}</span>{gift?.priceCny>0&&<em>¥ {Number(gift.priceCny).toFixed(2)}</em>}</div>{tier==='featured'&&<div className="particles">✦ ✧ ✦</div>}
</article> }
function Overlay({preview=false,previewSettings}:{preview?:boolean;previewSettings?:Settings}) { const root=useRef<HTMLDivElement>(null); const events=useEvents(preview); const {items,connected,setItems}=events; const settings=previewSettings||events.settings; const [shape,setShape]=useState('standard'); const [expandedKey,setExpandedKey]=useState<string>(); const fontFactor=settings.fontScale/100
useEffect(()=>{if(!root.current)return;const ob=new ResizeObserver(([entry])=>{const {width,height}=entry.contentRect;setShape(width<380?'narrow':height<420?'short':'standard')});ob.observe(root.current);return()=>ob.disconnect()},[])
useEffect(()=>{if(preview&&!items.length)setItems([{id:'text-preview',key:'text-preview',received:Date.now(),decorVariant:0,type:'live.danmaku',payload:{viewer:{name:'青玉观众'},text:'今天也要闪闪发光!'}},{id:'gift-preview',key:'gift-preview',received:Date.now()-1000,decorVariant:3,type:'live.gift',payload:{viewer:{name:'星光旅人'},quantity:1,gift:{name:'甜蜜告白',totalPrice:12000,priceCny:12,imageUrl:'',animationUrl:''}}}])},[preview,items.length,setItems])
useEffect(()=>{const newest=items[0];if(!newest){setExpandedKey(undefined);return}setExpandedKey(newest.key);const densityFactor=shape==='short'?.6:1;const timer=window.setTimeout(()=>setExpandedKey(key=>key===newest.key?undefined:key),settings.collapseAfterSeconds*1000*densityFactor);return()=>window.clearTimeout(timer)},[items[0]?.key,items[0]?.received,settings.collapseAfterSeconds,shape])
return <main ref={root} className={`overlay ${shape} ${settings.lowPerformanceMode?'low-motion':''}`} style={{['--motion' as string]:`${settings.motionIntensity/100}`,['--unfold-duration' as string]:`${settings.unfoldDurationMs||defaults.unfoldDurationMs}ms`,['--particle-duration' as string]:`${400000/Math.min(300,Math.max(25,settings.particleSpeed||defaults.particleSpeed))}ms`,['--font-title' as string]:`${18*fontFactor}px`,['--font-body' as string]:`${18*fontFactor}px`,['--font-expanded' as string]:`${26*fontFactor}px`,['--font-compact' as string]:`${15*fontFactor}px`}}><section className={`wall ${items.length?'awake':''}`}><header><i className={connected?'online':''}/><span>{settings.title}</span></header><div className="cards">{items.map(item=><Card item={item} settings={settings} expanded={item.key===expandedKey} key={item.key}/>)}</div></section></main> }
function api(url:string, init?:RequestInit){return fetch(url,{credentials:'same-origin',headers:{'content-type':'application/json',...(init?.headers||{})},...init})}
async function copyToClipboard(text:string){
if(window.isSecureContext&&navigator.clipboard?.writeText){try{await navigator.clipboard.writeText(text);return true}catch{}}
const input=document.createElement('textarea');input.value=text;input.readOnly=true;input.style.position='fixed';input.style.left='-9999px';input.style.opacity='0';document.body.appendChild(input);input.focus();input.select()
let copied=false;try{copied=document.execCommand('copy')}finally{input.remove()}
return copied
}
const previewPresets=[{label:'窄侧栏',width:360,height:600},{label:'竖屏',width:440,height:760},{label:'高清竖栏',width:600,height:1080},{label:'横向条',width:720,height:320}]
function Control(){
const [password,setPassword]=useState(''); const [settings,setSettings]=useState<Settings>(); const [error,setError]=useState(''); const [message,setMessage]=useState(''); const [obsAddress,setObsAddress]=useState(''); const [previewSize,setPreviewSize]=useState(previewPresets[1])
const load=useCallback(async()=>{const r=await api('/api/admin/overlay-settings');if(!r.ok)throw new Error(r.status===401?'请输入管理员密码':'无法读取设置');setSettings(await r.json())},[])
useEffect(()=>{load().catch(()=>{})},[load])
const login=async(e:React.FormEvent)=>{e.preventDefault();const r=await api('/api/auth/login',{method:'POST',body:JSON.stringify({password})});if(!r.ok){setError('密码不正确');return}setError('');await load()}
const save=async()=>{if(!settings)return;const r=await api('/api/admin/overlay-settings',{method:'PUT',body:JSON.stringify(settings)});if(!r.ok)setError('保存失败');else setSettings((await r.json()).settings)}
const copy=async()=>{setMessage('');const r=await api('/api/admin/obs-url');if(!r.ok){setError('无法获取 OBS 地址,请重新登录');return}const {path}=await r.json();const address=new URL(path,location.origin).toString();setObsAddress(address);if(await copyToClipboard(address)){setError('');setMessage('OBS 地址已复制到剪贴板')}else{setError('浏览器阻止了自动复制,请在下方地址框中手动复制')}}
if(!settings)return <main className="control login"><h1>弹幕猪控制台</h1><form onSubmit={login}><input type="password" autoFocus placeholder="管理员密码" value={password} onChange={e=>setPassword(e.target.value)}/><button>进入</button>{error&&<p>{error}</p>}</form></main>
const edit=(key:keyof Settings,value:any)=>setSettings({...settings,[key]:value})
const labels={showDanmaku:'弹幕',showEnter:'进房',showGift:'礼物',showSuperchat:'醒目留言',showGuard:'舰长',showLike:'点赞',showShare:'分享',lowPerformanceMode:'低性能模式'}
return <main className="control"><section><h1>青玉弹幕姬</h1><p>改动会立即同步到所有 OBS 浏览器源。</p><label>标题<input value={settings.title} onChange={e=>edit('title',e.target.value)}/></label><label>字号 <input type="range" min="50" max="300" step="5" value={settings.fontScale} onChange={e=>edit('fontScale',+e.target.value)}/><output>{settings.fontScale}%</output></label><label>最大可见条数 <input type="range" min="1" max="12" value={settings.maxVisible} onChange={e=>edit('maxVisible',+e.target.value)}/><output>{settings.maxVisible}</output></label><label>自动收缩秒数 <input type="range" min="2" max="60" value={settings.collapseAfterSeconds} onChange={e=>edit('collapseAfterSeconds',+e.target.value)}/><output>{settings.collapseAfterSeconds}s</output></label><label>卷轴展开时长 <input type="range" min="200" max="5000" step="100" value={settings.unfoldDurationMs} onChange={e=>edit('unfoldDurationMs',+e.target.value)}/><output>{(settings.unfoldDurationMs/1000).toFixed(1)}s</output></label><label>动效强度 <input type="range" min="0" max="100" value={settings.motionIntensity} onChange={e=>edit('motionIntensity',+e.target.value)}/><output>{settings.motionIntensity}%</output></label><label>每卡粒子数量 <input type="range" min="0" max="12" step="1" value={settings.particleCount} onChange={e=>edit('particleCount',+e.target.value)}/><output>{settings.particleCount}</output></label><label>粒子动画速度 <input type="range" min="25" max="300" step="25" value={settings.particleSpeed} onChange={e=>edit('particleSpeed',+e.target.value)}/><output>{settings.particleSpeed}%</output></label><fieldset>{(Object.keys(labels) as (keyof typeof labels)[]).map(k=><label key={k}><input type="checkbox" checked={settings[k]} onChange={e=>edit(k,e.target.checked)}/>{labels[k]}</label>)}</fieldset><label>高价值礼物(厘)<input type="number" value={settings.highValueThreshold} onChange={e=>edit('highValueThreshold',+e.target.value)}/></label><label>特别高价值(厘)<input type="number" value={settings.featuredValueThreshold} onChange={e=>edit('featuredValueThreshold',+e.target.value)}/></label><div className="buttons"><button type="button" onClick={save}>保存并同步</button><button type="button" className="secondary" onClick={copy}>复制 OBS 地址</button></div>{obsAddress&&<label className="obs-address">OBS 浏览器源地址<input readOnly value={obsAddress} onFocus={e=>e.currentTarget.select()}/></label>}{message&&<p className="success">{message}</p>}{error&&<p>{error}</p>}</section><section className="preview"><h2>自适应预览</h2><p>选择常用尺寸后仍可拖拽预览框右下角;OBS 中也可使用任意宽高。</p><div className="preset-buttons">{previewPresets.map(size=><button type="button" className={size.label===previewSize.label?'active':'secondary'} onClick={()=>setPreviewSize(size)} key={size.label}>{size.label}<small>{size.width}×{size.height}</small></button>)}</div><div className="preview-viewport"><div className="preview-frame" style={{width:previewSize.width,height:previewSize.height}}><Overlay preview previewSettings={settings}/></div></div></section></main>
}
const isControl=location.pathname.startsWith('/control');createRoot(document.getElementById('root')!).render(isControl?<Control/>:<Overlay/>);
+1
View File
@@ -0,0 +1 @@
*{box-sizing:border-box}html,body,#root{margin:0;width:100%;height:100%;font-family:"Noto Serif SC","Microsoft YaHei",serif}body{background:transparent;color:#dcfffa}.overlay{width:100%;height:100%;padding:clamp(8px,2vw,22px);display:flex;align-items:center;justify-content:flex-end;overflow:hidden;background:radial-gradient(ellipse at 100% 50%,rgba(21,94,96,.2),transparent 62%)}.wall{width:min(100%,480px);display:flex;flex-direction:column;gap:9px;filter:drop-shadow(0 10px 28px rgba(0,11,19,.36))}.wall header{align-self:flex-end;display:flex;gap:8px;align-items:center;padding:8px 14px;border:1px solid rgba(144,255,240,.33);border-radius:999px;background:linear-gradient(110deg,rgba(13,47,63,.72),rgba(29,115,107,.44));backdrop-filter:blur(12px);letter-spacing:.08em;font-size:clamp(12px,2.5vw,17px);transition:.5s}.wall:not(.awake) header{opacity:.72}.wall header i{width:7px;height:7px;border-radius:50%;background:#55736f}.wall header i.online{background:#74ffd9;box-shadow:0 0 10px #4bffc7}.cards{display:flex;flex-direction:column;gap:8px}.card{position:relative;min-height:58px;padding:11px 14px;border:1px solid rgba(101,226,211,.28);border-radius:14px;overflow:hidden;display:flex;align-items:center;gap:10px;background:linear-gradient(115deg,rgba(4,34,49,.82),rgba(8,69,72,.61));backdrop-filter:blur(15px);animation:arrive calc(.35s + .35s*var(--motion)) cubic-bezier(.19,.9,.3,1) both}.card:before{content:"";position:absolute;inset:0;background:linear-gradient(105deg,transparent 25%,rgba(155,255,232,.14),transparent 65%);transform:translateX(-120%);animation:sheen calc(2.4s - 1.3s*var(--motion)) ease-in-out .25s both}.copy{position:relative;min-width:0;display:flex;flex-direction:column;gap:3px;font-size:clamp(12px,2.7vw,17px)}.copy b{color:#e6fff9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.copy span{color:#a9dbd4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.copy em{font-style:normal;color:#ffdc89;font-size:.84em}.gift-art{z-index:1;width:48px;height:48px;flex:none;border-radius:12px;background:radial-gradient(circle,#2e9f9e,transparent 66%);display:grid;place-items:center}.gift-art img{width:100%;height:100%;object-fit:contain}.gift.high{min-height:72px;border-color:rgba(102,255,229,.65);background:linear-gradient(105deg,rgba(5,59,78,.92),rgba(14,119,103,.7))}.gift.featured{min-height:128px;border-color:#b6ffdc;background:radial-gradient(circle at 20% 50%,rgba(69,236,190,.4),transparent 35%),linear-gradient(118deg,rgba(7,63,84,.97),rgba(12,105,89,.85));animation:featured calc(2.8s - 1.5s*var(--motion)) ease-in-out infinite}.gift.featured .gift-art{width:92px;height:92px}.particles{position:absolute;right:9px;top:6px;color:#ceffe4;letter-spacing:9px;animation:float 2.2s ease-in-out infinite}.narrow .overlay{padding:6px}.narrow .wall{gap:5px}.narrow .card{min-height:45px;padding:7px 9px;border-radius:10px}.narrow .gift-art{width:34px;height:34px}.narrow .gift.featured{min-height:90px;align-items:flex-start}.narrow .gift.featured .gift-art{width:58px;height:58px}.short .cards .card:nth-child(n+4){display:none}.low-motion *{animation:none!important}.control{min-height:100%;padding:clamp(20px,5vw,56px);display:grid;grid-template-columns:minmax(300px,520px) minmax(280px,1fr);gap:36px;background:linear-gradient(135deg,#061923,#092c32 55%,#06151f);color:#d9fff6}.control h1{margin:0;color:#bffff0}.control h2{margin:0}.control p{color:#88bdb5}.control label{display:block;margin:14px 0;color:#bcebe3}.control input:not([type=checkbox]):not([type=range]){width:100%;padding:10px;margin-top:5px;border:1px solid #357e79;border-radius:8px;background:#071d28;color:#e6fff9}.control input[type=range]{margin-left:10px;accent-color:#5ae7c6}.control output{margin-left:8px}.control fieldset{border:1px solid #28645f;border-radius:10px;display:flex;flex-wrap:wrap;gap:3px 12px}.control fieldset label{margin:8px 0}.buttons{display:flex;gap:10px;margin-top:20px}.control button{padding:10px 14px;border:0;border-radius:8px;background:#55dcb9;color:#06211e;font-weight:bold;cursor:pointer}.control button.secondary{background:#173e4b;color:#d4fff7}.preview-frame{height:600px;resize:both;overflow:auto;border:1px dashed #4dafa4;background:linear-gradient(135deg,rgba(71,190,172,.12),transparent)}.login{display:grid;place-content:center;grid-template-columns:360px;background:#071923}.login form{display:flex;flex-direction:column;gap:12px}@keyframes arrive{from{opacity:0;transform:translateX(38px) scale(.96)}to{opacity:1;transform:none}}@keyframes sheen{to{transform:translateX(135%)}}@keyframes featured{50%{filter:brightness(1.18);transform:scale(1.015)}}@keyframes float{50%{transform:translateY(-8px);opacity:.5}}@media(max-width:720px){.control{grid-template-columns:1fr}.preview-frame{height:480px}}
+4
View File
@@ -0,0 +1,4 @@
{
"compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ES2022"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx" },
"include": ["src"]
}
+3
View File
@@ -0,0 +1,3 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({ plugins: [react()] })
+32
View File
@@ -1307,6 +1307,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -1841,6 +1847,7 @@ dependencies = [
"tokio",
"tokio-postgres",
"toml",
"tower-http",
"tracing",
"tracing-subscriber",
"uuid",
@@ -1898,6 +1905,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -3660,10 +3677,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags 2.13.0",
"bytes",
"futures-core",
"futures-util",
"http 1.4.2",
"http-body 1.1.0",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
@@ -3815,6 +3841,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
+1
View File
@@ -22,6 +22,7 @@ sha2 = "0.10"
tokio = { version = "1", features = ["full"] }
tokio-postgres = { version = "0.7", features = ["with-serde_json-1"] }
toml = "0.8"
tower-http = { version = "0.6", features = ["fs"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
uuid = { version = "1", features = ["v4", "serde"] }
@@ -1,19 +1,3 @@
CREATE TABLE IF NOT EXISTS viewer_accounts (
scope TEXT NOT NULL, room_id TEXT NOT NULL, uid TEXT NOT NULL, display_name TEXT NOT NULL,
points INTEGER NOT NULL DEFAULT 0 CHECK(points >= 0), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY(scope, room_id, uid)
);
CREATE TABLE IF NOT EXISTS point_ledger (
id UUID PRIMARY KEY, scope TEXT NOT NULL, room_id TEXT NOT NULL, uid TEXT NOT NULL, delta INTEGER NOT NULL,
reason TEXT NOT NULL, source_event_id TEXT UNIQUE, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS wheel_spins (
id UUID PRIMARY KEY, scope TEXT NOT NULL, room_id TEXT NOT NULL, uid TEXT NOT NULL, category TEXT NOT NULL,
song_id INTEGER NOT NULL, song_title TEXT NOT NULL, fallback BOOLEAN NOT NULL, cost INTEGER NOT NULL, balance_after INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS viewer_accounts_name_idx ON viewer_accounts(scope, room_id, display_name);
CREATE INDEX IF NOT EXISTS point_ledger_recent_idx ON point_ledger(scope, room_id, created_at DESC);
CREATE TABLE IF NOT EXISTS live_session_outbox (
id BIGSERIAL PRIMARY KEY,
streamer_uid BIGINT NOT NULL,
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS overlay_settings (
room_id TEXT PRIMARY KEY,
settings JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
File diff suppressed because it is too large Load Diff
+545
View File
@@ -0,0 +1,545 @@
use std::{collections::HashMap, sync::Arc};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::sync::RwLock;
use tokio_postgres::NoTls;
use tracing::warn;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OverlaySettings {
pub title: String,
#[serde(default = "default_font_scale")]
pub font_scale: u16,
pub show_danmaku: bool,
pub show_enter: bool,
pub show_gift: bool,
pub show_superchat: bool,
pub show_guard: bool,
pub show_like: bool,
pub show_share: bool,
pub max_visible: u8,
pub collapse_after_seconds: u16,
#[serde(default = "default_unfold_duration_ms")]
pub unfold_duration_ms: u16,
pub motion_intensity: u8,
#[serde(default = "default_particle_count")]
pub particle_count: u8,
#[serde(default = "default_particle_speed")]
pub particle_speed: u16,
pub low_performance_mode: bool,
/// Bilibili gift-panel prices are in thousandths of a yuan.
pub high_value_threshold: i64,
pub featured_value_threshold: i64,
}
impl Default for OverlaySettings {
fn default() -> Self {
Self {
title: "洛星瓷专用弹幕猪!".into(),
font_scale: default_font_scale(),
show_danmaku: true,
show_enter: true,
show_gift: true,
show_superchat: true,
show_guard: true,
show_like: false,
show_share: false,
max_visible: 5,
collapse_after_seconds: 12,
unfold_duration_ms: default_unfold_duration_ms(),
motion_intensity: 70,
particle_count: default_particle_count(),
particle_speed: default_particle_speed(),
low_performance_mode: false,
high_value_threshold: 10_000,
featured_value_threshold: 100_000,
}
}
}
impl OverlaySettings {
pub fn sanitize(mut self) -> Self {
self.title = self.title.trim().chars().take(48).collect();
if self.title.is_empty() {
self.title = Self::default().title;
}
self.max_visible = self.max_visible.clamp(1, 12);
self.font_scale = self.font_scale.clamp(50, 300);
self.collapse_after_seconds = self.collapse_after_seconds.clamp(2, 120);
self.unfold_duration_ms = self.unfold_duration_ms.clamp(200, 5_000);
self.motion_intensity = self.motion_intensity.min(100);
self.particle_count = self.particle_count.min(12);
self.particle_speed = self.particle_speed.clamp(25, 300);
self.high_value_threshold = self.high_value_threshold.max(0);
self.featured_value_threshold =
self.featured_value_threshold.max(self.high_value_threshold);
self
}
}
fn default_font_scale() -> u16 {
140
}
fn default_unfold_duration_ms() -> u16 {
1_000
}
fn default_particle_count() -> u8 {
8
}
fn default_particle_speed() -> u16 {
100
}
pub async fn load_settings(
database_url: &str,
room_id: &str,
defaults: OverlaySettings,
) -> Result<OverlaySettings, String> {
let (client, connection) = tokio_postgres::connect(database_url, NoTls)
.await
.map_err(|e| e.to_string())?;
tokio::spawn(async move {
let _ = connection.await;
});
let row = client
.query_opt(
"SELECT settings FROM overlay_settings WHERE room_id=$1",
&[&room_id],
)
.await
.map_err(|e| e.to_string())?;
Ok(row
.and_then(|row| serde_json::from_value::<OverlaySettings>(row.get::<_, Value>(0)).ok())
.unwrap_or(defaults)
.sanitize())
}
pub async fn save_settings(
database_url: &str,
room_id: &str,
settings: &OverlaySettings,
) -> Result<(), String> {
let (client, connection) = tokio_postgres::connect(database_url, NoTls)
.await
.map_err(|e| e.to_string())?;
tokio::spawn(async move {
let _ = connection.await;
});
client.execute("INSERT INTO overlay_settings(room_id,settings,updated_at) VALUES($1,$2,now()) ON CONFLICT(room_id) DO UPDATE SET settings=EXCLUDED.settings,updated_at=now()", &[&room_id, &json!(settings)]).await.map_err(|e| e.to_string())?;
Ok(())
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GiftMeta {
pub id: Option<i64>,
pub name: String,
pub coin_type: String,
pub unit_price: i64,
pub image_url: Option<String>,
pub animation_url: Option<String>,
pub effect_type: Option<String>,
pub stay_time: Option<i64>,
}
#[derive(Clone, Default)]
pub struct GiftCatalog {
by_id: Arc<RwLock<HashMap<i64, GiftMeta>>>,
by_name: Arc<RwLock<HashMap<String, GiftMeta>>>,
}
#[derive(Clone, Debug)]
pub struct EmoticonMeta {
pub emoji: String,
pub unique: Option<String>,
pub url: String,
pub width: Option<u32>,
pub height: Option<u32>,
pub is_dynamic: bool,
pub bulge_display: bool,
}
#[derive(Clone, Default)]
pub struct EmoticonCatalog {
by_unique: Arc<RwLock<HashMap<String, EmoticonMeta>>>,
by_emoji: Arc<RwLock<HashMap<String, EmoticonMeta>>>,
}
impl EmoticonCatalog {
pub async fn len(&self) -> usize {
self.by_unique.read().await.len()
}
pub async fn get(&self, unique: Option<&str>, emoji: &str) -> Option<EmoticonMeta> {
if let Some(unique) = unique.filter(|value| !value.is_empty()) {
if let Some(emoticon) = self.by_unique.read().await.get(unique).cloned() {
return Some(emoticon);
}
}
self.by_emoji.read().await.get(emoji).cloned()
}
pub async fn refresh(
&self,
room_id: &str,
cookie: &str,
timeout_seconds: u64,
) -> Result<usize, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_seconds))
.build()
.map_err(|error| error.to_string())?;
let response = client
.get("https://api.live.bilibili.com/xlive/web-ucenter/v2/emoticon/GetEmoticons")
.query(&[("platform", "pc"), ("room_id", room_id)])
.header(reqwest::header::COOKIE, cookie)
.header(reqwest::header::REFERER, "https://live.bilibili.com/")
.header(
reqwest::header::USER_AGENT,
"Mozilla/5.0 lxc-streamutils/1.0",
)
.send()
.await
.map_err(|error| error.to_string())?;
if !response.status().is_success() {
return Err(format!("emoticon API HTTP {}", response.status()));
}
let payload: Value = response.json().await.map_err(|error| error.to_string())?;
if payload.get("code").and_then(Value::as_i64).unwrap_or(-1) != 0 {
return Err(format!(
"emoticon API error: {}",
payload
.get("message")
.and_then(Value::as_str)
.unwrap_or("unknown")
));
}
let (unique, emoji, count) = parse_emoticon_catalog(&payload)?;
*self.by_unique.write().await = unique;
*self.by_emoji.write().await = emoji;
Ok(count)
}
}
impl GiftCatalog {
pub async fn len(&self) -> usize {
self.by_id.read().await.len()
}
pub async fn get(&self, id: Option<i64>, name: &str) -> Option<GiftMeta> {
if let Some(id) = id {
if let Some(gift) = self.by_id.read().await.get(&id).cloned() {
return Some(gift);
}
}
self.by_name
.read()
.await
.get(&normalize_name(name))
.cloned()
}
pub async fn refresh(&self, room_id: &str, timeout_seconds: u64) -> Result<usize, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_seconds))
.build()
.map_err(|e| e.to_string())?;
let response = client
.get("https://api.live.bilibili.com/xlive/web-room/v1/giftPanel/roomGiftList")
// Despite the endpoint name, the current API only includes
// gift_config for platform=pc. platform=web may return code=0
// with an empty room_gift_list and no catalog.
.query(&[("platform", "pc"), ("room_id", room_id)])
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("gift panel HTTP {}", response.status()));
}
let payload: Value = response.json().await.map_err(|e| e.to_string())?;
if payload.get("code").and_then(Value::as_i64).unwrap_or(-1) != 0 {
return Err(format!(
"gift panel API error: {}",
payload
.get("message")
.and_then(Value::as_str)
.unwrap_or("unknown")
));
}
let (ids, names, count) = parse_catalog(&payload)?;
*self.by_id.write().await = ids;
*self.by_name.write().await = names;
Ok(count)
}
pub fn spawn_refresh(self, room_id: String, interval_seconds: u64, timeout_seconds: u64) {
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(interval_seconds)).await;
if let Err(error) = self.refresh(&room_id, timeout_seconds).await {
warn!(%error, "gift catalog refresh failed; retaining the last successful cache");
}
}
});
}
}
fn parse_catalog(
payload: &Value,
) -> Result<(HashMap<i64, GiftMeta>, HashMap<String, GiftMeta>, usize), String> {
let list = payload
.pointer("/data/gift_config/base_config/list")
.and_then(Value::as_array)
.ok_or("gift panel response has no gift list")?;
let mut ids = HashMap::new();
let mut names = HashMap::new();
for item in list {
let Some(name) = item.get("name").and_then(Value::as_str) else {
continue;
};
let id = item.get("id").and_then(Value::as_i64);
let gift = GiftMeta {
id,
name: name.to_owned(),
coin_type: item
.get("coin_type")
.and_then(Value::as_str)
.unwrap_or("gold")
.to_owned(),
unit_price: item.get("price").and_then(Value::as_i64).unwrap_or(0),
image_url: string_field(item, "img_basic"),
animation_url: string_field(item, "gif"),
effect_type: item.get("effect").and_then(|value| match value {
Value::String(value) if !value.is_empty() => Some(value.clone()),
Value::Number(value) => Some(value.to_string()),
_ => None,
}),
stay_time: item.get("stay_time").and_then(Value::as_i64),
};
if let Some(id) = id {
ids.insert(id, gift.clone());
}
names.insert(normalize_name(name), gift);
}
if ids.is_empty() && names.is_empty() {
return Err("gift panel returned an empty catalog".into());
}
Ok((ids, names, list.len()))
}
fn string_field(value: &Value, name: &str) -> Option<String> {
value
.get(name)
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn parse_emoticon_catalog(
payload: &Value,
) -> Result<
(
HashMap<String, EmoticonMeta>,
HashMap<String, EmoticonMeta>,
usize,
),
String,
> {
let packages = payload
.pointer("/data/data")
.and_then(Value::as_array)
.ok_or("emoticon response has no package list")?;
let mut by_unique = HashMap::new();
let mut by_emoji = HashMap::new();
let mut count = 0;
for item in packages
.iter()
.filter_map(|package| package.get("emoticons").and_then(Value::as_array))
.flatten()
{
let emoji = item
.get("emoji")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
let unique = item
.get("emoticon_unique")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let Some(url) = item
.get("url")
.and_then(Value::as_str)
.and_then(normalize_image_url)
else {
continue;
};
let emoticon = EmoticonMeta {
emoji: emoji.clone(),
unique: unique.clone(),
url,
width: number_as_u32(item.get("width")),
height: number_as_u32(item.get("height")),
is_dynamic: truthy(item.get("is_dynamic")),
bulge_display: truthy(item.get("bulge_display")),
};
if let Some(unique) = unique {
by_unique.insert(unique, emoticon.clone());
}
if !emoji.is_empty() {
by_emoji.insert(emoji, emoticon);
}
count += 1;
}
if count == 0 {
return Err("emoticon API returned an empty catalog".into());
}
Ok((by_unique, by_emoji, count))
}
pub fn normalize_image_url(url: &str) -> Option<String> {
let url = url.trim();
if let Some(rest) = url.strip_prefix("http://") {
Some(format!("https://{rest}"))
} else if url.starts_with("https://") {
Some(url.to_owned())
} else if url.starts_with("//") {
Some(format!("https:{url}"))
} else {
None
}
}
fn number_as_u32(value: Option<&Value>) -> Option<u32> {
value.and_then(|value| {
value
.as_u64()
.and_then(|value| u32::try_from(value).ok())
.or_else(|| value.as_str()?.parse().ok())
})
}
fn truthy(value: Option<&Value>) -> bool {
value.is_some_and(|value| {
value.as_bool().unwrap_or(false)
|| value.as_i64().is_some_and(|value| value != 0)
|| value.as_str().is_some_and(|value| value == "1")
})
}
fn normalize_name(name: &str) -> String {
name.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_current_gift_panel_fields_and_indexes_by_id_and_name() {
let payload = json!({"data":{"gift_config":{"base_config":{"list":[{
"id":42,"name":"青玉灯","price":30000,"coin_type":"gold","img_basic":"https://example.test/gift.png",
"gif":"https://example.test/gift.gif","effect":2,"stay_time":3
}]}}}});
let (ids, names, count) = parse_catalog(&payload).expect("catalog");
assert_eq!(count, 1);
assert_eq!(ids.get(&42).expect("id index").unit_price, 30_000);
assert_eq!(
names
.get("青玉灯")
.expect("name index")
.effect_type
.as_deref(),
Some("2")
);
}
#[test]
fn rejects_an_empty_catalog_without_replacing_the_cache() {
let payload = json!({"data":{"gift_config":{"base_config":{"list":[]}}}});
assert!(parse_catalog(&payload).is_err());
}
#[tokio::test]
async fn parses_emoticon_packages_and_normalizes_image_urls() {
let payload = json!({"code":0,"data":{"data":[{"emoticons":[{
"emoji":"[热]",
"emoticon_unique":"emoji_278",
"url":"http://i0.hdslb.com/bfs/live/hot.png",
"width":20,
"height":20,
"is_dynamic":1,
"bulge_display":0
}]}]}});
let (by_unique, by_emoji, count) =
parse_emoticon_catalog(&payload).expect("emoticon catalog");
assert_eq!(count, 1);
assert_eq!(
by_unique.get("emoji_278").expect("unique index").url,
"https://i0.hdslb.com/bfs/live/hot.png"
);
assert!(by_emoji.get("[热]").expect("emoji index").is_dynamic);
let catalog = EmoticonCatalog {
by_unique: Arc::new(RwLock::new(by_unique)),
by_emoji: Arc::new(RwLock::new(by_emoji)),
};
assert!(catalog.get(Some("emoji_278"), "missing").await.is_some());
}
#[test]
fn settings_are_bounded_before_persistence() {
let settings = OverlaySettings {
title: " ".into(),
font_scale: 999,
max_visible: 99,
collapse_after_seconds: 1,
unfold_duration_ms: 9_000,
motion_intensity: 200,
particle_count: 255,
particle_speed: 0,
high_value_threshold: -1,
featured_value_threshold: -2,
..OverlaySettings::default()
}
.sanitize();
assert_eq!(settings.title, "洛星瓷专用弹幕猪!");
assert_eq!(settings.font_scale, 300);
assert_eq!(settings.max_visible, 12);
assert_eq!(settings.collapse_after_seconds, 2);
assert_eq!(settings.unfold_duration_ms, 5_000);
assert_eq!(settings.motion_intensity, 100);
assert_eq!(settings.particle_count, 12);
assert_eq!(settings.particle_speed, 25);
assert_eq!(settings.featured_value_threshold, 0);
let settings = OverlaySettings {
particle_speed: u16::MAX,
..OverlaySettings::default()
}
.sanitize();
assert_eq!(settings.particle_speed, 300);
}
#[test]
fn old_saved_settings_receive_new_field_defaults() {
let mut value = json!(OverlaySettings::default());
let object = value.as_object_mut().expect("settings object");
object.remove("fontScale");
object.remove("unfoldDurationMs");
object.remove("particleCount");
object.remove("particleSpeed");
let settings: OverlaySettings = serde_json::from_value(value).expect("legacy settings");
assert_eq!(settings.font_scale, 140);
assert_eq!(settings.unfold_duration_ms, 1_000);
assert_eq!(settings.particle_count, 8);
assert_eq!(settings.particle_speed, 100);
}
}
+44 -11
View File
@@ -1,4 +1,4 @@
# 洛星瓷直播转盘服务配置。
# 洛星瓷直播弹幕姬配置。
#
# 使用方式:复制为 config.toml 后填写所有 replace-with-* 项;该文件包含
# CookieCloud、数据库和访问令牌等敏感信息,请不要提交或公开分享。
@@ -14,13 +14,9 @@ room_id = "000000"
[server]
port = 9719
# 转盘系统自己的读写数据库。此账号必须可创建和修改业务表。
# 弹幕姬设置数据库。此账号必须可创建和修改 overlay_settings 表。
[database]
url = "postgresql://wheel:replace-with-wheel-password@127.0.0.1:5432/wheel?sslmode=disable"
# 洛星瓷歌单数据库,只用于读取歌曲和标签,建议使用只读账号。
[songlist]
database_url = "postgresql://lxc_songlist:replace-with-songlist-password@127.0.0.1:5432/lxc_songlist?sslmode=disable"
url = "postgresql://streamutils:replace-with-database-password@127.0.0.1:5432/streamutils?sslmode=disable"
# 外部部署的 CookieCloud 实例。key 是同步 UUID;password 是同步密码。
[cookiecloud]
@@ -38,10 +34,47 @@ session_secret = "replace-with-at-least-32-random-characters"
[obs]
access_token = "replace-with-a-long-random-obs-token"
# 启用后,服务会用 CookieCloud 中的 bili_jct 和 Cookie 向直播间发送回复。
# 回复失败不会影响记账、抽歌或 OBS 推送。
[reply]
enabled = false
# Bilibili 礼物目录缓存。启动时会立即加载,之后按此间隔刷新;
# 请求失败会继续使用上一次成功结果。刷新间隔最小 60 秒。
[gifts]
refresh_interval_seconds = 600
request_timeout_seconds = 10
# Bilibili 直播间表情目录缓存。接口使用 CookieCloud 中的 SESSDATA;
# 实时消息自带的表情图片始终优先,目录仅用于补齐缺失的图片地址。
[emoticons]
refresh_interval_seconds = 600
request_timeout_seconds = 10
# OBS 弹幕姬的初始设置。仅当该直播间尚未通过 /control 保存过设置时使用;
# 一旦控制台保存,PostgreSQL 中的设置优先。价格阈值单位为千分之一元,
# 因此 10000 = 10 元、100000 = 100 元。
[overlay]
title = "洛星瓷专用弹幕猪!"
# 全局字号百分比,可在管理控制台中实时调整;允许范围 50-300。
font_scale = 140
max_visible = 5
collapse_after_seconds = 12
# 新弹幕卷轴从中心向两侧展开的时长,单位毫秒;允许范围 200-5000。
unfold_duration_ms = 1000
motion_intensity = 70
# 每张消息卡片的闪光粒子数量;0 可关闭粒子,允许范围 0-12。
particle_count = 8
# 粒子动画速度百分比;100 为标准速度,允许范围 25-300。
particle_speed = 100
low_performance_mode = false
high_value_threshold = 10000
featured_value_threshold = 100000
# 初始显示的事件类别,同样可在 /control 中随时修改并实时同步到 OBS。
[overlay.events]
danmaku = true
enter = true
gift = true
superchat = true
guard = true
like = false
share = false
# 默认会记录本服务生命周期信息,并屏蔽 blivedm_rs 的认证响应日志。
# 如需诊断可临时调高本服务级别;不要将 blivedm 设为 info。
+27
View File
@@ -0,0 +1,27 @@
server {
listen 80;
listen [::]:80;
server_name danmaku.luoxingci.com;
access_log /var/log/nginx/danmaku.luoxingci.com.access.log;
error_log /var/log/nginx/danmaku.luoxingci.com.error.log;
location / {
proxy_pass http://127.0.0.1:9719;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600;
proxy_send_timeout 3600;
proxy_buffering off;
}
}