tweaked v1
This commit is contained in:
@@ -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>
|
||||
Generated
+1823
File diff suppressed because it is too large
Load Diff
@@ -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" }
|
||||
}
|
||||
@@ -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 |
@@ -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; }
|
||||
}
|
||||
@@ -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/>);
|
||||
@@ -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}}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({ plugins: [react()] })
|
||||
Generated
+32
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
+1320
-86
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user