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
+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);
}
}