tweaked v1
This commit is contained in:
@@ -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}}
|
||||
Reference in New Issue
Block a user