mirror of
https://github.com/deadcxap/bs-overlay.git
synced 2026-07-01 21:33:40 +03:00
1601 lines
62 KiB
HTML
1601 lines
62 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>BS+ Cyberpunk Overlay</title>
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&display=swap');
|
|
|
|
:root {
|
|
--neon-cyan: #0ff;
|
|
--neon-magenta: #f0f;
|
|
--neon-lime: #39ff14;
|
|
--neon-red: #ff3b3b;
|
|
--bg-glass: rgba(10, 10, 15, 0.65);
|
|
--border-glow: 0 0 10px rgba(0, 255, 255, 0.3);
|
|
--soft-text-outline: -1px -1px 0 rgba(0,0,0,0.65), 1px -1px 0 rgba(0,0,0,0.65), -1px 1px 0 rgba(0,0,0,0.65), 1px 1px 0 rgba(0,0,0,0.65), 0 1px 2px rgba(0,0,0,0.55);
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
padding: 0;
|
|
background: transparent;
|
|
font-family: 'Orbitron', sans-serif;
|
|
color: #fff;
|
|
overflow: hidden;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
}
|
|
|
|
/* === АНИМАЦИИ === */
|
|
@keyframes rainbow {
|
|
0% { filter: hue-rotate(0deg); }
|
|
100% { filter: hue-rotate(360deg); }
|
|
}
|
|
|
|
#app-container {
|
|
position: absolute;
|
|
transition: transform 0.3s ease;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
width: max-content;
|
|
max-width: 90vw;
|
|
}
|
|
|
|
.view-mode {
|
|
position: absolute;
|
|
transition: opacity 0.4s ease, transform 0.4s ease;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
width: max-content;
|
|
}
|
|
|
|
.view-mode.active {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
}
|
|
|
|
.glass-panel {
|
|
background: var(--bg-glass);
|
|
backdrop-filter: blur(6px);
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
border-radius: 12px;
|
|
padding: 12px;
|
|
box-shadow: var(--border-glow);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.panel-no-bg {
|
|
background: transparent !important;
|
|
backdrop-filter: none !important;
|
|
border-color: transparent !important;
|
|
box-shadow: none !important;
|
|
}
|
|
|
|
#header-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 15px;
|
|
}
|
|
|
|
/* === КОНТЕЙНЕРЫ ДЛЯ ИЗОБРАЖЕНИЙ === */
|
|
#cover-wrapper, #bl-avatar-wrapper {
|
|
position: relative;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
#cover-wrapper { width: 90px; height: 90px; }
|
|
#bl-avatar-wrapper { width: 75px; height: 75px; display: none; }
|
|
|
|
#cover {
|
|
width: 100%; height: 100%;
|
|
border-radius: 10px;
|
|
object-fit: cover;
|
|
display: block;
|
|
}
|
|
|
|
#bl-avatar {
|
|
width: 100%; height: 100%;
|
|
border-radius: 12px;
|
|
object-fit: cover;
|
|
display: block;
|
|
}
|
|
|
|
.avatar-glow-wrapper::after {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
#cover-wrapper::after { border-radius: 10px; border: 1px solid rgba(255,255,255,0.2); }
|
|
#bl-avatar-wrapper::after { border-radius: 12px; border: 2px solid rgba(255,255,255,0.2); }
|
|
|
|
.avatar-glow-wrapper.active-glow::after {
|
|
box-shadow: 0 0 10px var(--neon-cyan);
|
|
border-color: var(--neon-cyan);
|
|
animation: rainbow 10s linear infinite;
|
|
}
|
|
|
|
#text-block {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
width: 320px;
|
|
}
|
|
|
|
#title {
|
|
font-size: 22px;
|
|
font-weight: 900;
|
|
background: linear-gradient(45deg, var(--neon-cyan), #fff);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
white-space: normal;
|
|
word-wrap: break-word;
|
|
line-height: 1.2;
|
|
filter: drop-shadow(0 0 5px rgba(0,255,255,0.4));
|
|
}
|
|
|
|
#artist-mapper {
|
|
font-size: 14px;
|
|
color: #fff;
|
|
letter-spacing: 0.5px;
|
|
white-space: normal;
|
|
word-wrap: break-word;
|
|
text-shadow: var(--soft-text-outline);
|
|
}
|
|
|
|
#meta-line {
|
|
font-size: 13px;
|
|
color: #fff;
|
|
margin-top: 2px;
|
|
text-shadow: var(--soft-text-outline);
|
|
}
|
|
|
|
#meta-line span { margin-right: 12px; }
|
|
#difficulty {
|
|
font-weight: bold;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.difficulty-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
min-height: 24px;
|
|
padding: 2px 8px;
|
|
border-radius: 999px;
|
|
background: var(--diff-color, rgba(255,255,255,0.16));
|
|
color: #fff;
|
|
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.14), 0 1px 2px rgba(0,0,0,0.35);
|
|
text-shadow: var(--soft-text-outline);
|
|
}
|
|
|
|
.difficulty-badge.icon-only {
|
|
min-width: 22px;
|
|
padding: 2px 7px;
|
|
}
|
|
|
|
.difficulty-char-icon {
|
|
width: 18px;
|
|
height: 18px;
|
|
display: block;
|
|
color: #fff;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.difficulty-badge .difficulty-text {
|
|
display: inline-block;
|
|
line-height: 1;
|
|
}
|
|
|
|
#bsr-line {
|
|
margin-top: 4px;
|
|
display: flex;
|
|
gap: 15px;
|
|
}
|
|
|
|
#key, #map-date {
|
|
font-size: 15px;
|
|
color: #fff;
|
|
text-shadow: var(--soft-text-outline);
|
|
}
|
|
|
|
/* === ШИРОКИЙ PROGRESS BAR === */
|
|
#stats-row {
|
|
display: flex;
|
|
width: 100%;
|
|
align-self: stretch;
|
|
}
|
|
|
|
#progress-wrapper {
|
|
position: relative;
|
|
width: 100%;
|
|
min-width: 320px;
|
|
height: 18px;
|
|
background: rgba(0,0,0,0.6);
|
|
border-radius: 4px;
|
|
border: 1px solid rgba(0, 255, 255, 0.4);
|
|
box-shadow: 0 0 8px rgba(0, 255, 255, 0.3);
|
|
overflow: hidden;
|
|
display: flex;
|
|
}
|
|
|
|
#progress-fill {
|
|
width: 0%;
|
|
height: 100%;
|
|
background: linear-gradient(90deg, var(--neon-magenta), var(--neon-cyan));
|
|
transition: width 0.1s linear;
|
|
}
|
|
|
|
#time-prog {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
color: #fff;
|
|
text-shadow: 1px 1px 2px #000, -1px -1px 2px #000;
|
|
pointer-events: none;
|
|
}
|
|
|
|
#hp-bar-wrapper {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 12px;
|
|
background: rgba(0,0,0,0.6);
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
border: 1px solid rgba(255, 0, 255, 0.4);
|
|
box-shadow: 0 0 10px rgba(255, 0, 255, 0.2);
|
|
display: flex;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
#hp-bar-fill {
|
|
width: 100%;
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #8b0000, var(--neon-magenta));
|
|
transition: width 0.1s linear;
|
|
}
|
|
|
|
#hp-val {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
font-size: 10px;
|
|
font-weight: bold;
|
|
text-shadow: 1px 1px 2px #000, -1px -1px 2px #000;
|
|
pointer-events: none;
|
|
}
|
|
|
|
#bottom-stats {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
margin-top: 5px;
|
|
width: 100%;
|
|
}
|
|
|
|
.bottom-stat-row {
|
|
display: flex;
|
|
gap: 20px;
|
|
}
|
|
|
|
.stat-item {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 8px;
|
|
}
|
|
|
|
.stat-item .label {
|
|
font-size: 11px;
|
|
color: #d1d5db;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
text-shadow: 1px 1px 2px rgba(0,0,0,0.8);
|
|
}
|
|
|
|
.stat-item .val {
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
text-shadow: 1px 1px 3px #000;
|
|
}
|
|
|
|
#combo-val { color: var(--neon-cyan); text-shadow: 0 0 6px rgba(0, 255, 255, 0.45), -1px -1px 0 rgba(0,0,0,0.45), 1px -1px 0 rgba(0,0,0,0.45), -1px 1px 0 rgba(0,0,0,0.45), 1px 1px 0 rgba(0,0,0,0.45); }
|
|
#miss-val { color: var(--neon-red); text-shadow: 0 0 8px rgba(255, 59, 59, 0.6), 1px 1px 2px #000; }
|
|
|
|
.stat-item.acc-large .label { font-size: 14px; }
|
|
.stat-item.acc-large #acc-num {
|
|
font-size: 32px;
|
|
color: #fff;
|
|
-webkit-text-stroke: 0.6px rgba(0, 0, 0, 0.7);
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.55);
|
|
}
|
|
.stat-item.acc-large #acc-grade { font-size: 32px; }
|
|
|
|
#bl-wrapper {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 15px;
|
|
}
|
|
|
|
#bl-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
gap: 3px;
|
|
}
|
|
|
|
#bl-name {
|
|
font-size: 18px;
|
|
font-weight: 900;
|
|
color: var(--neon-magenta);
|
|
text-shadow: 0 0 5px rgba(255,0,255,0.5);
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.bl-stat-compact {
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
color: #fff !important;
|
|
text-shadow:
|
|
-1px -1px 0 rgba(0, 0, 0, 0.45),
|
|
1px -1px 0 rgba(0, 0, 0, 0.45),
|
|
-1px 1px 0 rgba(0, 0, 0, 0.45),
|
|
1px 1px 0 rgba(0, 0, 0, 0.45),
|
|
0 1px 2px rgba(0, 0, 0, 0.4);
|
|
}
|
|
|
|
.bl-stat-compact span {
|
|
color: var(--neon-cyan);
|
|
font-weight: bold;
|
|
text-shadow: 0 0 5px rgba(0,255,255,0.5), 0 1px 2px rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
.bl-country-flag {
|
|
width: 16px;
|
|
height: 12px;
|
|
margin-left: 6px;
|
|
border-radius: 2px;
|
|
vertical-align: -1px;
|
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25);
|
|
}
|
|
|
|
/* === МЕНЮ НАСТРОЕК === */
|
|
#settings-modal {
|
|
position: fixed;
|
|
top: 50%; left: 50%;
|
|
transform: translate(-50%, -50%) scale(0.9);
|
|
background: rgba(10, 10, 15, 0.95);
|
|
border: 1px solid var(--neon-cyan);
|
|
box-shadow: 0 0 30px rgba(0, 255, 255, 0.3);
|
|
padding: 25px;
|
|
border-radius: 12px;
|
|
z-index: 999;
|
|
display: none;
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
width: 360px;
|
|
opacity: 0;
|
|
transition: opacity 0.2s, transform 0.2s;
|
|
}
|
|
#settings-modal.show { display: flex; opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
|
#settings-modal h2 { margin: 0; color: var(--neon-cyan); text-align: center; font-size: 18px; }
|
|
.setting-row { display: flex; flex-direction: column; gap: 5px; }
|
|
|
|
.setting-row input[type="text"], .setting-row input[type="number"] {
|
|
background: rgba(0,0,0,0.5); color: #fff; border: 1px solid #444;
|
|
padding: 8px; font-family: 'Orbitron', sans-serif; border-radius: 4px; outline: none;
|
|
}
|
|
.setting-row input:focus { border-color: var(--neon-cyan); }
|
|
|
|
.radio-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 8px;
|
|
}
|
|
.radio-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
background: rgba(0,0,0,0.5);
|
|
padding: 6px 8px;
|
|
border: 1px solid #444;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
transition: 0.2s;
|
|
}
|
|
.radio-label:hover { border-color: var(--neon-cyan); }
|
|
.radio-label input { margin: 0; cursor: pointer; accent-color: var(--neon-cyan); }
|
|
|
|
.modules-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 6px;
|
|
}
|
|
.checkbox-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
cursor: pointer;
|
|
font-size: 11px;
|
|
background: rgba(0,0,0,0.4);
|
|
padding: 6px;
|
|
border-radius: 4px;
|
|
border: 1px solid #333;
|
|
transition: 0.2s;
|
|
}
|
|
.checkbox-row:hover { border-color: var(--neon-cyan); }
|
|
.checkbox-row input { margin: 0; cursor: pointer; accent-color: var(--neon-cyan); }
|
|
|
|
.setting-row button {
|
|
background: transparent; color: var(--neon-cyan); border: 1px solid var(--neon-cyan); padding: 10px;
|
|
font-weight: bold; cursor: pointer; border-radius: 4px; margin-top: 5px;
|
|
font-family: 'Orbitron', sans-serif; transition: 0.2s;
|
|
}
|
|
.setting-row button:hover { background: var(--neon-cyan); color: #000; box-shadow: 0 0 15px var(--neon-cyan); }
|
|
|
|
#debug {
|
|
position: fixed; bottom: 10px; left: 10px; background: rgba(0,0,0,0.8);
|
|
color: var(--neon-lime); padding: 4px 8px; font-size: 11px;
|
|
border-radius: 4px; opacity: 0; transition: opacity 0.5s; z-index: 1000;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="app-container">
|
|
<div id="menu-overlay" class="view-mode glass-panel">
|
|
<div id="bl-wrapper">
|
|
<div id="bl-avatar-wrapper" class="avatar-glow-wrapper active-glow">
|
|
<img id="bl-avatar" src="" alt="Avatar">
|
|
</div>
|
|
<div id="bl-info">
|
|
<div id="bl-name" data-i18n="loading">Loading...</div>
|
|
<div class="bl-stat-compact">Global: <span id="bl-global">#--</span></div>
|
|
<div class="bl-stat-compact">Local: <span id="bl-local">#--</span></div>
|
|
<div class="bl-stat-compact">PP: <span id="bl-pp">-- pp</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="playing-overlay" class="view-mode">
|
|
<div class="glass-panel" id="top-glass-panel">
|
|
<div id="header-row">
|
|
<div id="cover-wrapper" class="avatar-glow-wrapper active-glow">
|
|
<img id="cover" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" alt="Cover">
|
|
</div>
|
|
<div id="text-block">
|
|
<div id="title" data-i18n="waitingSong">Waiting for song...</div>
|
|
<div id="artist-mapper">-</div>
|
|
<div id="meta-line">
|
|
<span id="difficulty">-</span>
|
|
<span id="bpm">BPM -</span>
|
|
</div>
|
|
<div id="bsr-line">
|
|
<span id="key">BSR: -</span>
|
|
<span id="map-date"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="stats-row">
|
|
<div id="progress-wrapper">
|
|
<div id="progress-fill"></div>
|
|
<div id="time-prog">0:00 / 0:00</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="hp-bar-wrapper">
|
|
<div id="hp-bar-fill"></div>
|
|
<div id="hp-val">100%</div>
|
|
</div>
|
|
|
|
<div id="bottom-stats">
|
|
<div class="bottom-stat-row">
|
|
<div class="stat-item"><span class="label">Miss</span><span class="val" id="miss-val">0</span></div>
|
|
<div class="stat-item"><span class="label">Combo</span><span class="val" id="combo-val">0</span></div>
|
|
</div>
|
|
<div class="stat-item acc-large">
|
|
<span class="label">Acc</span>
|
|
<span class="val" id="acc-num">0.0%</span>
|
|
<span class="val" id="acc-grade" style="color:var(--neon-cyan); text-shadow: 0 0 10px var(--neon-cyan)">SS</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="settings-modal">
|
|
<h2 data-i18n="sysSetup">SYSTEM SETUP [F2]</h2>
|
|
|
|
<div class="setting-row">
|
|
<label style="font-size: 12px; color: #ccc;" data-i18n="langLabel">Language / Язык</label>
|
|
<div class="radio-grid">
|
|
<label class="radio-label"><input type="radio" name="lang" value="en"> English</label>
|
|
<label class="radio-label"><input type="radio" name="lang" value="ru"> Русский</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting-row">
|
|
<label style="font-size: 12px; color: #ccc;">WebSocket URL</label>
|
|
<input type="text" id="inp-ws" placeholder="ws://127.0.0.1:2947/socket">
|
|
</div>
|
|
<div class="setting-row">
|
|
<label style="font-size: 12px; color: #ccc;" data-i18n="layoutLabel">Layout</label>
|
|
<div class="radio-grid">
|
|
<label class="radio-label"><input type="radio" name="layout" value="top-left"> Top Left</label>
|
|
<label class="radio-label"><input type="radio" name="layout" value="top-right"> Top Right</label>
|
|
<label class="radio-label"><input type="radio" name="layout" value="bottom-left"> Bottom Left</label>
|
|
<label class="radio-label"><input type="radio" name="layout" value="bottom-right"> Bottom Right</label>
|
|
</div>
|
|
</div>
|
|
<div class="setting-row">
|
|
<label style="font-size: 12px; color: #ccc;" data-i18n="scaleLabel">Scale</label>
|
|
<input type="number" step="0.1" id="inp-scale">
|
|
</div>
|
|
<div class="setting-row">
|
|
<label style="font-size: 12px; color: #ccc;" data-i18n="blLabel">BeatLeader ID / Nickname</label>
|
|
<input type="text" id="inp-bl">
|
|
</div>
|
|
|
|
<div class="setting-row">
|
|
<label style="font-size: 12px; color: #ccc; margin-top: 5px;" data-i18n="modulesLabel">Display Modules</label>
|
|
<div class="modules-grid">
|
|
<label class="checkbox-row"><input type="checkbox" id="inp-show-bl"> <span data-i18n="modBl">BeatLeader Menu</span></label>
|
|
<label class="checkbox-row"><input type="checkbox" id="inp-show-cover"> <span data-i18n="modCover">Track Cover</span></label>
|
|
<label class="checkbox-row"><input type="checkbox" id="inp-show-title"> <span data-i18n="modTitle">Track Title</span></label>
|
|
<label class="checkbox-row"><input type="checkbox" id="inp-show-artist"> <span data-i18n="modArtist">Artist / Mapper</span></label>
|
|
<label class="checkbox-row"><input type="checkbox" id="inp-show-meta"> <span data-i18n="modMeta">Difficulty & BPM</span></label>
|
|
<label class="checkbox-row"><input type="checkbox" id="inp-show-bsr"> <span data-i18n="modBsr">BSR & Date</span></label>
|
|
<label class="checkbox-row"><input type="checkbox" id="inp-show-progress"> <span data-i18n="modProg">Progress Bar</span></label>
|
|
<label class="checkbox-row"><input type="checkbox" id="inp-show-hp"> <span data-i18n="modHp">HP Bar</span></label>
|
|
<label class="checkbox-row"><input type="checkbox" id="inp-show-stats"> <span data-i18n="modStats">Miss / Combo</span></label>
|
|
<label class="checkbox-row"><input type="checkbox" id="inp-show-acc"> <span data-i18n="modAcc">Accuracy (Acc)</span></label>
|
|
<label class="checkbox-row"><input type="checkbox" id="inp-glow-avatar"> <span data-i18n="modGlow">Neon Glow</span></label>
|
|
<label class="checkbox-row"><input type="checkbox" id="inp-show-debug"> <span data-i18n="modDebug">Debug Msgs</span></label>
|
|
<label class="checkbox-row"><input type="checkbox" id="inp-map-bg"> <span data-i18n="modMapBg">Map Card Background</span></label>
|
|
<label class="checkbox-row"><input type="checkbox" id="inp-bl-bg"> <span data-i18n="modBlBg">BeatLeader Background</span></label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting-row">
|
|
<button onclick="saveSettings()" data-i18n="applyBtn">APPLY & RECONNECT</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="debug">System init...</div>
|
|
|
|
<script>
|
|
|
|
// === TRANSLATIONS / ЛОКАЛИЗАЦИЯ ===
|
|
const PLACEHOLDER_COVER = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
|
|
|
|
const i18n = {
|
|
en: {
|
|
sysSetup: "SYSTEM SETUP [F2]",
|
|
langLabel: "Language / Язык",
|
|
layoutLabel: "Layout (Alignment)",
|
|
scaleLabel: "Scale (0.5 - 2.0)",
|
|
blLabel: "BeatLeader ID / Nickname",
|
|
blPlaceholder: "Example: 76561198029377687",
|
|
modulesLabel: "Display Modules (On/Off)",
|
|
modBl: "BeatLeader Menu",
|
|
modCover: "Track Cover",
|
|
modTitle: "Track Title",
|
|
modArtist: "Artist / Mapper",
|
|
modMeta: "Difficulty & BPM",
|
|
modBsr: "BSR Code & Date",
|
|
modProg: "Progress Bar",
|
|
modHp: "HP Bar (Health)",
|
|
modStats: "Miss / Combo",
|
|
modAcc: "Accuracy (Acc)",
|
|
modGlow: "Neon Glow",
|
|
modDebug: "Debug Messages",
|
|
modMapBg: "Map Card Background",
|
|
modBlBg: "BeatLeader Background",
|
|
applyBtn: "APPLY & RECONNECT",
|
|
waitingSong: "Waiting for song...",
|
|
loading: "Loading...",
|
|
profileNotFound: "Profile not found",
|
|
profileLoadError: "Error loading profile"
|
|
},
|
|
ru: {
|
|
sysSetup: "СИСТЕМНЫЕ НАСТРОЙКИ [F2]",
|
|
langLabel: "Язык / Language",
|
|
layoutLabel: "Выравнивание (Layout)",
|
|
scaleLabel: "Масштаб (0.5 - 2.0)",
|
|
blLabel: "BeatLeader ID / Никнейм",
|
|
blPlaceholder: "Например: 76561198029377687",
|
|
modulesLabel: "Модули отображения (Вкл/Выкл)",
|
|
modBl: "BeatLeader Меню",
|
|
modCover: "Обложка трека",
|
|
modTitle: "Название трека",
|
|
modArtist: "Исполнитель / Маппер",
|
|
modMeta: "Сложность и BPM",
|
|
modBsr: "BSR код и Дата",
|
|
modProg: "Прогресс-бар",
|
|
modHp: "HP Бар (Здоровье)",
|
|
modStats: "Промахи и Комбо",
|
|
modAcc: "Точность (Acc)",
|
|
modGlow: "Неоновое свечение",
|
|
modDebug: "Сообщения отладки",
|
|
modMapBg: "Фон карточки карты",
|
|
modBlBg: "Фон BeatLeader",
|
|
applyBtn: "ПРИМЕНИТЬ И ПЕРЕПОДКЛЮЧИТЬ",
|
|
waitingSong: "Ожидание трека...",
|
|
loading: "Загрузка...",
|
|
profileNotFound: "Профиль не найден",
|
|
profileLoadError: "Ошибка загрузки профиля"
|
|
}
|
|
};
|
|
|
|
const els = {
|
|
app: document.getElementById('app-container'),
|
|
menuOverlay: document.getElementById('menu-overlay'),
|
|
playingOverlay: document.getElementById('playing-overlay'),
|
|
topGlassPanel: document.getElementById('top-glass-panel'),
|
|
headerRow: document.getElementById('header-row'),
|
|
textBlock: document.getElementById('text-block'),
|
|
statsRow: document.getElementById('stats-row'),
|
|
progFill: document.getElementById('progress-fill'),
|
|
time: document.getElementById('time-prog'),
|
|
title: document.getElementById('title'),
|
|
artist: document.getElementById('artist-mapper'),
|
|
metaLine: document.getElementById('meta-line'),
|
|
bsrLine: document.getElementById('bsr-line'),
|
|
diff: document.getElementById('difficulty'),
|
|
bpm: document.getElementById('bpm'),
|
|
key: document.getElementById('key'),
|
|
date: document.getElementById('map-date'),
|
|
coverWrapper: document.getElementById('cover-wrapper'),
|
|
cover: document.getElementById('cover'),
|
|
bottomStats: document.getElementById('bottom-stats'),
|
|
bottomStatRow: document.querySelector('.bottom-stat-row'),
|
|
accLarge: document.querySelector('.acc-large'),
|
|
accNum: document.getElementById('acc-num'),
|
|
accGrade: document.getElementById('acc-grade'),
|
|
combo: document.getElementById('combo-val'),
|
|
miss: document.getElementById('miss-val'),
|
|
hpBarWrapper: document.getElementById('hp-bar-wrapper'),
|
|
hpVal: document.getElementById('hp-val'),
|
|
hpFill: document.getElementById('hp-bar-fill'),
|
|
debug: document.getElementById('debug'),
|
|
settings: document.getElementById('settings-modal'),
|
|
blAvatarWrapper: document.getElementById('bl-avatar-wrapper'),
|
|
blAvatar: document.getElementById('bl-avatar'),
|
|
blName: document.getElementById('bl-name'),
|
|
blGlobal: document.getElementById('bl-global'),
|
|
blLocal: document.getElementById('bl-local'),
|
|
blPp: document.getElementById('bl-pp')
|
|
};
|
|
|
|
let config = {
|
|
lang: 'ru',
|
|
ws: 'ws://127.0.0.1:2947/socket',
|
|
layout: 'top-left',
|
|
scale: 1.0,
|
|
blId: '',
|
|
resolvedBlId: '',
|
|
resolvedBlQuery: '',
|
|
showBL: true,
|
|
showDebugUI: true,
|
|
glowAvatar: true,
|
|
showCover: true,
|
|
showTitle: true,
|
|
showArtist: true,
|
|
showMeta: true,
|
|
showBsr: true,
|
|
showProgress: true,
|
|
showHp: true,
|
|
showStats: true,
|
|
showAcc: true,
|
|
showMapBg: true,
|
|
showBLBg: true
|
|
};
|
|
|
|
let ws = null;
|
|
let reconnectAttempts = 0;
|
|
let reconnectTimeout = null;
|
|
let reconnectScheduled = false;
|
|
let debugTimeout = null;
|
|
let duration = 0;
|
|
let isGamePlaying = false;
|
|
let lastKnownSongTime = 0;
|
|
let visualSongTime = 0;
|
|
let lastTimeAnchorMs = 0;
|
|
let mapTimeMultiplier = 1;
|
|
let progressRafId = null;
|
|
|
|
let lastBlFetch = 0;
|
|
let isFetchingBL = false;
|
|
let blRequestToken = 0;
|
|
let blAbortController = null;
|
|
let bsrRequestToken = 0;
|
|
let bsrAbortController = null;
|
|
let currentProxyIdx = 0;
|
|
|
|
const proxies = [
|
|
"https://api.codetabs.com/v1/proxy?quest=",
|
|
"https://api.allorigins.win/raw?url=",
|
|
"https://corsproxy.io/?",
|
|
"https://thingproxy.freeboard.io/fetch/"
|
|
];
|
|
|
|
function getText(key) {
|
|
return (i18n[config.lang] && i18n[config.lang][key]) || (i18n.en && i18n.en[key]) || key;
|
|
}
|
|
|
|
function persistConfig() {
|
|
try {
|
|
localStorage.setItem('bsCyberConfig', JSON.stringify(config));
|
|
} catch (err) {
|
|
console.warn('[BS+ Overlay] Failed to persist config:', err);
|
|
}
|
|
}
|
|
|
|
function clampScale(value) {
|
|
if (!Number.isFinite(value)) return 1.0;
|
|
return Math.min(2.0, Math.max(0.5, value));
|
|
}
|
|
|
|
function resetBLDisplay(messageKey = 'loading') {
|
|
els.blName.textContent = getText(messageKey);
|
|
els.blGlobal.textContent = '#--';
|
|
els.blLocal.textContent = '#--';
|
|
els.blPp.textContent = '-- pp';
|
|
els.blAvatar.src = '';
|
|
els.blAvatarWrapper.style.display = 'none';
|
|
}
|
|
|
|
function renderBLPlayer(player) {
|
|
els.blName.textContent = player.name || 'Unknown';
|
|
els.blGlobal.textContent = player.rank ? `#${player.rank.toLocaleString()}` : '#--';
|
|
els.blLocal.innerHTML = formatBLLocal(player.countryRank, player.country);
|
|
els.blPp.textContent = player.pp ? `${Math.round(player.pp).toLocaleString()} pp` : '-- pp';
|
|
|
|
if (player.avatar) {
|
|
els.blAvatar.src = player.avatar;
|
|
els.blAvatarWrapper.style.display = 'block';
|
|
} else {
|
|
els.blAvatar.src = '';
|
|
els.blAvatarWrapper.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function formatBLLocal(countryRank, country) {
|
|
if (!(countryRank > 0)) return '#--';
|
|
|
|
const rankText = `#${countryRank.toLocaleString()}`;
|
|
const countryCode = String(country || '').trim().toUpperCase();
|
|
if (!/^[A-Z]{2}$/.test(countryCode)) return rankText;
|
|
|
|
const flagUrl = `https://flagcdn.com/20x15/${countryCode.toLowerCase()}.png`;
|
|
return `${rankText}<img class="bl-country-flag" src="${flagUrl}" alt="${countryCode}" title="${countryCode}" loading="lazy" decoding="async" referrerpolicy="no-referrer">`;
|
|
}
|
|
|
|
function normalizeName(value) {
|
|
return String(value || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
}
|
|
|
|
function normalizeLoose(value) {
|
|
return normalizeName(value).replace(/[_\-\s]+/g, '');
|
|
}
|
|
|
|
function scorePlayerMatch(player, query) {
|
|
const name = String(player?.name || '');
|
|
if (!name) return Number.NEGATIVE_INFINITY;
|
|
|
|
const qExact = normalizeName(query);
|
|
const qLoose = normalizeLoose(query);
|
|
const nExact = normalizeName(name);
|
|
const nLoose = normalizeLoose(name);
|
|
|
|
let score = 0;
|
|
|
|
if (nExact === qExact) score += 1000;
|
|
if (nLoose === qLoose) score += 950;
|
|
if (nExact.startsWith(qExact)) score += 700;
|
|
if (nLoose.startsWith(qLoose)) score += 650;
|
|
if (nExact.includes(qExact)) score += 450;
|
|
if (nLoose.includes(qLoose)) score += 400;
|
|
|
|
if (typeof player.pp === 'number') score += Math.min(player.pp / 100, 50);
|
|
if (typeof player.rank === 'number' && player.rank > 0) score += Math.max(0, 50 - Math.min(player.rank, 5000) / 100);
|
|
if (typeof player.countryRank === 'number' && player.countryRank > 0) score += Math.max(0, 10 - Math.min(player.countryRank, 1000) / 100);
|
|
|
|
return score;
|
|
}
|
|
|
|
function resolveBestPlayer(players, query) {
|
|
if (!Array.isArray(players) || players.length === 0) return null;
|
|
|
|
const ranked = players
|
|
.map(player => ({ player, score: scorePlayerMatch(player, query) }))
|
|
.sort((a, b) => b.score - a.score);
|
|
|
|
return {
|
|
best: ranked[0]?.player || null,
|
|
ranked
|
|
};
|
|
}
|
|
|
|
function buildTargetUrl(originalUrl, proxy) {
|
|
return proxy ? proxy + encodeURIComponent(originalUrl) : originalUrl;
|
|
}
|
|
|
|
async function fetchJSONWithProxyFallback(originalUrl, label = 'Request', options = {}) {
|
|
const { signal } = options;
|
|
const totalAttempts = proxies.length;
|
|
let lastError = null;
|
|
|
|
for (let offset = 0; offset < totalAttempts; offset++) {
|
|
if (signal?.aborted) {
|
|
throw new DOMException('The operation was aborted.', 'AbortError');
|
|
}
|
|
|
|
const idx = (currentProxyIdx + offset) % totalAttempts;
|
|
const proxy = proxies[idx];
|
|
const targetUrl = buildTargetUrl(originalUrl, proxy);
|
|
|
|
try {
|
|
showDebug(`${label} [${offset + 1}/${totalAttempts}] via proxy`);
|
|
|
|
const res = await fetch(targetUrl, {
|
|
headers: { 'Accept': 'application/json' },
|
|
signal
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`Network error: ${res.status}`);
|
|
}
|
|
|
|
const json = await res.json();
|
|
currentProxyIdx = idx;
|
|
return json;
|
|
} catch (err) {
|
|
if (err?.name === 'AbortError') {
|
|
throw err;
|
|
}
|
|
|
|
lastError = err;
|
|
showDebug(`${label} failed: ${err.message}`);
|
|
|
|
if (offset < totalAttempts - 1) {
|
|
await new Promise(resolve => setTimeout(resolve, 1200));
|
|
}
|
|
}
|
|
}
|
|
|
|
throw lastError || new Error('Request failed');
|
|
}
|
|
|
|
function applyLanguage() {
|
|
const t = i18n[config.lang] || i18n.en;
|
|
|
|
const isDefaultTitle =
|
|
els.title.textContent === i18n.en.waitingSong ||
|
|
els.title.textContent === i18n.ru.waitingSong ||
|
|
els.title.textContent === "Waiting for song...";
|
|
|
|
const isDefaultLoading =
|
|
els.blName.textContent === i18n.en.loading ||
|
|
els.blName.textContent === i18n.ru.loading ||
|
|
els.blName.textContent === "Loading...";
|
|
|
|
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
const key = el.getAttribute('data-i18n');
|
|
if (t[key]) {
|
|
if (el.id === 'title' && !isDefaultTitle) return;
|
|
if (el.id === 'bl-name' && !isDefaultLoading) return;
|
|
el.textContent = t[key];
|
|
}
|
|
});
|
|
|
|
document.getElementById('inp-bl').placeholder = t.blPlaceholder;
|
|
document.documentElement.lang = config.lang;
|
|
}
|
|
|
|
function init() {
|
|
els.app.style.display = 'none';
|
|
els.cover.src = PLACEHOLDER_COVER;
|
|
|
|
loadSettings();
|
|
applyLanguage();
|
|
applyLayout();
|
|
applyModules();
|
|
applyGlow();
|
|
applyPanelBackgrounds();
|
|
connectWS();
|
|
|
|
setInterval(() => fetchBL(), 900000);
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'F2') els.settings.classList.toggle('show');
|
|
});
|
|
}
|
|
|
|
function loadSettings() {
|
|
const saved = localStorage.getItem('bsCyberConfig');
|
|
if (saved) {
|
|
try {
|
|
config = { ...config, ...JSON.parse(saved) };
|
|
} catch (err) {
|
|
console.warn('[BS+ Overlay] Invalid saved config. Resetting localStorage config.', err);
|
|
localStorage.removeItem('bsCyberConfig');
|
|
}
|
|
}
|
|
|
|
config.scale = clampScale(parseFloat(config.scale));
|
|
|
|
document.getElementById('inp-ws').value = config.ws;
|
|
document.getElementById('inp-scale').value = config.scale;
|
|
document.getElementById('inp-bl').value = config.blId;
|
|
|
|
document.getElementById('inp-show-bl').checked = config.showBL !== false;
|
|
document.getElementById('inp-show-debug').checked = config.showDebugUI !== false;
|
|
document.getElementById('inp-glow-avatar').checked = config.glowAvatar !== false;
|
|
|
|
document.getElementById('inp-show-cover').checked = config.showCover !== false;
|
|
document.getElementById('inp-show-title').checked = config.showTitle !== false;
|
|
document.getElementById('inp-show-artist').checked = config.showArtist !== false;
|
|
document.getElementById('inp-show-meta').checked = config.showMeta !== false;
|
|
document.getElementById('inp-show-bsr').checked = config.showBsr !== false;
|
|
|
|
document.getElementById('inp-show-progress').checked = config.showProgress !== false;
|
|
document.getElementById('inp-show-hp').checked = config.showHp !== false;
|
|
document.getElementById('inp-show-stats').checked = config.showStats !== false;
|
|
document.getElementById('inp-show-acc').checked = config.showAcc !== false;
|
|
document.getElementById('inp-map-bg').checked = config.showMapBg !== false;
|
|
document.getElementById('inp-bl-bg').checked = config.showBLBg !== false;
|
|
|
|
const layoutRadio = document.querySelector(`input[name="layout"][value="${config.layout}"]`);
|
|
if (layoutRadio) layoutRadio.checked = true;
|
|
|
|
const langRadio = document.querySelector(`input[name="lang"][value="${config.lang}"]`);
|
|
if (langRadio) langRadio.checked = true;
|
|
}
|
|
|
|
function saveSettings() {
|
|
const prevBlId = config.blId;
|
|
|
|
config.ws = document.getElementById('inp-ws').value.trim() || 'ws://127.0.0.1:2947/socket';
|
|
config.scale = clampScale(parseFloat(document.getElementById('inp-scale').value));
|
|
config.blId = document.getElementById('inp-bl').value.trim();
|
|
|
|
config.showBL = document.getElementById('inp-show-bl').checked;
|
|
config.showDebugUI = document.getElementById('inp-show-debug').checked;
|
|
config.glowAvatar = document.getElementById('inp-glow-avatar').checked;
|
|
|
|
config.showCover = document.getElementById('inp-show-cover').checked;
|
|
config.showTitle = document.getElementById('inp-show-title').checked;
|
|
config.showArtist = document.getElementById('inp-show-artist').checked;
|
|
config.showMeta = document.getElementById('inp-show-meta').checked;
|
|
config.showBsr = document.getElementById('inp-show-bsr').checked;
|
|
|
|
config.showProgress = document.getElementById('inp-show-progress').checked;
|
|
config.showHp = document.getElementById('inp-show-hp').checked;
|
|
config.showStats = document.getElementById('inp-show-stats').checked;
|
|
config.showAcc = document.getElementById('inp-show-acc').checked;
|
|
config.showMapBg = document.getElementById('inp-map-bg').checked;
|
|
config.showBLBg = document.getElementById('inp-bl-bg').checked;
|
|
|
|
const checkedLayout = document.querySelector('input[name="layout"]:checked');
|
|
if (checkedLayout) config.layout = checkedLayout.value;
|
|
|
|
const checkedLang = document.querySelector('input[name="lang"]:checked');
|
|
if (checkedLang) config.lang = checkedLang.value;
|
|
|
|
if (config.blId !== prevBlId) {
|
|
config.resolvedBlId = '';
|
|
config.resolvedBlQuery = '';
|
|
lastBlFetch = 0;
|
|
resetBLDisplay(config.blId ? 'loading' : 'loading');
|
|
}
|
|
|
|
persistConfig();
|
|
els.settings.classList.remove('show');
|
|
|
|
applyLanguage();
|
|
applyLayout();
|
|
applyModules();
|
|
applyGlow();
|
|
applyPanelBackgrounds();
|
|
connectWS();
|
|
|
|
if (config.showBL) {
|
|
fetchBL(true);
|
|
}
|
|
}
|
|
|
|
function applyLayout() {
|
|
const isLeft = config.layout.includes('left');
|
|
const isTop = config.layout.includes('top');
|
|
|
|
els.app.style.transformOrigin = `${isLeft ? 'left' : 'right'} ${isTop ? 'top' : 'bottom'}`;
|
|
els.app.style.transform = `scale(${config.scale})`;
|
|
|
|
els.app.style.top = isTop ? '20px' : 'auto';
|
|
els.app.style.bottom = isTop ? 'auto' : '20px';
|
|
els.app.style.left = isLeft ? '20px' : 'auto';
|
|
els.app.style.right = isLeft ? 'auto' : '20px';
|
|
|
|
els.playingOverlay.style.flexDirection = isTop ? 'column' : 'column-reverse';
|
|
|
|
els.menuOverlay.style.top = isTop ? '0' : 'auto';
|
|
els.menuOverlay.style.bottom = isTop ? 'auto' : '0';
|
|
els.playingOverlay.style.top = isTop ? '0' : 'auto';
|
|
els.playingOverlay.style.bottom = isTop ? 'auto' : '0';
|
|
|
|
els.app.style.alignItems = isLeft ? 'flex-start' : 'flex-end';
|
|
els.playingOverlay.style.alignItems = isLeft ? 'flex-start' : 'flex-end';
|
|
|
|
els.headerRow.style.flexDirection = isLeft ? 'row' : 'row-reverse';
|
|
els.textBlock.style.alignItems = isLeft ? 'flex-start' : 'flex-end';
|
|
els.textBlock.style.textAlign = isLeft ? 'left' : 'right';
|
|
|
|
els.statsRow.style.justifyContent = isLeft ? 'flex-start' : 'flex-end';
|
|
|
|
els.bottomStats.style.alignItems = isLeft ? 'flex-start' : 'flex-end';
|
|
els.bottomStatRow.style.flexDirection = isLeft ? 'row' : 'row-reverse';
|
|
|
|
document.getElementById('bl-wrapper').style.flexDirection = isLeft ? 'row' : 'row-reverse';
|
|
document.getElementById('bl-info').style.alignItems = isLeft ? 'flex-start' : 'flex-end';
|
|
document.getElementById('bl-info').style.textAlign = isLeft ? 'left' : 'right';
|
|
|
|
document.getElementById('bsr-line').style.justifyContent = isLeft ? 'flex-start' : 'flex-end';
|
|
|
|
els.hpFill.style.marginLeft = isLeft ? '0' : 'auto';
|
|
els.progFill.style.marginLeft = isLeft ? '0' : 'auto';
|
|
}
|
|
|
|
function applyModules() {
|
|
els.coverWrapper.style.display = config.showCover ? 'flex' : 'none';
|
|
|
|
els.title.style.display = config.showTitle ? '' : 'none';
|
|
els.artist.style.display = config.showArtist ? '' : 'none';
|
|
els.metaLine.style.display = config.showMeta ? '' : 'none';
|
|
els.bsrLine.style.display = config.showBsr ? '' : 'none';
|
|
|
|
const showAnyText = config.showTitle || config.showArtist || config.showMeta || config.showBsr;
|
|
els.textBlock.style.display = showAnyText ? 'flex' : 'none';
|
|
|
|
const showHeader = config.showCover || showAnyText;
|
|
els.headerRow.style.display = showHeader ? 'flex' : 'none';
|
|
|
|
els.statsRow.style.display = config.showProgress ? 'flex' : 'none';
|
|
|
|
const showTopPanel = showHeader;
|
|
els.topGlassPanel.style.display = showTopPanel ? 'flex' : 'none';
|
|
|
|
els.hpBarWrapper.style.display = config.showHp ? 'flex' : 'none';
|
|
|
|
els.bottomStatRow.style.display = config.showStats ? 'flex' : 'none';
|
|
els.accLarge.style.display = config.showAcc ? 'flex' : 'none';
|
|
|
|
const showBottomStats = config.showStats || config.showAcc;
|
|
els.bottomStats.style.display = showBottomStats ? 'flex' : 'none';
|
|
}
|
|
|
|
function applyGlow() {
|
|
if (els.blAvatarWrapper) els.blAvatarWrapper.classList.toggle('active-glow', config.glowAvatar !== false);
|
|
if (els.coverWrapper) els.coverWrapper.classList.toggle('active-glow', config.glowAvatar !== false);
|
|
}
|
|
|
|
function applyPanelBackgrounds() {
|
|
els.topGlassPanel.classList.toggle('panel-no-bg', config.showMapBg === false);
|
|
els.menuOverlay.classList.toggle('panel-no-bg', config.showBLBg === false);
|
|
}
|
|
|
|
function showDebug(msg) {
|
|
console.log("[BS+ Overlay]", msg);
|
|
if (config.showDebugUI === false) return;
|
|
|
|
clearTimeout(debugTimeout);
|
|
els.debug.textContent = msg;
|
|
els.debug.style.opacity = 1;
|
|
debugTimeout = setTimeout(() => els.debug.style.opacity = 0, 5000);
|
|
}
|
|
|
|
function renderProgressAt(timeSec) {
|
|
if (!(duration > 0)) return;
|
|
|
|
const safeTime = Math.max(0, Math.min(timeSec, duration));
|
|
const pct = Math.min((safeTime / duration) * 100, 100);
|
|
els.progFill.style.width = `${pct}%`;
|
|
|
|
const curM = Math.floor(safeTime / 60);
|
|
const curS = Math.floor(safeTime % 60).toString().padStart(2, '0');
|
|
const totM = Math.floor(duration / 60);
|
|
const totS = Math.floor(duration % 60).toString().padStart(2, '0');
|
|
els.time.textContent = `${curM}:${curS} / ${totM}:${totS}`;
|
|
}
|
|
|
|
function stopProgressLoop() {
|
|
if (progressRafId !== null) {
|
|
cancelAnimationFrame(progressRafId);
|
|
progressRafId = null;
|
|
}
|
|
}
|
|
|
|
function syncSongTime(timeSec) {
|
|
const safeTime = Math.max(0, Number(timeSec) || 0);
|
|
lastKnownSongTime = safeTime;
|
|
visualSongTime = safeTime;
|
|
lastTimeAnchorMs = performance.now();
|
|
|
|
if (duration > 0) {
|
|
renderProgressAt(safeTime);
|
|
}
|
|
}
|
|
|
|
function startProgressLoop() {
|
|
if (progressRafId !== null) return;
|
|
|
|
const tick = () => {
|
|
progressRafId = null;
|
|
|
|
if (!isGamePlaying || !(duration > 0)) return;
|
|
|
|
const now = performance.now();
|
|
const elapsedSec = Math.max(0, (now - lastTimeAnchorMs) / 1000);
|
|
const predictedTime = lastKnownSongTime + (elapsedSec * mapTimeMultiplier);
|
|
visualSongTime = predictedTime;
|
|
renderProgressAt(predictedTime);
|
|
|
|
progressRafId = requestAnimationFrame(tick);
|
|
};
|
|
|
|
progressRafId = requestAnimationFrame(tick);
|
|
}
|
|
|
|
function setMode(mode) {
|
|
if (mode === 'playing') {
|
|
isGamePlaying = true;
|
|
lastTimeAnchorMs = performance.now();
|
|
els.menuOverlay.classList.remove('active');
|
|
els.playingOverlay.classList.add('active');
|
|
startProgressLoop();
|
|
} else {
|
|
isGamePlaying = false;
|
|
stopProgressLoop();
|
|
els.playingOverlay.classList.remove('active');
|
|
if (config.showBL) {
|
|
els.menuOverlay.classList.add('active');
|
|
fetchBL();
|
|
} else {
|
|
els.menuOverlay.classList.remove('active');
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function normalizeCharacteristic(value) {
|
|
return String(value ?? '').toLowerCase().replace(/\s+/g, '');
|
|
}
|
|
|
|
function getCharacteristicIconSvg(characteristic) {
|
|
const normalized = normalizeCharacteristic(characteristic);
|
|
const iconMap = {
|
|
standard: 'standard',
|
|
onesaber: 'onesaber',
|
|
noarrows: 'noarrows',
|
|
rotation90degree: '90degree',
|
|
degree90: '90degree',
|
|
_90degree: '90degree',
|
|
'90degree': '90degree',
|
|
rotation360degree: '360degree',
|
|
degree360: '360degree',
|
|
_360degree: '360degree',
|
|
'360degree': '360degree',
|
|
lightshow: 'lightshow',
|
|
lawless: 'lawless',
|
|
legacy: 'legacy'
|
|
};
|
|
|
|
const icon = iconMap[normalized];
|
|
if (!icon) return '';
|
|
|
|
return `<img class="difficulty-char-icon" src="https://beatsaver.com/static/icons/${icon}.svg" alt="" aria-hidden="true" decoding="async" referrerpolicy="no-referrer" />`;
|
|
}
|
|
|
|
function formatCharacteristicBadge(characteristic) {
|
|
const icon = getCharacteristicIconSvg(characteristic);
|
|
if (icon) {
|
|
return icon;
|
|
}
|
|
return `<span class="difficulty-text">${escapeHtml(characteristic)}</span>`;
|
|
}
|
|
|
|
function formatDifficultyLabel(difficulty) {
|
|
return String(difficulty ?? '').replace(/^ExpertPlus$/i, 'Expert+').replace(/^Expert Plus$/i, 'Expert+');
|
|
}
|
|
|
|
function formatDifficultyDisplay(characteristic, difficulty) {
|
|
const safeDifficulty = escapeHtml(formatDifficultyLabel(difficulty));
|
|
const characteristicPart = formatCharacteristicBadge(characteristic);
|
|
return `<span class="difficulty-badge" title="${escapeHtml(characteristic)} ${safeDifficulty}">${characteristicPart}<span class="difficulty-text">${safeDifficulty}</span></span>`;
|
|
}
|
|
|
|
function getGrade(acc) {
|
|
let grade = 'E';
|
|
if (acc >= 0.9) grade = 'SS';
|
|
else if (acc >= 0.8) grade = 'S';
|
|
else if (acc >= 0.65) grade = 'A';
|
|
else if (acc >= 0.5) grade = 'B';
|
|
else if (acc >= 0.35) grade = 'C';
|
|
else if (acc >= 0.2) grade = 'D';
|
|
|
|
let color = '#e0e0e0';
|
|
if (acc >= 0.95) color = '#b046ff';
|
|
else if (acc >= 0.90) color = '#ff3b3b';
|
|
else if (acc >= 0.85) color = '#ff9800';
|
|
else if (acc >= 0.80) color = '#00e5ff';
|
|
else if (acc >= 0.70) color = '#39ff14';
|
|
|
|
return { grade, color };
|
|
}
|
|
|
|
function getDifficultyStyle(diff) {
|
|
const d = String(diff || '').toLowerCase();
|
|
if (d.includes('easy')) return { color: '#3cb371', shadow: 'rgba(60, 179, 113, 0.5)' };
|
|
if (d.includes('normal')) return { color: '#59b0f4', shadow: 'rgba(89, 176, 244, 0.5)' };
|
|
if (d.includes('hard')) return { color: '#ff9800', shadow: 'rgba(255, 152, 0, 0.5)' };
|
|
if (d.includes('expert+') || d.includes('expertplus')) return { color: '#8f48db', shadow: 'rgba(143, 72, 219, 0.5)' };
|
|
if (d.includes('expert')) return { color: '#e53935', shadow: 'rgba(229, 57, 53, 0.5)' };
|
|
return { color: '#fff', shadow: 'rgba(255, 255, 255, 0.5)' };
|
|
}
|
|
|
|
async function fetchBSR(hash) {
|
|
const requestToken = ++bsrRequestToken;
|
|
|
|
if (bsrAbortController) {
|
|
bsrAbortController.abort();
|
|
}
|
|
const controller = new AbortController();
|
|
bsrAbortController = controller;
|
|
|
|
try {
|
|
els.key.textContent = `BSR: Loading...`;
|
|
els.date.textContent = ``;
|
|
const res = await fetch(`https://api.beatsaver.com/maps/hash/${hash}`, {
|
|
signal: controller.signal
|
|
});
|
|
if (!res.ok) throw new Error("Not found");
|
|
const data = await res.json();
|
|
|
|
if (requestToken !== bsrRequestToken) return;
|
|
|
|
els.key.textContent = `BSR: ${data.id}`;
|
|
|
|
if (data.uploaded) {
|
|
const date = new Date(data.uploaded);
|
|
els.date.textContent = date.toLocaleDateString();
|
|
}
|
|
} catch (err) {
|
|
if (err?.name === 'AbortError') return;
|
|
if (requestToken !== bsrRequestToken) return;
|
|
|
|
els.key.textContent = `BSR: N/A`;
|
|
els.date.textContent = ``;
|
|
} finally {
|
|
if (requestToken === bsrRequestToken && bsrAbortController === controller) {
|
|
bsrAbortController = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function fetchBL(force = false) {
|
|
if (!config.blId || !config.showBL) {
|
|
if (blAbortController) {
|
|
blAbortController.abort();
|
|
blAbortController = null;
|
|
}
|
|
isFetchingBL = false;
|
|
return;
|
|
}
|
|
|
|
if (isFetchingBL && !force) return;
|
|
if (!force && Date.now() - lastBlFetch < 900000) return;
|
|
|
|
const requestToken = ++blRequestToken;
|
|
if (blAbortController) {
|
|
blAbortController.abort();
|
|
}
|
|
const controller = new AbortController();
|
|
blAbortController = controller;
|
|
isFetchingBL = true;
|
|
|
|
try {
|
|
let player = null;
|
|
const isNumeric = /^\d+$/.test(config.blId);
|
|
|
|
if (isNumeric) {
|
|
const json = await fetchJSONWithProxyFallback(`https://api.beatleader.com/player/${config.blId}?stats=true`, 'BL Player', {
|
|
signal: controller.signal
|
|
});
|
|
player = json?.data ? json.data[0] : json;
|
|
config.resolvedBlId = config.blId;
|
|
config.resolvedBlQuery = config.blId;
|
|
} else {
|
|
const normalizedQuery = normalizeName(config.blId);
|
|
|
|
if (config.resolvedBlId && normalizeName(config.resolvedBlQuery) === normalizedQuery) {
|
|
try {
|
|
const json = await fetchJSONWithProxyFallback(`https://api.beatleader.com/player/${config.resolvedBlId}?stats=true`, 'BL Resolved Player', {
|
|
signal: controller.signal
|
|
});
|
|
player = json?.data ? json.data[0] : json;
|
|
} catch (err) {
|
|
if (err?.name === 'AbortError') throw err;
|
|
player = null;
|
|
}
|
|
}
|
|
|
|
if (!player) {
|
|
const json = await fetchJSONWithProxyFallback(`https://api.beatleader.com/players?search=${encodeURIComponent(config.blId)}`, 'BL Search', {
|
|
signal: controller.signal
|
|
});
|
|
const candidates = Array.isArray(json?.data) ? json.data : [];
|
|
const resolved = resolveBestPlayer(candidates, config.blId);
|
|
|
|
if (!resolved?.best) {
|
|
throw new Error('Player not found');
|
|
}
|
|
|
|
player = resolved.best;
|
|
|
|
if (player.id) {
|
|
config.resolvedBlId = String(player.id);
|
|
config.resolvedBlQuery = config.blId;
|
|
}
|
|
|
|
if (resolved.ranked.length > 1) {
|
|
showDebug(`BL best match: ${player.name}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!player || !player.name) {
|
|
throw new Error('Player not found');
|
|
}
|
|
|
|
if (requestToken !== blRequestToken) return;
|
|
|
|
renderBLPlayer(player);
|
|
lastBlFetch = Date.now();
|
|
persistConfig();
|
|
showDebug(`BL Profile Loaded Successfully!`);
|
|
} catch (err) {
|
|
if (err?.name === 'AbortError') return;
|
|
if (requestToken !== blRequestToken) return;
|
|
|
|
resetBLDisplay(err.message === 'Player not found' ? 'profileNotFound' : 'profileLoadError');
|
|
showDebug(`BL Error: ${err.message}`);
|
|
} finally {
|
|
if (requestToken === blRequestToken) {
|
|
isFetchingBL = false;
|
|
if (blAbortController === controller) {
|
|
blAbortController = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function connectWS() {
|
|
if (ws) {
|
|
try {
|
|
ws.onopen = null;
|
|
ws.onclose = null;
|
|
ws.onerror = null;
|
|
ws.onmessage = null;
|
|
ws.close();
|
|
} catch (_) {}
|
|
}
|
|
|
|
clearTimeout(reconnectTimeout);
|
|
reconnectTimeout = null;
|
|
reconnectScheduled = false;
|
|
isGamePlaying = false;
|
|
duration = 0;
|
|
mapTimeMultiplier = 1;
|
|
lastKnownSongTime = 0;
|
|
visualSongTime = 0;
|
|
stopProgressLoop();
|
|
els.app.style.display = 'none';
|
|
|
|
showDebug(`Connecting to ${config.ws}...`);
|
|
|
|
let socket;
|
|
try {
|
|
socket = new WebSocket(config.ws);
|
|
} catch (e) {
|
|
handleDisconnect(null, e);
|
|
return;
|
|
}
|
|
|
|
ws = socket;
|
|
|
|
socket.onopen = () => {
|
|
if (ws !== socket) return;
|
|
showDebug('✅ WebSocket Connected');
|
|
reconnectAttempts = 0;
|
|
reconnectScheduled = false;
|
|
els.app.style.display = 'flex';
|
|
setMode('menu');
|
|
};
|
|
|
|
const disconnectHandler = (evtOrErr) => {
|
|
handleDisconnect(socket, evtOrErr);
|
|
};
|
|
|
|
socket.onclose = disconnectHandler;
|
|
socket.onerror = disconnectHandler;
|
|
|
|
socket.onmessage = (ev) => {
|
|
if (ws !== socket) return;
|
|
|
|
let data;
|
|
try {
|
|
data = JSON.parse(ev.data);
|
|
} catch (err) {
|
|
showDebug(`WS JSON parse error: ${err.message}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const event = data._event;
|
|
|
|
if (event === 'gameState') {
|
|
if (data.gameStateChanged === 'Playing') {
|
|
setMode('playing');
|
|
} else {
|
|
setMode('menu');
|
|
}
|
|
}
|
|
|
|
if (event === 'mapInfo' && data.mapInfoChanged) {
|
|
const m = data.mapInfoChanged;
|
|
els.title.textContent = m.sub_name ? `${m.name} ${m.sub_name}` : m.name;
|
|
|
|
const mapper = (m.mapper && m.mapper !== 'Not mapped') ? m.mapper : '';
|
|
els.artist.textContent = mapper ? `${m.artist} // ${mapper}` : m.artist;
|
|
|
|
const diffStyle = getDifficultyStyle(m.difficulty);
|
|
els.diff.style.setProperty('--diff-color', diffStyle.color);
|
|
els.diff.style.setProperty('--diff-shadow', diffStyle.shadow);
|
|
els.diff.innerHTML = formatDifficultyDisplay(m.characteristic, m.difficulty);
|
|
|
|
els.bpm.textContent = `BPM ${Math.round(m.BPM || 0)}`;
|
|
if (m.coverRaw) els.cover.src = `data:image/png;base64,${m.coverRaw}`;
|
|
else els.cover.src = PLACEHOLDER_COVER;
|
|
|
|
duration = (m.duration || 0) / 1000;
|
|
mapTimeMultiplier = Math.max(0, Number(m.timeMultiplier) || 1);
|
|
syncSongTime(m.time !== undefined ? m.time : 0);
|
|
if (!(duration > 0)) {
|
|
els.time.textContent = '0:00 / 0:00';
|
|
}
|
|
|
|
if (m.BSRKey) {
|
|
els.key.textContent = `BSR: ${m.BSRKey}`;
|
|
els.date.textContent = ``;
|
|
if (m.level_id?.startsWith('custom_level_')) fetchBSR(m.level_id.substring(13));
|
|
} else if (m.level_id?.startsWith('custom_level_')) {
|
|
fetchBSR(m.level_id.substring(13));
|
|
} else {
|
|
els.key.textContent = `OST/DLC`;
|
|
els.date.textContent = ``;
|
|
}
|
|
|
|
if (isGamePlaying) {
|
|
startProgressLoop();
|
|
}
|
|
}
|
|
|
|
if (event === 'score' && data.scoreEvent) {
|
|
const s = data.scoreEvent;
|
|
|
|
if (s.accuracy !== undefined) {
|
|
const accRaw = s.accuracy;
|
|
const accObj = getGrade(accRaw);
|
|
els.accNum.textContent = `${(accRaw * 100).toFixed(1)}%`;
|
|
els.accGrade.textContent = accObj.grade;
|
|
els.accGrade.style.color = accObj.color;
|
|
els.accGrade.style.textShadow = `0 0 10px ${accObj.color}, 1px 1px 3px #000`;
|
|
}
|
|
|
|
if (s.combo !== undefined) els.combo.textContent = s.combo;
|
|
if (s.missCount !== undefined) els.miss.textContent = s.missCount;
|
|
|
|
if (s.currentHealth !== undefined) {
|
|
const hpPct = Math.round(s.currentHealth * 100);
|
|
els.hpVal.textContent = `${hpPct}%`;
|
|
els.hpFill.style.width = `${hpPct}%`;
|
|
}
|
|
|
|
if (s.time !== undefined) {
|
|
syncSongTime(s.time);
|
|
if (isGamePlaying) startProgressLoop();
|
|
}
|
|
}
|
|
|
|
if (event === 'pause') {
|
|
isGamePlaying = false;
|
|
stopProgressLoop();
|
|
if (data.pauseTime !== undefined) {
|
|
syncSongTime(data.pauseTime);
|
|
}
|
|
}
|
|
|
|
if (event === 'resume') {
|
|
if (data.resumeTime !== undefined) {
|
|
syncSongTime(data.resumeTime);
|
|
} else {
|
|
lastTimeAnchorMs = performance.now();
|
|
}
|
|
isGamePlaying = true;
|
|
startProgressLoop();
|
|
}
|
|
} catch (err) {
|
|
showDebug(`WS handler error: ${err.message}`);
|
|
}
|
|
};
|
|
}
|
|
|
|
function handleDisconnect(sourceSocket, error = null) {
|
|
if (sourceSocket && sourceSocket !== ws) return;
|
|
if (reconnectScheduled) return;
|
|
|
|
reconnectScheduled = true;
|
|
isGamePlaying = false;
|
|
stopProgressLoop();
|
|
lastKnownSongTime = 0;
|
|
visualSongTime = 0;
|
|
lastTimeAnchorMs = 0;
|
|
mapTimeMultiplier = 1;
|
|
els.app.style.display = 'none';
|
|
|
|
const delay = 5000;
|
|
const suffix = error && error.message ? ` (${error.message})` : '';
|
|
showDebug(`❌ WS Lost. Reconnecting in ${delay/1000}s...${suffix}`);
|
|
reconnectAttempts++;
|
|
|
|
clearTimeout(reconnectTimeout);
|
|
reconnectTimeout = setTimeout(() => {
|
|
reconnectScheduled = false;
|
|
connectWS();
|
|
}, delay);
|
|
}
|
|
|
|
init();
|
|
|
|
</script>
|
|
</body>
|
|
</html>
|