Files
Live-streaming/web/index.html
2026-08-15 14:43:56 +08:00

2682 lines
94 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BGI 直播间 UI</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--cream: #fff2c9;
--cream-soft: #f8eecf;
--cream-deep: #d9bf7c;
--mint: #b8ead0;
--mint-2: #92d8b0;
--mint-3: #68bd91;
--leaf: #2f7858;
--leaf-dark: #153c2f;
--leaf-deep: #0d2b22;
--glass: rgba(19, 66, 49, 0.52);
--glass-strong: rgba(15, 55, 42, 0.72);
--glass-light: rgba(255, 250, 226, 0.24);
--line: rgba(255, 244, 202, 0.42);
--line-green: rgba(165, 235, 197, 0.48);
--text: #f7fff5;
--text-dim: #cce7d6;
--shadow: rgba(12, 47, 35, 0.45);
--custom-bg-image: none;
--custom-bg-opacity: 0.65;
--custom-bg-blur: 0px;
--custom-bg-size: cover;
}
body {
background: transparent;
color: var(--text);
font-family: "汉仪润圆", "HYRunYuan", "Microsoft YaHei", system-ui, sans-serif;
font-size: 18px;
overflow: hidden;
}
#viewport {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
}
body.preview #viewport {
background: radial-gradient(circle at 24% 20%, rgba(150, 224, 179, 0.26), transparent 32%),
radial-gradient(circle at 80% 78%, rgba(255, 236, 177, 0.18), transparent 34%),
#10281f;
}
#canvas {
width: 1920px;
height: 1080px;
position: relative;
background: transparent;
transform-origin: top left;
}
#frame {
position: absolute;
left: 0;
top: 0;
width: 1920px;
height: 1080px;
pointer-events: none;
overflow: hidden;
z-index: 1;
isolation: isolate;
/* clip-path: 在背景上挖出 1098×640 圆角矩形镂空(左 192, 顶 210, rx 44) */
clip-path: path('M0,0 L1920,0 L1920,1080 L0,1080 Z M236,210 A44,44 0 0 0 192,254 V806 A44,44 0 0 0 236,850 H1246 A44,44 0 0 0 1290,806 V254 A44,44 0 0 0 1246,210 Z');
-webkit-clip-path: path('M0,0 L1920,0 L1920,1080 L0,1080 Z M236,210 A44,44 0 0 0 192,254 V806 A44,44 0 0 0 236,850 H1246 A44,44 0 0 0 1290,806 V254 A44,44 0 0 0 1246,210 Z');
}
#frame::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 12% 20%, rgba(255, 248, 211, 0.72) 0 9%, transparent 25%),
radial-gradient(circle at 86% 10%, rgba(154, 226, 183, 0.64) 0 11%, transparent 28%),
radial-gradient(circle at 70% 88%, rgba(255, 239, 190, 0.56) 0 13%, transparent 30%),
linear-gradient(135deg, rgba(223, 244, 231, 0.88) 0%, rgba(249, 240, 207, 0.82) 47%, rgba(191, 232, 207, 0.88) 100%),
#1a4a38;
z-index: 1;
}
#frame::after {
content: '';
position: absolute;
inset: 0;
opacity: 0.34;
background-image: linear-gradient(120deg, rgba(255, 255, 255, 0.22) 0 1px, transparent 1px 38px),
linear-gradient(30deg, rgba(47, 120, 88, 0.10) 0 1px, transparent 1px 46px),
radial-gradient(circle, rgba(47, 120, 88, 0.13) 1.2px, transparent 1.6px);
background-size: 88px 88px, 76px 76px, 34px 34px;
z-index: 3;
}
#custom-bg {
position: absolute;
inset: 0;
background-image: var(--custom-bg-image);
background-size: var(--custom-bg-size);
background-position: center;
background-repeat: no-repeat;
opacity: var(--custom-bg-opacity);
filter: blur(var(--custom-bg-blur));
transform: scale(1.02);
z-index: 2;
pointer-events: none;
}
#game-cutout {
position: absolute;
left: 192px;
top: 210px;
width: 1098px;
height: 640px;
border-radius: 44px;
background: transparent;
pointer-events: none;
z-index: 5;
/* 立体边框: 外发光 + 实线边框 + 内阴影 + 投影 */
border: 3px solid rgba(255, 247, 208, 0.38);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15),
0 0 32px rgba(12, 47, 35, 0.28),
0 24px 60px rgba(7, 28, 20, 0.22),
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
inset 0 2px 24px rgba(0, 0, 0, 0.18);
}
body.preview #game-cutout {
background: rgba(8, 24, 19, 0.76);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.12) inset,
0 18px 42px rgba(7, 38, 28, 0.26);
}
/* 游戏画面左侧:可用配置组纵向轮播 */
.available-groups-panel {
left: 16px;
top: 210px;
width: 160px;
height: 698px;
display: flex;
flex-direction: column;
padding: 14px 6px 15px !important;
}
.available-groups-panel .panel-title {
font-size: 17px;
gap: 7px;
white-space: nowrap;
margin-bottom: 8px;
}
.available-groups-panel .panel-title::before {
width: 7px;
height: 13px;
}
.group-carousel-viewport {
position: relative;
width: 100%;
margin-left: 0;
z-index: 1;
flex: 1;
min-height: 0;
overflow: hidden;
border-radius: 18px;
mask-image: linear-gradient(to bottom, transparent 0, #000 24px, #000 calc(100% - 24px), transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 24px, #000 calc(100% - 24px), transparent 100%);
}
.group-carousel-track {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 82px;
gap: 7px 3px;
transform: translateY(0);
will-change: transform;
}
.group-carousel-track.smooth-scroll {
animation: group-carousel-scroll var(--group-scroll-duration, 90s) linear infinite;
}
@keyframes group-carousel-scroll {
from { transform: translateY(0); }
to { transform: translateY(var(--group-scroll-distance, -1000px)); }
}
.group-card {
position: relative;
height: 82px;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 5px 3px 4px;
overflow: hidden;
isolation: isolate;
border-radius: 14px;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.14), rgba(120, 214, 158, 0.07));
border: 1px solid rgba(255, 255, 255, 0.13);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.11);
}
.group-card-image {
position: relative;
z-index: 2;
width: 46px;
height: 46px;
flex-shrink: 0;
object-fit: contain;
filter: drop-shadow(0 5px 9px rgba(6, 31, 22, 0.30));
}
.group-card-nahida {
position: absolute;
z-index: 1;
top: 39%;
left: 69%;
width: 90px;
height: 90px;
object-fit: contain;
opacity: 0.40;
pointer-events: none;
transform: translate(-50%, -50%) rotate(-24deg) scale(1.08);
filter:
sepia(0.08)
saturate(1.18)
hue-rotate(70deg)
brightness(1.72)
contrast(1.12)
drop-shadow(0 0 3px rgba(218, 255, 228, 0.72))
drop-shadow(0 0 11px rgba(104, 244, 158, 0.52));
}
.group-card.uses-nahida {
background:
radial-gradient(circle at 72% 36%, rgba(116, 241, 161, 0.25), transparent 48%),
linear-gradient(145deg, rgba(255, 255, 255, 0.16), rgba(92, 225, 144, 0.11));
border-color: rgba(184, 255, 207, 0.34);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.13),
inset -12px 8px 24px rgba(87, 224, 139, 0.06);
}
.group-card-name {
position: relative;
z-index: 2;
width: 100%;
color: var(--cream);
font-size: 11px;
line-height: 1.1;
font-weight: 850;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 2px 7px rgba(0, 0, 0, 0.32);
}
.group-carousel-empty {
position: relative;
z-index: 1;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: rgba(247, 255, 245, 0.54);
font-size: 13px;
text-align: center;
line-height: 1.5;
}
.group-carousel-empty[hidden],
.group-carousel-viewport[hidden] {
display: none !important;
}
body.frame-off #frame {
display: none;
}
.deco {
position: absolute;
pointer-events: none;
z-index: 2;
}
.deco-blob {
border-radius: 44% 56% 64% 36% / 48% 35% 65% 52%;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.48), rgba(255, 255, 255, 0.08)),
radial-gradient(circle at 34% 30%, rgba(255, 245, 205, 0.85), transparent 28%),
linear-gradient(135deg, rgba(122, 211, 159, 0.46), rgba(255, 237, 176, 0.36));
border: 1px solid rgba(255, 255, 255, 0.42);
box-shadow: inset 0 0 28px rgba(255, 255, 255, 0.22), 0 18px 44px rgba(31, 92, 67, 0.18);
backdrop-filter: blur(16px) saturate(140%);
-webkit-backdrop-filter: blur(16px) saturate(140%);
}
.deco-blob.one {
width: 250px;
height: 88px;
left: 1006px;
top: 22px;
transform: rotate(-4deg);
}
.deco-blob.two {
display: none;
}
.deco-blob.three {
width: 230px;
height: 230px;
left: 1648px;
top: 32px;
opacity: 0.65;
}
.deco-ribbon {
left: 1332px;
top: 18px;
width: 540px;
height: 74px;
border-radius: 32px;
background: linear-gradient(90deg, rgba(255, 247, 210, 0.36), rgba(137, 218, 171, 0.20));
border: 1px solid rgba(255, 255, 255, 0.34);
box-shadow: inset 0 1px 18px rgba(255, 255, 255, 0.24);
}
.deco-leaf {
width: 82px;
height: 42px;
border-radius: 100% 0 100% 0;
background: linear-gradient(135deg, rgba(96, 183, 132, 0.62), rgba(255, 240, 189, 0.38));
border: 1px solid rgba(255, 255, 255, 0.30);
box-shadow: 0 10px 24px rgba(38, 112, 78, 0.16);
}
.deco-leaf::after {
content: '';
position: absolute;
left: 13px;
top: 21px;
width: 56px;
height: 1px;
background: rgba(255, 255, 255, 0.55);
transform: rotate(-24deg);
}
.leaf-a {
left: 118px;
top: 38px;
transform: rotate(18deg);
}
.leaf-b {
left: 1212px;
top: 842px;
transform: rotate(-38deg);
}
.leaf-c {
left: 1810px;
top: 928px;
transform: rotate(34deg);
}
.deco-star {
color: rgba(255, 246, 207, 0.92);
font-size: 28px;
text-shadow: 0 0 18px rgba(255, 241, 186, 0.66);
}
.star-a {
left: 510px;
top: 46px;
}
.star-b {
left: 1468px;
top: 954px;
font-size: 22px;
}
.star-c {
left: 1850px;
top: 402px;
font-size: 18px;
}
#hole-mask,
#hole-frame {
display: none;
}
.panel {
position: absolute;
z-index: 8;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.035)),
radial-gradient(circle at 18% 0%, rgba(255, 244, 199, 0.18), transparent 42%),
radial-gradient(circle at 100% 100%, rgba(129, 218, 166, 0.22), transparent 40%),
var(--glass);
border: 1px solid rgba(255, 247, 208, 0.34);
border-top-color: rgba(255, 255, 255, 0.56);
border-left-color: rgba(255, 255, 255, 0.42);
border-radius: 26px;
box-shadow: 0 22px 58px var(--shadow),
inset 0 1px 0 rgba(255, 255, 255, 0.32),
inset 0 -18px 36px rgba(18, 70, 51, 0.18);
padding: 14px 24px 16px;
overflow: hidden;
backdrop-filter: blur(20px) saturate(150%);
-webkit-backdrop-filter: blur(20px) saturate(150%);
}
.panel::before {
content: '';
position: absolute;
left: 18px;
right: 18px;
top: 7px;
height: 22px;
border-radius: 18px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.30), rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.22));
opacity: 0.66;
pointer-events: none;
}
.panel::after {
content: '';
position: absolute;
width: 180px;
height: 180px;
right: -80px;
bottom: -100px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 239, 187, 0.18), transparent 66%);
pointer-events: none;
}
body.glass-on .panel {
background: linear-gradient(145deg, rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0.055)),
radial-gradient(circle at 18% 0%, rgba(255, 244, 199, 0.22), transparent 42%),
radial-gradient(circle at 100% 100%, rgba(129, 218, 166, 0.25), transparent 40%),
rgba(19, 66, 49, 0.42);
backdrop-filter: blur(28px) saturate(180%);
-webkit-backdrop-filter: blur(28px) saturate(180%);
}
.panel-title {
position: relative;
z-index: 1;
font-size: 20px;
font-weight: 800;
color: var(--cream);
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 10px;
letter-spacing: 0.8px;
text-shadow: 0 2px 10px rgba(14, 50, 37, 0.46);
}
.panel-title::before {
content: '';
width: 9px;
height: 16px;
border-radius: 10px 2px 10px 2px;
background: linear-gradient(180deg, #fff3bf, #87dca8 62%, #4ba977);
box-shadow: 0 0 16px rgba(170, 235, 193, 0.62);
transform: rotate(12deg);
}
/* 左上:运行进度 + 排队 / 性能 */
.top-panel {
left: 16px;
top: 16px;
width: 856px;
height: 178px;
display: flex;
gap: 16px;
align-items: stretch;
}
.performance-panel {
left: 888px;
top: 16px;
width: 402px;
height: 178px;
display: flex;
flex-direction: column;
}
.progress-area {
width: 250px;
display: flex;
flex-direction: column;
justify-content: flex-start;
min-width: 0;
position: relative;
z-index: 1;
}
.queue-area {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
position: relative;
z-index: 1;
min-width: 0;
}
.performance-area {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
position: relative;
z-index: 1;
}
.queue-list {
flex: 1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 34px;
gap: 7px 9px;
align-content: start;
}
.queue-item {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
min-width: 0;
font-size: 15px;
color: var(--text-dim);
background: linear-gradient(90deg, rgba(255, 255, 255, 0.12), rgba(170, 236, 198, 0.08));
border: 1px solid rgba(255, 255, 255, 0.12);
padding: 4px 9px;
border-radius: 14px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.10);
}
.queue-item.admin {
background: linear-gradient(90deg, rgba(255, 240, 185, 0.24), rgba(130, 225, 166, 0.18));
color: var(--cream);
border-color: rgba(255, 242, 194, 0.36);
box-shadow: 0 0 18px rgba(157, 232, 184, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.queue-item .idx {
color: #fff1b7;
font-weight: 800;
margin-right: 5px;
flex-shrink: 0;
}
.queue-item .name {
min-width: 0;
max-width: 155px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 750;
}
.queue-item .pts {
margin-left: 8px;
flex-shrink: 0;
font-size: 14px;
font-family: "汉仪润圆", "HYRunYuan", "Microsoft YaHei", sans-serif;
color: #fff1b7;
font-weight: 850;
}
.queue-more {
justify-content: center;
align-items: center;
font-size: 18px;
font-weight: 900;
color: var(--cream);
letter-spacing: 0.5px;
background: linear-gradient(135deg, rgba(255, 238, 183, 0.24), rgba(125, 221, 165, 0.20));
}
.badge {
font-size: 15px;
font-weight: 800;
background: linear-gradient(135deg, rgba(255, 238, 183, 0.30), rgba(125, 221, 165, 0.26));
color: var(--cream);
border: 1px solid rgba(255, 255, 255, 0.24);
padding: 3px 10px;
border-radius: 999px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.task-name-row {
min-width: 0;
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 8px;
}
.task-nahida-icon {
width: 25px;
height: 25px;
flex: 0 0 auto;
object-fit: contain;
filter: brightness(1.55) contrast(1.18) drop-shadow(0 1px 4px rgba(6, 31, 22, 0.9));
}
.task-name {
min-width: 0;
font-size: 22px;
font-weight: 850;
color: var(--cream);
line-height: 1.12;
letter-spacing: 0.4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-area .task-meta {
gap: 6px;
flex-direction: column;
align-items: flex-start;
}
.progress-area .task-meta span {
max-width: 100%;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-bar {
background: rgba(12, 45, 34, 0.42);
height: 10px;
border-radius: 999px;
overflow: hidden;
margin-bottom: 8px;
border: 1px solid rgba(255, 255, 255, 0.14);
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.20);
}
.progress-fill {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.35), transparent 30%),
linear-gradient(90deg, #fff0b5, #a8eabf 45%, #62c58c);
height: 100%;
width: 0%;
border-radius: 999px;
transition: width 0.5s;
box-shadow: 0 0 20px rgba(150, 234, 182, 0.46);
}
.task-meta {
display: flex;
justify-content: space-between;
font-size: 15px;
line-height: 1.1;
color: var(--text-dim);
}
.perf-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
align-items: center;
position: relative;
z-index: 1;
}
.perf-card {
height: 106px;
border-radius: 22px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 7px;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.13), rgba(120, 214, 158, 0.06));
border: 1px solid rgba(255, 255, 255, 0.13);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.10);
}
.perf-ring {
--pct: 0;
width: 58px;
height: 58px;
border-radius: 50%;
display: grid;
place-items: center;
background: conic-gradient(#fff0b5 0 calc(var(--pct) * 1%), rgba(142, 232, 174, 0.72) calc(var(--pct) * 1%) calc(var(--pct) * 1% + 7%), rgba(255, 255, 255, 0.12) 0 100%);
box-shadow: 0 0 18px rgba(150, 234, 182, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.18);
}
.perf-ring::before {
content: '';
width: 42px;
height: 42px;
border-radius: 50%;
background: rgba(14, 48, 37, 0.78);
border: 1px solid rgba(255, 255, 255, 0.10);
}
.perf-value {
position: absolute;
font-size: 15px;
font-weight: 900;
color: #fff2c9;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.35);
}
.perf-label {
color: var(--text-dim);
font-weight: 900;
font-size: 14px;
letter-spacing: 0.5px;
}
/* 左下:独立下播倒计时 */
.live-end-panel {
left: 16px;
top: 924px;
width: 160px;
height: 140px;
display: flex;
flex-direction: column;
padding: 12px 14px 12px;
}
.live-end-countdown {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
}
/* 左下:音乐 + 点歌队列 */
.music-panel {
left: 192px;
top: 866px;
width: 662px;
height: 198px;
display: flex;
gap: 12px;
align-items: stretch;
padding-left: 18px;
padding-right: 18px;
}
.live-end-panel .panel-title,
.current-music .panel-title,
.music-extra .panel-title {
font-size: 17px;
gap: 7px;
white-space: nowrap;
}
.live-end-panel .panel-title::before,
.current-music .panel-title::before,
.music-extra .panel-title::before {
width: 7px;
height: 13px;
}
.countdown-stage {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 0;
padding-bottom: 1px;
}
.countdown-value {
color: #fff2c9;
font-family: "Microsoft YaHei", sans-serif;
font-size: 24px;
line-height: 1;
font-weight: 900;
letter-spacing: 1px;
white-space: nowrap;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.38), 0 0 18px rgba(255, 240, 181, 0.24);
}
.countdown-value.inactive {
color: rgba(220, 244, 229, 0.62);
font-size: 21px;
}
.countdown-target {
max-width: 100%;
margin-top: 9px;
color: var(--text-dim);
font-size: 12px;
font-weight: 750;
line-height: 1.3;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.countdown-day {
margin-top: 3px;
color: rgba(255, 241, 183, 0.76);
font-size: 11px;
line-height: 1.2;
white-space: nowrap;
}
.current-music {
width: 286px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 7px;
position: relative;
z-index: 1;
}
.cover-row {
flex: 1;
display: flex;
gap: 11px;
align-items: center;
min-width: 0;
}
.cover {
width: 82px;
height: 82px;
border-radius: 22px;
flex-shrink: 0;
background: radial-gradient(circle at 30% 20%, rgba(255, 247, 205, 0.72), transparent 30%),
linear-gradient(135deg, rgba(121, 216, 159, 0.52), rgba(255, 236, 177, 0.28));
border: 1px solid rgba(255, 255, 255, 0.32);
display: flex;
align-items: center;
justify-content: center;
font-size: 38px;
color: rgba(255, 246, 209, 0.76);
overflow: hidden;
box-shadow: 0 18px 38px rgba(10, 42, 31, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.30);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.music-info {
flex: 1;
min-width: 0;
position: relative;
z-index: 1;
}
.music-info .title {
font-size: 18px;
font-weight: 850;
color: var(--cream);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 3px;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.26);
}
.music-info .artist {
font-size: 14px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.music-note {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
margin-top: 3px;
max-width: 100%;
padding: 4px 8px;
border-radius: 999px;
color: var(--text-dim);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: rgba(255, 255, 255, 0.10);
border: 1px solid rgba(255, 255, 255, 0.14);
}
.music-note::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: #8cf1ad;
box-shadow: 0 0 14px rgba(140, 241, 173, 0.62);
}
.music-extra {
flex: 1;
min-width: 0;
position: relative;
z-index: 1;
padding-left: 2px;
}
.playlist {
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.playlist-list {
flex: 1;
list-style: none;
display: flex;
flex-direction: column;
gap: 5px;
overflow-y: auto;
padding-right: 4px;
}
.playlist-list li {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 6px;
min-width: 0;
font-size: 13px;
color: var(--text-dim);
padding: 6px 7px;
border-radius: 12px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.12), rgba(134, 222, 169, 0.06));
border: 1px solid rgba(255, 255, 255, 0.10);
}
.playlist-list li .title {
flex: 1;
min-width: 0;
color: var(--cream);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 650;
}
.playlist-list li .by {
flex-shrink: 0;
max-width: 38%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: rgba(220, 244, 229, 0.72);
}
.input-panel {
left: 870px;
top: 866px;
width: 420px;
height: 198px;
display: flex;
flex-direction: column;
}
.input-visualizer {
min-width: 0;
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
flex: 1;
}
.input-stage {
flex: 1;
display: grid;
grid-template-columns: 124px 1fr;
gap: 14px;
align-items: center;
padding-top: 1px;
}
.mouse-card, .keys-card {
height: 136px;
border-radius: 18px;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.13), rgba(120, 214, 158, 0.06));
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.10);
}
.mouse-card {
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.mouse-orbit {
position: absolute;
left: 10px;
top: 9px;
width: 98px;
height: 98px;
border-radius: 50%;
background: radial-gradient(circle at center, rgba(255, 244, 202, 0.10) 0 26%, transparent 27%),
radial-gradient(circle at center, transparent 0 52%, rgba(255, 255, 255, 0.10) 53%, transparent 55%);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.mouse-orbit::before, .mouse-orbit::after {
content: '';
position: absolute;
background: rgba(255, 244, 202, 0.10);
border-radius: 999px;
}
.mouse-orbit::before {
left: 48px;
top: 9px;
width: 1px;
height: 80px;
}
.mouse-orbit::after {
left: 9px;
top: 48px;
width: 80px;
height: 1px;
}
.mouse-body {
position: relative;
width: 42px;
height: 64px;
border-radius: 23px 23px 20px 20px;
background: linear-gradient(145deg, rgba(255, 246, 211, 0.22), rgba(99, 190, 133, 0.18));
border: 2px solid rgba(255, 244, 202, 0.36);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.24), 0 10px 22px rgba(8, 36, 26, 0.18);
overflow: hidden;
z-index: 2;
}
.mouse-body::before {
content: '';
position: absolute;
left: 19px;
top: 2px;
bottom: 34px;
width: 2px;
background: rgba(255, 244, 202, 0.30);
}
.mouse-body::after {
content: '';
position: absolute;
left: 16px;
top: 18px;
width: 8px;
height: 11px;
border-radius: 999px;
background: rgba(255, 244, 202, 0.36);
}
.mouse-btn {
position: absolute;
top: 0;
width: 20px;
height: 30px;
opacity: 0.34;
transition: opacity 0.05s, background 0.05s;
}
.mouse-btn.left {
left: 0;
border-radius: 23px 0 8px 0;
}
.mouse-btn.right {
right: 0;
border-radius: 0 23px 0 8px;
}
.mouse-btn.active {
opacity: 1;
background: linear-gradient(180deg, rgba(255, 239, 176, 0.92), rgba(136, 231, 171, 0.66));
box-shadow: 0 0 18px rgba(158, 238, 186, 0.42);
}
.mouse-trail {
position: absolute;
left: 54px;
top: 54px;
width: 18px;
height: 18px;
margin-left: -9px;
margin-top: -9px;
border-radius: 50%;
opacity: 0;
transform: translate(0, 0) scale(0.55);
background: radial-gradient(circle, #fff1b7 0 25%, rgba(142, 232, 174, 0.82) 26% 56%, rgba(142, 232, 174, 0.12) 57% 100%);
box-shadow: 0 0 18px rgba(255, 241, 183, 0.55), 0 0 28px rgba(142, 232, 174, 0.36);
transition: transform 0.06s linear, opacity 0.08s linear;
z-index: 3;
}
.mouse-trail.active {
opacity: 1;
}
.keys-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
}
.key-row {
display: flex;
justify-content: center;
gap: 6px;
}
.keycap {
width: 34px;
height: 29px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
font-family: "汉仪润圆", "HYRunYuan", "Microsoft YaHei", sans-serif;
font-size: 15px;
font-weight: 800;
color: rgba(232, 250, 238, 0.72);
background: linear-gradient(145deg, rgba(255, 255, 255, 0.10), rgba(128, 218, 164, 0.08));
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
transition: background 0.04s, color 0.04s, transform 0.04s, box-shadow 0.04s;
}
.keycap.space {
width: 154px;
}
.keycap.active {
color: #244233;
transform: translateY(1px);
background: linear-gradient(180deg, #fff0b5, #8fe5ae 68%, #62c58c);
box-shadow: 0 0 16px rgba(150, 234, 182, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.55);
}
/* 右上:BGI 日志 */
.log-panel {
left: 1306px;
top: 16px;
width: 598px;
height: 338px;
display: flex;
flex-direction: column;
}
.log-title {
justify-content: flex-start;
}
.log-title .status {
margin-left: auto;
}
.status {
display: flex;
align-items: center;
gap: 7px;
font-size: 15px;
color: var(--text-dim);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #8fa99a;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.24);
}
.dot.on {
background: #8cf1ad;
box-shadow: 0 0 14px #8cf1ad, 0 0 28px rgba(140, 241, 173, 0.35);
}
.log-box {
position: relative;
z-index: 1;
flex: 1;
font-family: "Microsoft YaHei", "微软雅黑", sans-serif;
font-size: 16px;
line-height: 1.7;
color: var(--text-dim);
overflow-y: auto;
padding: 4px 7px 0 0;
mask-image: linear-gradient(to bottom, transparent 0, #000 14px, #000 calc(100% - 8px));
}
.log-box::-webkit-scrollbar, .danmu-box::-webkit-scrollbar, .playlist-list::-webkit-scrollbar {
width: 5px;
}
.log-box::-webkit-scrollbar-thumb, .danmu-box::-webkit-scrollbar-thumb, .playlist-list::-webkit-scrollbar-thumb {
background: rgba(255, 242, 198, 0.26);
border-radius: 999px;
}
.log-line {
word-break: break-all;
padding: 1px 0;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.22);
}
.log-inf {
color: #aaf1c3;
}
.log-tp {
color: #ffe8a8;
}
.log-warn {
color: #ffd08f;
}
.log-err {
color: #ffaaa7;
}
.log-blue {
color: #bfe1ff;
}
.log-fight {
color: #ffc1d0;
}
.log-pick {
color: #dfd0ff;
}
.log-dbg {
color: rgba(216, 232, 220, 0.50);
}
/* 右侧中部:教学轮播 */
.tutorial-panel {
left: 1306px;
top: 370px;
width: 598px;
height: 140px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tutorial-title {
justify-content: flex-start;
}
.tutorial-title::after {
content: "轮播";
margin-left: auto;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
color: rgba(247, 255, 245, 0.72);
background: rgba(255, 242, 201, 0.10);
border: 1px solid rgba(255, 242, 201, 0.13);
}
.tutorial-stage {
position: relative;
z-index: 1;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 0 14px 3px;
overflow: hidden;
}
.tutorial-text {
width: 100%;
color: var(--text);
font-size: 26px;
font-weight: 900;
line-height: 1.35;
text-align: center;
white-space: nowrap;
letter-spacing: 0.7px;
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.48), 0 0 14px rgba(255, 240, 181, 0.18);
opacity: 1;
transform: translateY(0);
transition: opacity 0.45s ease, transform 0.45s ease;
}
.tutorial-text.long {
font-size: 23px;
letter-spacing: 0.35px;
}
.tutorial-text.extra-long {
font-size: 21px;
letter-spacing: 0;
}
.tutorial-text.switching {
opacity: 0;
transform: translateY(10px);
}
.tutorial-dots {
position: relative;
z-index: 1;
display: flex;
justify-content: center;
gap: 6px;
padding-bottom: 4px;
}
.tutorial-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: rgba(247, 255, 245, 0.23);
transition: width 0.35s ease, background 0.35s ease, box-shadow 0.35s ease;
}
.tutorial-dot.active {
width: 22px;
border-radius: 999px;
background: #fff0b5;
box-shadow: 0 0 10px rgba(255, 240, 181, 0.48);
}
/* 右下:弹幕 */
.danmu-panel {
left: 1306px;
top: 526px;
width: 598px;
height: 538px;
display: flex;
flex-direction: column;
}
.danmu-box {
position: relative;
z-index: 1;
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
padding-right: 6px;
}
.danmu-item {
display: flex;
gap: 8px;
align-items: baseline;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.035)),
rgba(11, 45, 34, 0.20);
padding: 8px 11px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.10);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.danmu-name {
color: #fff1b7;
font-weight: 800;
font-size: 18px;
flex-shrink: 0;
}
.danmu-text {
color: var(--text);
font-size: 18px;
word-break: break-all;
line-height: 1.5;
}
.empty {
color: rgba(247, 255, 245, 0.48);
text-align: center;
padding: 20px;
font-size: 15px;
}
/* ===== 礼物特效层 ===== */
#gift-layer {
position: absolute;
inset: 0;
z-index: 60;
pointer-events: none;
overflow: hidden;
}
#gift-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.gift-banner {
position: absolute;
left: 50%;
top: 120px;
transform: translateX(-50%) translateY(-30px) scale(0.8);
opacity: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 30px;
border-radius: 999px;
font-weight: 900;
white-space: nowrap;
transition: transform 0.35s cubic-bezier(0.2, 1.4, 0.4, 1), opacity 0.3s ease;
}
.gift-banner.show {
transform: translateX(-50%) translateY(0) scale(1);
opacity: 1;
}
.gift-banner.hide {
transform: translateX(-50%) translateY(-20px) scale(0.92);
opacity: 0;
transition: transform 0.4s ease, opacity 0.4s ease;
}
.gift-banner .gb-name { font-weight: 900; }
.gift-banner .gb-gift { font-weight: 800; }
.gift-banner.tier-1 {
font-size: 22px;
color: #eafff2;
background: rgba(20, 60, 45, 0.72);
border: 1px solid rgba(155, 240, 190, 0.35);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
}
.gift-banner.tier-2 {
font-size: 26px;
color: #fff3c4;
background: rgba(70, 52, 10, 0.78);
border: 1px solid rgba(255, 214, 110, 0.55);
box-shadow: 0 6px 26px rgba(0, 0, 0, 0.4), 0 0 24px rgba(255, 200, 80, 0.25);
}
.gift-banner.tier-3 {
font-size: 30px;
color: #ffe9ec;
background: rgba(88, 22, 40, 0.82);
border: 2px solid rgba(255, 130, 160, 0.7);
box-shadow: 0 8px 34px rgba(0, 0, 0, 0.45), 0 0 40px rgba(255, 110, 150, 0.4);
animation: gift-pulse 0.9s ease-in-out infinite alternate;
}
.gift-banner.tier-4 {
font-size: 34px;
color: #fff;
background: rgba(60, 20, 90, 0.85);
border: 2px solid rgba(220, 160, 255, 0.85);
box-shadow: 0 10px 44px rgba(0, 0, 0, 0.5), 0 0 60px rgba(200, 130, 255, 0.55);
animation: gift-pulse 0.6s ease-in-out infinite alternate;
}
@keyframes gift-pulse {
from { filter: brightness(1); }
to { filter: brightness(1.25); }
}
.gift-flash {
position: absolute;
inset: 0;
background: radial-gradient(circle at 50% 30%, rgba(255, 235, 170, 0.35), transparent 60%);
opacity: 0;
transition: opacity 0.25s ease;
}
.gift-flash.on { opacity: 1; }
/* ================================================================
主题预设:aurora(极光)
仅做视觉美化,不改变任何面板位置/尺寸/布局(left/top/width/height 不变)。
通过 body.theme-aurora 覆盖颜色变量与玻璃质感。
================================================================ */
body.theme-aurora {
--cream: #eef3ff;
--cream-soft: #dce6fb;
--cream-deep: #a8baff;
--mint: #6fe0ff;
--mint-2: #5bc0ff;
--mint-3: #7d8bff;
--leaf: #5566ff;
--leaf-dark: #2c2c66;
--leaf-deep: #131436;
--glass: rgba(16, 20, 48, 0.50);
--glass-strong: rgba(10, 12, 32, 0.72);
--glass-light: rgba(150, 180, 255, 0.20);
--line: rgba(150, 180, 255, 0.40);
--line-green: rgba(120, 200, 255, 0.46);
--text: #f4f7ff;
--text-dim: #b7c4e6;
--shadow: rgba(3, 5, 18, 0.62);
}
body.theme-aurora #frame::before {
background:
radial-gradient(circle at 14% 18%, rgba(122, 140, 255, 0.52) 0 10%, transparent 30%),
radial-gradient(circle at 88% 12%, rgba(94, 231, 255, 0.48) 0 12%, transparent 32%),
radial-gradient(circle at 72% 90%, rgba(255, 122, 217, 0.44) 0 14%, transparent 34%),
radial-gradient(circle at 30% 86%, rgba(52, 245, 197, 0.38) 0 10%, transparent 30%),
linear-gradient(135deg, rgba(20, 22, 56, 0.92) 0%, rgba(28, 24, 68, 0.88) 50%, rgba(13, 18, 50, 0.92) 100%),
#0b0e26;
}
body.theme-aurora #frame::after {
opacity: 0.28;
background-image:
linear-gradient(120deg, rgba(160, 190, 255, 0.20) 0 1px, transparent 1px 40px),
linear-gradient(30deg, rgba(120, 200, 255, 0.12) 0 1px, transparent 1px 48px),
radial-gradient(circle, rgba(150, 180, 255, 0.20) 1.4px, transparent 1.8px);
background-size: 90px 90px, 78px 78px, 36px 36px;
}
body.theme-aurora #game-cutout {
border: 3px solid rgba(150, 180, 255, 0.46);
box-shadow:
0 0 0 1px rgba(180, 200, 255, 0.18),
0 0 40px rgba(90, 120, 255, 0.30),
0 26px 70px rgba(4, 6, 24, 0.55),
inset 0 0 0 1px rgba(200, 220, 255, 0.10),
inset 0 2px 28px rgba(0, 0, 0, 0.32);
animation: aurora-cutout-glow 5s ease-in-out infinite;
}
@keyframes aurora-cutout-glow {
0%, 100% {
box-shadow:
0 0 0 1px rgba(180, 200, 255, 0.18),
0 0 40px rgba(90, 120, 255, 0.30),
0 26px 70px rgba(4, 6, 24, 0.55),
inset 0 0 0 1px rgba(200, 220, 255, 0.10),
inset 0 2px 28px rgba(0, 0, 0, 0.32);
}
50% {
box-shadow:
0 0 0 1px rgba(200, 220, 255, 0.28),
0 0 58px rgba(110, 150, 255, 0.46),
0 26px 70px rgba(4, 6, 24, 0.55),
inset 0 0 0 1px rgba(220, 235, 255, 0.16),
inset 0 2px 28px rgba(0, 0, 0, 0.32);
}
}
body.theme-aurora .panel {
background:
linear-gradient(145deg, rgba(150, 180, 255, 0.16), rgba(150, 180, 255, 0.04)),
radial-gradient(circle at 18% 0%, rgba(122, 140, 255, 0.20), transparent 44%),
radial-gradient(circle at 100% 100%, rgba(94, 231, 255, 0.18), transparent 42%),
rgba(16, 20, 46, 0.55);
border: 1px solid rgba(150, 180, 255, 0.36);
border-top-color: rgba(190, 210, 255, 0.52);
border-left-color: rgba(170, 195, 255, 0.42);
box-shadow:
0 22px 58px var(--shadow),
inset 0 1px 0 rgba(200, 220, 255, 0.22),
inset 0 -18px 36px rgba(8, 10, 30, 0.32);
backdrop-filter: blur(22px) saturate(160%);
-webkit-backdrop-filter: blur(22px) saturate(160%);
}
body.theme-aurora .panel::before {
background: linear-gradient(90deg, rgba(170, 195, 255, 0.28), rgba(170, 195, 255, 0.06), rgba(120, 200, 255, 0.22));
}
body.theme-aurora .panel::after {
background: radial-gradient(circle, rgba(94, 231, 255, 0.16), transparent 66%);
}
body.theme-aurora .panel-title::before {
background: linear-gradient(180deg, #cfe0ff, #8aa4ff 55%, #5e7cff);
box-shadow: 0 0 18px rgba(130, 160, 255, 0.65);
}
body.theme-aurora .queue-item {
background: linear-gradient(90deg, rgba(150, 180, 255, 0.14), rgba(94, 231, 255, 0.10));
border: 1px solid rgba(150, 180, 255, 0.20);
}
body.theme-aurora .queue-item.admin {
background: linear-gradient(90deg, rgba(122, 140, 255, 0.30), rgba(94, 231, 255, 0.20));
border-color: rgba(160, 190, 255, 0.42);
box-shadow: 0 0 20px rgba(122, 140, 255, 0.24), inset 0 1px 0 rgba(220, 235, 255, 0.20);
}
body.theme-aurora .queue-item .idx { color: #cfe0ff; }
body.theme-aurora .queue-item .pts { color: #cfe0ff; }
body.theme-aurora .group-card {
background: linear-gradient(145deg, rgba(150, 180, 255, 0.16), rgba(94, 231, 255, 0.08));
border: 1px solid rgba(150, 180, 255, 0.20);
box-shadow: inset 0 1px 0 rgba(220, 235, 255, 0.14);
}
body.theme-aurora .group-card.uses-nahida {
background:
radial-gradient(circle at 72% 36%, rgba(122, 200, 255, 0.28), transparent 48%),
linear-gradient(145deg, rgba(150, 180, 255, 0.18), rgba(94, 231, 255, 0.12));
border-color: rgba(160, 210, 255, 0.38);
box-shadow:
inset 0 1px 0 rgba(220, 235, 255, 0.16),
inset -12px 8px 24px rgba(80, 120, 255, 0.10);
}
body.theme-aurora .group-card-name {
text-shadow: 0 2px 8px rgba(4, 6, 24, 0.5);
}
body.theme-aurora .progress-bar {
background: rgba(14, 18, 46, 0.5);
border: 1px solid rgba(150, 180, 255, 0.22);
}
body.theme-aurora .progress-fill {
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.32), transparent 30%),
linear-gradient(90deg, #cfe0ff, #8aa4ff 45%, #5e7cff);
box-shadow: 0 0 20px rgba(130, 160, 255, 0.5);
}
body.theme-aurora .perf-card {
background: linear-gradient(145deg, rgba(150, 180, 255, 0.13), rgba(94, 231, 255, 0.06));
border: 1px solid rgba(150, 180, 255, 0.18);
}
body.theme-aurora .tutorial-title::after {
color: rgba(230, 238, 255, 0.74);
background: rgba(150, 180, 255, 0.12);
border: 1px solid rgba(150, 180, 255, 0.22);
}
body.theme-aurora .tutorial-dot.active {
background: #cfe0ff;
box-shadow: 0 0 10px rgba(150, 180, 255, 0.55);
}
body.theme-aurora .tutorial-text {
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.5), 0 0 16px rgba(150, 180, 255, 0.22);
}
</style>
</head>
<body>
<div id="viewport">
<div id="canvas">
<div id="frame">
<div id="custom-bg"></div>
<div class="deco deco-ribbon"></div>
<div class="deco deco-blob one"></div>
<div class="deco deco-blob two"></div>
<div class="deco deco-blob three"></div>
<div class="deco deco-leaf leaf-a"></div>
<div class="deco deco-leaf leaf-b"></div>
<div class="deco deco-leaf leaf-c"></div>
<div class="deco deco-star star-a"></div>
<div class="deco deco-star star-b"></div>
<div class="deco deco-star star-c"></div>
</div>
<!-- game-cutout 放在 #frame 外面, 不受 clip-path 裁剪, 保证镂空区域完全透明(OBS 穿透) -->
<div id="game-cutout"></div>
<!-- 游戏画面左侧:可用配置组 -->
<section class="panel available-groups-panel">
<div class="panel-title">可用配置组</div>
<div id="group-carousel-viewport" class="group-carousel-viewport">
<div id="group-carousel-track" class="group-carousel-track"></div>
</div>
<div id="group-carousel-empty" class="group-carousel-empty" hidden>暂无可展示的配置组</div>
</section>
<!-- 左上:运行进度 + 排队状态 -->
<section class="panel top-panel">
<div class="progress-area">
<div class="panel-title">运行进度</div>
<div class="task-name-row">
<img id="task-nahida-icon" class="task-nahida-icon" src="/uploads/nahida_collect.webp" alt="纳西妲采集" title="该配置组使用纳西妲采集" hidden>
<div id="task-name" class="task-name">无任务运行</div>
</div>
<div class="progress-bar">
<div id="progress-fill" class="progress-fill"></div>
</div>
<div class="task-meta">
<span id="task-operator">操作人:--</span>
<span id="task-points">剩余积分:--</span>
</div>
</div>
<div class="queue-area">
<div class="panel-title">排队状态 <span id="queue-count" class="badge">0</span></div>
<div id="queue-list" class="queue-list">
<div class="empty">队列为空</div>
</div>
</div>
</section>
<!-- 左上右侧:性能监测 -->
<section class="panel performance-panel">
<div class="panel-title">性能监测</div>
<div class="perf-grid">
<div class="perf-card">
<div id="perf-cpu-ring" class="perf-ring"><span id="perf-cpu" class="perf-value">--%</span></div>
<div class="perf-label">CPU</div>
</div>
<div class="perf-card">
<div id="perf-mem-ring" class="perf-ring"><span id="perf-mem" class="perf-value">--%</span></div>
<div class="perf-label">内存</div>
</div>
<div class="perf-card">
<div id="perf-gpu-ring" class="perf-ring"><span id="perf-gpu" class="perf-value">--%</span></div>
<div class="perf-label">GPU</div>
</div>
</div>
</section>
<!-- 左下:独立下播倒计时 -->
<section class="panel live-end-panel">
<div class="panel-title">下播倒计时</div>
<div class="live-end-countdown">
<div class="countdown-stage">
<div id="live-end-countdown" class="countdown-value inactive">--:--:--</div>
<div id="live-end-target" class="countdown-target">正在读取关播计划</div>
<div id="live-end-day" class="countdown-day">系统定时</div>
</div>
</div>
</section>
<!-- 左下:当前音乐 + 播放列表 -->
<section class="panel music-panel">
<div class="current-music">
<div class="panel-title">当前音乐</div>
<div class="cover-row">
<div id="music-cover" class="cover"></div>
<div class="music-info">
<div id="music-title" class="title">未配置</div>
<div id="music-artist" class="artist">等待数据源</div>
<div id="music-note" class="music-note">正在同步播放器</div>
</div>
</div>
</div>
<div class="music-extra">
<div class="playlist">
<div class="panel-title">点歌队列</div>
<ul id="playlist" class="playlist-list">
<li class="empty">暂无点歌</li>
</ul>
</div>
</div>
</section>
<!-- 左下右侧:键鼠输入 -->
<section class="panel input-panel">
<div class="input-visualizer">
<div class="panel-title">键鼠输入</div>
<div class="input-stage">
<div class="mouse-card">
<div class="mouse-orbit"></div>
<div class="mouse-body">
<div id="mouse-left" class="mouse-btn left"></div>
<div id="mouse-right" class="mouse-btn right"></div>
</div>
<div id="mouse-trail" class="mouse-trail"></div>
</div>
<div class="keys-card">
<div class="key-row">
<div id="key-q" class="keycap">Q</div>
<div id="key-w" class="keycap">W</div>
<div id="key-e" class="keycap">E</div>
<div id="key-r" class="keycap">R</div>
</div>
<div class="key-row">
<div id="key-a" class="keycap">A</div>
<div id="key-s" class="keycap">S</div>
<div id="key-d" class="keycap">D</div>
<div id="key-f" class="keycap">F</div>
</div>
<div class="key-row">
<div id="key-space" class="keycap space">SPACE</div>
</div>
</div>
</div>
</div>
</section>
<!-- 右上:BGI 日志 -->
<section class="panel log-panel">
<div class="panel-title log-title">
<span>BGI 日志</span>
<div class="status"><span id="bgi-dot" class="dot"></span><span id="bgi-status">未运行</span></div>
</div>
<div id="log-box" class="log-box"></div>
</section>
<!-- 右侧中部:教学轮播 -->
<section class="panel tutorial-panel">
<div class="panel-title tutorial-title">直播间使用说明</div>
<div class="tutorial-stage">
<div id="tutorial-text" class="tutorial-text">发送“签到”领取积分,发送“排队”加入队伍</div>
</div>
<div id="tutorial-dots" class="tutorial-dots" aria-hidden="true"></div>
</section>
<!-- 右下:直播间弹幕 -->
<section class="panel danmu-panel">
<div class="panel-title">直播间弹幕</div>
<div id="danmu-box" class="danmu-box">
<div class="empty">等待弹幕...</div>
</div>
</section>
<!-- 礼物特效层(最顶层,不拦截事件) -->
<div id="gift-layer">
<canvas id="gift-canvas"></canvas>
<div id="gift-flash" class="gift-flash"></div>
<div id="gift-banner" class="gift-banner tier-1" style="display:none">
<span class="gb-name"></span><span class="gb-gift"></span>
</div>
</div>
</div>
</div>
<script>
function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;"
}[c]));
}
function fmt(sec) {
const m = Math.floor(sec / 60), s = Math.abs(sec) % 60;
return String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
}
function fmtMin(sec) {
const m = Math.floor(sec / 60);
return m + ":" + String(sec % 60).padStart(2, "0");
}
const tutorialSlides = [
"发送“签到”领取积分,发送“排队”加入队伍",
"成为队首后,请在90秒内发送“上号”",
"发送“上号”后,请在4分钟内完成扫码登录",
"扫码后,请在1分钟内发送“是”或“不是”确认账号",
"发送“执行 配置组名称”开始任务",
"发送“积分”查询积分,发送“队列”查看排队情况",
"发送“退出”离开队伍,发送“点歌 歌名”点歌"
];
let tutorialIndex = 0;
function renderTutorialDots() {
const dots = document.getElementById("tutorial-dots");
if (!dots) return;
dots.innerHTML = tutorialSlides.map((_, index) =>
'<span class="tutorial-dot' + (index === tutorialIndex ? ' active' : '') + '"></span>'
).join("");
}
function showTutorialSlide(index, animate = true) {
const text = document.getElementById("tutorial-text");
if (!text) return;
tutorialIndex = (index + tutorialSlides.length) % tutorialSlides.length;
const update = () => {
const slide = tutorialSlides[tutorialIndex];
text.textContent = slide;
text.classList.toggle("long", slide.length > 20 && slide.length < 24);
text.classList.toggle("extra-long", slide.length >= 24);
renderTutorialDots();
requestAnimationFrame(() => text.classList.remove("switching"));
};
if (!animate) {
update();
return;
}
text.classList.add("switching");
window.setTimeout(update, 450);
}
function startTutorialCarousel() {
showTutorialSlide(0, false);
window.setInterval(() => showTutorialSlide(tutorialIndex + 1), 6500);
}
function simplifyLogLine(line) {
let text = String(line || "");
let time = "";
// BetterGI 原生日志: [21:46:36.567] [INF] Logger.Name 交互或拾取:"霓裳花"
let m = text.match(/^\[(\d{2}:\d{2}:\d{2})(?:\.\d+)?\]\s*(?:\[[A-Z]+\]\s*)?(.*)$/);
if (m) {
time = m[1];
text = m[2] || "";
} else {
// 本项目日志: 21:46:36 [INFO] [日志监控] ...
m = text.match(/^(\d{2}:\d{2}:\d{2})(?:\.\d+)?\s*(?:\[[A-Z]+\]\s*)?(.*)$/);
if (m) {
time = m[1];
text = m[2] || "";
}
}
text = text.trim();
// 去掉常见 logger 全限定名,只保留真正消息内容。
text = text.replace(/^(?:[A-Za-z_][\w$]*\.)+[A-Za-z_][\w$]*\s+/, "");
// 兼容 AutoPickTrigger 后面没有空格或格式略有变化的情况。
text = text.replace(/^BetterGenshinImpact\.[\w.]+\s*/, "");
// 前台只保留秒级时间,不显示毫秒、级别、logger 名。
return time ? ("[" + time + "] " + text) : text;
}
function simplifyLogLines(lines) {
const result = [];
const arr = Array.isArray(lines) ? lines : [];
for (let i = 0; i < arr.length; i++) {
const current = simplifyLogLine(arr[i]);
// 有些 BetterGI 日志会把“时间/级别/logger”和正文拆成两行,前台合并成一行显示。
if (/^\[\d{2}:\d{2}:\d{2}\]\s*$/.test(current) && i + 1 < arr.length) {
const next = simplifyLogLine(arr[i + 1]).trim();
if (next && !/^\[\d{2}:\d{2}:\d{2}\]/.test(next)) {
result.push({raw: String(arr[i] || "") + " " + String(arr[i + 1] || ""), text: current + next});
i++;
continue;
}
}
result.push({raw: String(arr[i] || ""), text: current});
}
return result.filter(x => x.text.trim());
}
function colorLog(line) {
const raw = typeof line === "string" ? line : String((line && line.raw) || "");
const text = typeof line === "string" ? simplifyLogLine(line) : String((line && line.text) || "");
if (raw.includes("[ERR]") || text.includes("Error") || text.includes("Exception")) return "log-err";
if (raw.includes("[WRN]") || text.includes("传送完成") || text.includes("返回主界面")) return "log-tp";
if (text.includes("开始连续执行") || text.includes("执行结束")) return "log-inf";
if (text.includes("加载完成") || text.includes("初始化")) return "log-blue";
if (text.includes("动作") || text.includes("战斗") || text.includes("攻击")) return "log-fight";
if (text.includes("自动拾取") || text.includes("交互或拾取")) return "log-pick";
if (raw.includes("[DBG]")) return "log-dbg";
return "";
}
let musicState = {playing: false, current: null, playlist: [], requests: []};
let availableGroups = [];
const fallbackSystemScheduleConfig = {
live_start_time: "09:00",
live_end_time: "23:00"
};
let systemScheduleConfig = {...fallbackSystemScheduleConfig};
let inputState = {keys: {}, mouse: {left: false, right: false, direction: "idle"}};
let performanceState = {cpu: null, memory: null, gpu: null, gpu_available: false};
let lastCoverKey = "";
async function fetchState() {
try {
const r = await fetch("/api/state");
const d = await r.json();
renderTask(d.current_group, d.group_start_time, d.current_admin, d.users, d.default_running, d.bgi_running, d.current_group_uses_nahida_collect);
renderQueue(d.queue, d.users, d.current_admin);
initGiftFxBaseline(d.recent_gifts);
giftFx.sync(d.recent_gifts);
} catch (e) {
console.error("state", e);
}
}
function shortName(name, maxChars = 8) {
const arr = Array.from(String(name || ""));
return arr.length > maxChars ? arr.slice(0, maxChars).join("") + "…" : arr.join("");
}
function renderTask(group, startTime, adminUid, users, isDefault, bgiRunning, usesNahidaCollect) {
const nameEl = document.getElementById("task-name");
const nahidaIcon = document.getElementById("task-nahida-icon");
const pointsEl = document.getElementById("task-points");
const opEl = document.getElementById("task-operator");
const fill = document.getElementById("progress-fill");
const dot = document.getElementById("bgi-dot");
const status = document.getElementById("bgi-status");
if (bgiRunning) {
dot.classList.add("on");
status.textContent = "运行中";
status.style.color = "#9df0bb";
} else {
dot.classList.remove("on");
status.textContent = "未运行";
status.style.color = "var(--text-dim)";
}
if (!group) {
nahidaIcon.hidden = true;
nameEl.textContent = "无任务运行";
nameEl.style.color = "var(--text-dim)";
opEl.textContent = "操作人:--";
pointsEl.textContent = "剩余积分:--";
fill.style.width = "0%";
return;
}
nahidaIcon.hidden = !usesNahidaCollect;
nameEl.textContent = group + (isDefault ? " (默认)" : "");
nameEl.style.color = "var(--cream)";
if (startTime) {
const sec = Math.floor((Date.now() - new Date(startTime).getTime()) / 1000);
fill.style.width = Math.min(sec / 600 * 100, 100) + "%";
} else fill.style.width = "0%";
if (adminUid && users && users[adminUid]) {
const u = users[adminUid];
opEl.textContent = "操作人:" + shortName(u.uname, 8);
pointsEl.textContent = "剩余积分:" + (Number.isFinite(Number(u.points)) ? Number(u.points) : 0) + "🌟";
} else if (isDefault) {
opEl.textContent = "操作人:系统默认";
pointsEl.textContent = "剩余积分:--";
} else {
opEl.textContent = "操作人:--";
pointsEl.textContent = "剩余积分:--";
}
}
function renderQueue(queue, users, adminUid) {
const el = document.getElementById("queue-list");
const cnt = document.getElementById("queue-count");
const total = (queue && queue.length) || 0;
cnt.textContent = total;
if (!queue || total === 0) {
el.innerHTML = '<div class="empty">队列为空</div>';
return;
}
let html = queue.slice(0, 6).map((uid, i) => {
const u = (users && users[uid]) || {uname: "用户" + uid, points: 0};
const isAdmin = uid === adminUid;
return '<div class="queue-item' + (isAdmin ? ' admin' : '') + '">' +
'<span class="name"><span class="idx">#' + (i + 1) + '</span>' + esc(shortName(u.uname, 8)) + '</span>' +
'<span class="pts">' + (Number.isFinite(Number(u.points)) ? Number(u.points) : 0) + '🌟</span></div>';
}).join("");
if (total > 6) html += '<div class="queue-item queue-more">+' + (total - 6) + '</div>';
el.innerHTML = html;
}
async function fetchLog() {
try {
const r = await fetch("/api/bgi-log?lines=80");
const d = await r.json();
const el = document.getElementById("log-box");
el.innerHTML = simplifyLogLines(d.lines).map(item => {
const cls = colorLog(item);
return '<div class="log-line' + (cls ? ' ' + cls : '') + '">' + esc(item.text) + '</div>';
}).join("");
el.scrollTop = el.scrollHeight;
} catch (e) {
console.error("log", e);
}
}
async function fetchDanmu() {
try {
const r = await fetch("/api/danmu?limit=60");
const d = await r.json();
const el = document.getElementById("danmu-box");
const msgs = d.messages || [];
if (msgs.length === 0) {
el.innerHTML = '<div class="empty">等待弹幕...</div>';
return;
}
el.innerHTML = msgs.map(m => {
return '<div class="danmu-item"><span class="danmu-name">' + esc(m.uname || "?") + ':</span>' +
'<span class="danmu-text">' + esc(m.text || "") + '</span></div>';
}).join("");
el.scrollTop = el.scrollHeight;
} catch (e) {
console.error("danmu", e);
}
}
async function fetchMusic() {
try {
const r = await fetch("/api/music");
const d = await r.json();
musicState = d || {playing: false, current: null, playlist: [], requests: []};
renderMusic();
} catch (e) {
console.error("music", e);
}
}
function groupCardHtml(group) {
const usesNahida = !!group.uses_nahida_collect;
const nahidaTitle = usesNahida
? '使用纳西妲采集' + (group.nahida_route_count ? '' + group.nahida_route_count + '条路线)' : '')
: '';
return '<div class="group-card' + (usesNahida ? ' uses-nahida' : '') + '"' +
(nahidaTitle ? ' title="' + esc(nahidaTitle) + '"' : '') + '>' +
'<img class="group-card-image" src="' + esc(group.image || "") + '" alt="' + esc(group.name || "") + '">' +
(usesNahida ? '<img class="group-card-nahida" src="/uploads/nahida_collect.webp" alt="纳西妲采集">' : '') +
'<div class="group-card-name" title="' + esc(group.name || "") + '">' + esc(group.name || "未命名") + '</div>' +
'</div>';
}
function startGroupCarousel() {
const track = document.getElementById("group-carousel-track");
const viewport = document.getElementById("group-carousel-viewport");
const empty = document.getElementById("group-carousel-empty");
if (!track || !viewport || !empty) return;
track.classList.remove("smooth-scroll");
track.style.removeProperty("--group-scroll-distance");
track.style.removeProperty("--group-scroll-duration");
if (!availableGroups.length) {
track.innerHTML = "";
viewport.hidden = true;
empty.hidden = false;
return;
}
viewport.hidden = false;
empty.hidden = true;
const n = availableGroups.length;
if (n <= 2) {
track.innerHTML = availableGroups.map(groupCardHtml).join("");
return;
}
const rowCount = Math.ceil(n / 2);
// 复制一份用于无缝循环:第二份首卡与第一份首卡之间的真实距离即一个完整循环周期。
track.innerHTML = availableGroups.concat(availableGroups).map(groupCardHtml).join("");
// 用真实渲染距离计算循环周期,避免硬编码卡片高度/间距导致“播完跳回开头”。
const cards = track.querySelectorAll(".group-card");
let distance = 0;
if (cards.length > n) {
distance = cards[n].getBoundingClientRect().top - cards[0].getBoundingClientRect().top;
}
if (distance <= 0) {
distance = rowCount * 89;
}
const duration = Math.max(45, rowCount * 2.1);
track.style.setProperty("--group-scroll-distance", "-" + distance.toFixed(2) + "px");
track.style.setProperty("--group-scroll-duration", duration.toFixed(1) + "s");
requestAnimationFrame(() => track.classList.add("smooth-scroll"));
}
async function fetchAvailableGroups() {
try {
const r = await fetch("/api/groups?t=" + Date.now());
if (!r.ok) throw new Error("HTTP " + r.status);
const data = await r.json();
const groups = Array.isArray(data) ? data : data.groups;
availableGroups = Array.isArray(groups) ? groups.filter(x => x && x.name && x.image) : [];
startGroupCarousel();
} catch (e) {
console.error("available-groups", e);
availableGroups = [];
startGroupCarousel();
}
}
function getNextStopPushTime(now, hhmm) {
const match = /^(\d{1,2}):(\d{2})$/.exec(String(hhmm || "").trim());
if (!match) return null;
const hour = Number(match[1]);
const minute = Number(match[2]);
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
return null;
}
const target = new Date(now.getTime());
target.setHours(hour, minute, 0, 0);
if (target.getTime() <= now.getTime()) target.setDate(target.getDate() + 1);
return target;
}
function renderLiveEndCountdown(now = new Date()) {
const valueEl = document.getElementById("live-end-countdown");
const targetEl = document.getElementById("live-end-target");
const dayEl = document.getElementById("live-end-day");
if (!valueEl || !targetEl || !dayEl) return;
const configuredTime = String(
systemScheduleConfig.live_end_time || systemScheduleConfig.bilibili_stop_push_time || ""
).trim();
const startTime = String(
systemScheduleConfig.live_start_time || systemScheduleConfig.bilibili_push_time || ""
).trim();
const toMinutes = value => {
const match = /^(\d{1,2}):(\d{2})$/.exec(value);
if (!match) return null;
const hour = Number(match[1]), minute = Number(match[2]);
return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 ? hour * 60 + minute : null;
};
const startMinutes = toMinutes(startTime);
const endMinutes = toMinutes(configuredTime);
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const liveNow = startMinutes !== null && endMinutes !== null && startMinutes !== endMinutes && (
startMinutes < endMinutes
? nowMinutes >= startMinutes && nowMinutes < endMinutes
: nowMinutes >= startMinutes || nowMinutes < endMinutes
);
if (!liveNow) {
valueEl.textContent = "--:--:--";
valueEl.classList.add("inactive");
targetEl.textContent = "当前未开播";
dayEl.textContent = startTime && configuredTime ? (startTime + " - " + configuredTime) : "请设置直播时间";
return;
}
const target = getNextStopPushTime(now, configuredTime);
if (!target) {
valueEl.textContent = "--:--:--";
valueEl.classList.add("inactive");
targetEl.textContent = "关播时间配置无效";
dayEl.textContent = configuredTime || "未设置时间";
return;
}
const remainingSec = Math.max(0, Math.ceil((target.getTime() - now.getTime()) / 1000));
const hours = Math.floor(remainingSec / 3600);
const minutes = Math.floor((remainingSec % 3600) / 60);
const seconds = remainingSec % 60;
valueEl.textContent = String(hours).padStart(2, "0") + ":" + String(minutes).padStart(2, "0") + ":" + String(seconds).padStart(2, "0");
valueEl.classList.remove("inactive");
targetEl.textContent = "计划关播 " + String(target.getHours()).padStart(2, "0") + ":" + String(target.getMinutes()).padStart(2, "0");
dayEl.textContent = target.toDateString() === now.toDateString() ? "今天" : "次日";
}
async function fetchSystemScheduleConfig() {
try {
const r = await fetch("/api/system-schedule-config");
if (!r.ok) throw new Error("HTTP " + r.status);
systemScheduleConfig = await r.json() || {};
try {
localStorage.setItem("liveEndScheduleConfig", JSON.stringify(systemScheduleConfig));
} catch (_) {
}
renderLiveEndCountdown();
} catch (e) {
console.error("system-schedule-config", e);
// 直播姬浏览器源偶发无法请求API时,优先使用最近一次成功配置;首次加载则使用页面构建时的兜底值。
let cached = null;
try {
cached = JSON.parse(localStorage.getItem("liveEndScheduleConfig") || "null");
} catch (_) {
}
systemScheduleConfig = cached && typeof cached === "object"
? cached
: {...fallbackSystemScheduleConfig};
renderLiveEndCountdown();
const dayEl = document.getElementById("live-end-day");
if (dayEl) dayEl.textContent += " · 本地配置";
}
}
async function fetchInputState() {
try {
const r = await fetch("/api/input_state");
inputState = await r.json();
renderInputState();
} catch (e) {
console.error("input", e);
}
}
async function fetchPerformance() {
try {
const r = await fetch("/api/performance");
performanceState = await r.json();
renderPerformance();
} catch (e) {
console.error("performance", e);
}
}
async function fetchFrontendConfig() {
try {
const r = await fetch("/api/frontend-config");
const cfg = await r.json();
const root = document.documentElement;
if (cfg.background_image) root.style.setProperty("--custom-bg-image", "url('" + cfg.background_image + "')");
else root.style.setProperty("--custom-bg-image", "none");
root.style.setProperty("--custom-bg-opacity", String(cfg.background_opacity ?? 0.65));
root.style.setProperty("--custom-bg-blur", String(cfg.background_blur ?? 0) + "px");
const fit = cfg.background_fit === "fill" ? "100% 100%" : (cfg.background_fit || "cover");
root.style.setProperty("--custom-bg-size", fit);
// 主题预设:classic 为默认(无额外类),其它主题通过 body.theme-<id> 生效
const themeClasses = Array.from(document.body.classList).filter((c) => c.startsWith("theme-"));
themeClasses.forEach((c) => document.body.classList.remove(c));
const theme = cfg.theme || "classic";
if (theme && theme !== "classic") document.body.classList.add("theme-" + theme);
} catch (e) {
console.error("frontend-config", e);
}
}
function setActive(id, on) {
const el = document.getElementById(id);
if (!el) return;
el.classList.toggle("active", !!on);
}
function renderPerformance() {
const setPerf = (name, value) => {
const textEl = document.getElementById("perf-" + name);
const ringEl = document.getElementById("perf-" + name + "-ring");
const n = Number(value);
if (!Number.isFinite(n)) {
if (textEl) textEl.textContent = "--%";
if (ringEl) ringEl.style.setProperty("--pct", 0);
return;
}
const pct = Math.max(0, Math.min(100, n));
if (textEl) textEl.textContent = pct.toFixed(pct >= 10 ? 0 : 1) + "%";
if (ringEl) ringEl.style.setProperty("--pct", pct);
};
setPerf("cpu", performanceState.cpu);
setPerf("mem", performanceState.memory);
setPerf("gpu", performanceState.gpu);
}
function renderInputState() {
const keys = inputState.keys || {};
["q", "w", "e", "r", "a", "s", "d", "f", "space"].forEach(k => setActive("key-" + k, keys[k]));
const mouse = inputState.mouse || {};
setActive("mouse-left", mouse.left);
setActive("mouse-right", mouse.right);
const trail = document.getElementById("mouse-trail");
if (!trail) return;
const sx = Number(mouse.smooth_dx || 0);
const sy = Number(mouse.smooth_dy || 0);
const mag = Math.max(0, Math.min(1, Number(mouse.magnitude || 0)));
if (!mag || (mouse.direction || "idle") === "idle") {
trail.classList.remove("active");
trail.style.transform = "translate(0, 0) scale(0.55)";
} else {
const len = Math.min(34, Math.max(12, mag * 36));
const norm = Math.max(1, Math.hypot(sx, sy));
const x = sx / norm * len;
const y = sy / norm * len;
trail.classList.add("active");
trail.style.transform = "translate(" + x.toFixed(1) + "px, " + y.toFixed(1) + "px) scale(" + (0.72 + mag * 0.42).toFixed(2) + ")";
}
}
function renderMusic() {
const cur = musicState.current || {};
const coverEl = document.getElementById("music-cover");
const titleEl = document.getElementById("music-title");
const artistEl = document.getElementById("music-artist");
const noteEl = document.getElementById("music-note");
const listEl = document.getElementById("playlist");
const monitor = musicState.monitor || {};
const updatedAtMs = Number(monitor.updated_at || 0) * 1000;
const monitorStale = !updatedAtMs || Date.now() - updatedAtMs > 10000;
const monitorOffline = monitor.online === false || monitorStale;
if (monitorOffline) {
titleEl.textContent = "音乐源离线";
artistEl.textContent = "等待网易云 SMTC 状态";
coverEl.innerHTML = "♪";
lastCoverKey = "";
noteEl.textContent = "监听未运行或状态已过期";
} else if (!cur.title && !cur.artist) {
titleEl.textContent = "未配置";
artistEl.textContent = "等待数据源";
coverEl.innerHTML = "♪";
lastCoverKey = "";
noteEl.textContent = "等待音乐源";
} else {
titleEl.textContent = cur.title || "未知歌曲";
artistEl.textContent = cur.artist || "未知艺人";
const rawCover = cur.cover ? String(cur.cover) : "";
const coverKey = rawCover + "|" + (cur.cover_hash || "");
if (rawCover && coverKey !== lastCoverKey) {
// 预加载新图, 加载成功才替换 DOM, 避免闪烁
const sep = rawCover.includes("?") ? "&" : "?";
const imgSrc = rawCover + sep + "v=" + encodeURIComponent(cur.cover_hash || "stable");
const img = new Image();
img.onload = () => {
coverEl.innerHTML = '<img src="' + esc(imgSrc) + '" alt="">';
lastCoverKey = coverKey;
};
img.onerror = () => { /* 加载失败保留旧图, 不清空 */
};
img.src = imgSrc;
} else if (!rawCover && !lastCoverKey) {
// 从未有封面才显示音符占位; 已有封面则保留旧图
coverEl.innerHTML = "♪";
}
noteEl.textContent = musicState.playing ? "正在播放" : "已暂停";
}
const playlist = musicState.requests || musicState.playlist || [];
if (playlist.length === 0) {
listEl.innerHTML = '<li class="empty">暂无点歌</li>';
} else {
listEl.innerHTML = playlist.map((song, i) => {
const title = song.name || song.title || "未知歌曲";
const durationSec = Math.max(0, Number(song.duration_sec || 0));
const duration = durationSec > 0
? Math.floor(durationSec / 60) + ":" + String(Math.floor(durationSec % 60)).padStart(2, "0")
: "--:--";
const by = song.uname ? ("点歌:" + song.uname) : (song.artist || "-");
return '<li><span class="title">#' + (i + 1) + ' ' + esc(title) + ' [' + esc(duration) + ']</span><span class="by">' + esc(by) + '</span></li>';
}).join("");
}
}
// ===== 礼物特效引擎 =====
// 档位: value(折算人民币) >=100→4, >=10→3, >0→2, 免费→1;礼物数量 num 放大粒子规模。
const giftFx = {
particles: [],
rafId: null,
lastTs: 0,
bannerTimer: null,
queue: [],
playing: false,
tierOf(g) {
const v = Number(g.value || 0);
if (v >= 100) return 4;
if (v >= 10) return 3;
if (v > 0) return 2;
return 1;
},
canvasSetup() {
const cv = document.getElementById("gift-canvas");
const layer = document.getElementById("gift-layer");
if (!cv || !layer) return null;
const rect = layer.getBoundingClientRect();
// 按 1920x1080 逻辑分辨率设置,CSS 已随 #canvas 缩放
cv.width = 1920;
cv.height = 1080;
return cv.getContext("2d");
},
spawnBurst(cx, cy, count, colors, power) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = (2 + Math.random() * 6) * power;
this.particles.push({
x: cx, y: cy,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 2 * power,
life: 1,
decay: 0.008 + Math.random() * 0.012,
size: 3 + Math.random() * 6 * power,
color: colors[Math.floor(Math.random() * colors.length)],
shape: Math.random() < 0.3 ? "star" : "circle",
spin: Math.random() * Math.PI * 2,
spinV: (Math.random() - 0.5) * 0.2,
});
}
this.startLoop();
},
startLoop() {
if (this.rafId) return;
const ctx = this.canvasSetup();
if (!ctx) return;
const step = () => {
ctx.clearRect(0, 0, 1920, 1080);
this.particles = this.particles.filter(p => p.life > 0);
for (const p of this.particles) {
p.x += p.vx;
p.y += p.vy;
p.vy += 0.12;
p.vx *= 0.985;
p.life -= p.decay;
p.spin += p.spinV;
ctx.save();
ctx.globalAlpha = Math.max(0, p.life);
ctx.fillStyle = p.color;
if (p.shape === "star") {
ctx.translate(p.x, p.y);
ctx.rotate(p.spin);
const s = p.size;
ctx.beginPath();
for (let k = 0; k < 10; k++) {
const r = k % 2 === 0 ? s : s * 0.45;
const a = (Math.PI / 5) * k;
ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r);
}
ctx.closePath();
ctx.fill();
} else {
ctx.beginPath();
ctx.arc(p.x, p.y, p.size / 2, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
if (this.particles.length === 0) {
this.rafId = null;
ctx.clearRect(0, 0, 1920, 1080);
return;
}
this.rafId = requestAnimationFrame(step);
};
this.rafId = requestAnimationFrame(step);
},
showBanner(g, tier) {
const banner = document.getElementById("gift-banner");
if (!banner) return;
banner.className = "gift-banner tier-" + tier;
banner.style.display = "flex";
banner.querySelector(".gb-name").textContent = shortName(g.uname || "热心观众", 10);
banner.querySelector(".gb-gift").textContent =
"送出 " + (g.gift_name || "礼物") + (g.num > 1 ? " ×" + g.num : "");
requestAnimationFrame(() => banner.classList.add("show"));
clearTimeout(this.bannerTimer);
const holdMs = 1800 + tier * 600;
this.bannerTimer = setTimeout(() => {
banner.classList.remove("show");
banner.classList.add("hide");
setTimeout(() => { banner.style.display = "none"; banner.classList.remove("hide"); }, 450);
}, holdMs);
},
flash(tier) {
if (tier < 3) return;
const el = document.getElementById("gift-flash");
if (!el) return;
el.classList.add("on");
setTimeout(() => el.classList.remove("on"), tier >= 4 ? 500 : 300);
},
play(g) {
const tier = this.tierOf(g);
const num = Math.min(Number(g.num || 1), 99);
// 基础粒子数按档位递增,数量 num 再放大(对数压缩避免刷屏)
const base = [0, 20, 45, 90, 160][tier];
const count = Math.round(base * (1 + Math.log10(num)));
const power = 0.7 + tier * 0.25;
const palette = {
1: ["#9fe1cb", "#eafff2", "#5dcaa5"],
2: ["#ffd66e", "#fff3c4", "#ef9f27"],
3: ["#ff82a0", "#ffe9ec", "#ed93b1", "#ffd66e"],
4: ["#dcA0ff", "#ffffff", "#afa9ec", "#ffd66e", "#ff82a0"],
}[tier];
this.showBanner(g, tier);
this.flash(tier);
// 中心主爆点
this.spawnBurst(960, 420, count, palette, power);
// 高档位追加侧翼爆点,礼物越大场面越足
if (tier >= 2) this.spawnBurst(560, 560, Math.round(count * 0.4), palette, power * 0.8);
if (tier >= 3) this.spawnBurst(1360, 560, Math.round(count * 0.4), palette, power * 0.8);
if (tier >= 4) {
setTimeout(() => this.spawnBurst(960, 300, Math.round(count * 0.6), palette, power), 350);
setTimeout(() => this.spawnBurst(700, 480, Math.round(count * 0.4), palette, power * 0.9), 700);
setTimeout(() => this.spawnBurst(1220, 480, Math.round(count * 0.4), palette, power * 0.9), 700);
}
},
enqueue(g) {
this.queue.push(g);
this.pump();
},
pump() {
if (this.playing || this.queue.length === 0) return;
this.playing = true;
const g = this.queue.shift();
this.play(g);
const tier = this.tierOf(g);
setTimeout(() => {
this.playing = false;
this.pump();
}, 1200 + tier * 400);
},
// 每轮 state 轮询调用:播放 ts 晚于上次记录的所有礼物
sync(gifts) {
if (!Array.isArray(gifts) || gifts.length === 0) return;
const fresh = gifts.filter(g => Number(g.ts || 0) > this.lastTs);
if (fresh.length === 0) return;
this.lastTs = Math.max(...gifts.map(g => Number(g.ts || 0)));
for (const g of fresh) this.enqueue(g);
},
};
// 首次加载时把已有礼物的最大 ts 设为基线,避免刷新页面重放旧礼物
function initGiftFxBaseline(gifts) {
if (giftFx.lastTs > 0 || !Array.isArray(gifts) || gifts.length === 0) return;
giftFx.lastTs = Math.max(...gifts.map(g => Number(g.ts || 0)));
}
function scaleCanvas() {
const canvas = document.getElementById("canvas");
const scale = Math.min(window.innerWidth / 1920, window.innerHeight / 1080);
canvas.style.transform = "scale(" + scale + ")";
}
startTutorialCarousel();
fetchFrontendConfig();
fetchState();
fetchLog();
fetchDanmu();
fetchMusic();
fetchAvailableGroups();
fetchSystemScheduleConfig();
renderLiveEndCountdown();
fetchInputState();
fetchPerformance();
setInterval(fetchState, 2000);
setInterval(fetchLog, 1500);
setInterval(fetchDanmu, 1500);
setInterval(fetchMusic, 5000);
setInterval(renderLiveEndCountdown, 1000);
setInterval(fetchSystemScheduleConfig, 60000);
setInterval(fetchInputState, 33);
setInterval(fetchPerformance, 2000);
scaleCanvas();
const params = new URLSearchParams(location.search);
if (params.get('preview') === '1') document.body.classList.add('preview');
if (params.get('frame') === '0') document.body.classList.add('frame-off');
if (params.get('glass') === '1') document.body.classList.add('glass-on');
scaleCanvas();
window.addEventListener("resize", scaleCanvas);
</script>
</body>
</html>