315 lines
9.6 KiB
JavaScript
315 lines
9.6 KiB
JavaScript
// src/client/index.ts
|
|
function must(id) {
|
|
const element = document.getElementById(id);
|
|
if (!element) throw new Error(`Missing element: ${id}`);
|
|
return element;
|
|
}
|
|
function parseJson(raw) {
|
|
return JSON.parse(raw);
|
|
}
|
|
var beatSaberPlus = {
|
|
// https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay
|
|
url: "ws://localhost:2947/socket",
|
|
onMessage: (e) => {
|
|
const data = parseJson(e.data);
|
|
switch (data._type) {
|
|
case "event":
|
|
switch (data._event) {
|
|
case "gameState":
|
|
document.body.dataset.gameState = data.gameStateChanged;
|
|
break;
|
|
case "mapInfo":
|
|
void updateMapInfo(data.mapInfoChanged);
|
|
break;
|
|
case "pause":
|
|
updateTime(data.pauseTime, true);
|
|
break;
|
|
case "resume":
|
|
updateTime(data.resumeTime, false);
|
|
break;
|
|
case "score":
|
|
updateScore(data.scoreEvent);
|
|
break;
|
|
}
|
|
break;
|
|
default:
|
|
console.log("message", e.data);
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
var provider = beatSaberPlus;
|
|
var retryMs = 1e4;
|
|
var retries = 0;
|
|
function connect() {
|
|
console.log(`Connecting to ${provider.url} (attempt ${retries++})`);
|
|
const ws = new WebSocket(provider.url);
|
|
ws.onopen = onOpen;
|
|
ws.onmessage = provider.onMessage;
|
|
ws.onclose = onClose;
|
|
}
|
|
function onOpen() {
|
|
console.log("Connection open.");
|
|
retries = 0;
|
|
}
|
|
function onClose(e) {
|
|
console.log(`Connection closed. code: ${e.code}, reason: ${e.reason}, clean: ${e.wasClean}`);
|
|
setTimeout(connect, retryMs);
|
|
}
|
|
connect();
|
|
var cover = must("coverImg");
|
|
var title = must("title");
|
|
var subTitle = must("subTitle");
|
|
var artist = must("artist");
|
|
var mapper = must("mapper");
|
|
var difficulty = must("difficulty");
|
|
var characteristic = must("characteristicImg");
|
|
var difficultyLabel = must("difficultyLabel");
|
|
var type = must("type");
|
|
var bsrKey = must("bsrKey");
|
|
var timeMultiplier = 1;
|
|
var duration = 0;
|
|
async function updateMapInfo(data) {
|
|
const custom = data.level_id.startsWith("custom_level_");
|
|
const wip = custom && data.level_id.endsWith("WIP");
|
|
cover.src = data.coverRaw ? `data:image/jpeg;base64,${data.coverRaw}` : "images/unknown.svg";
|
|
title.textContent = data.name || "";
|
|
subTitle.textContent = data.sub_name || "";
|
|
artist.textContent = data.artist || "";
|
|
mapper.textContent = data.mapper || "";
|
|
difficulty.textContent = data.difficulty?.replace("Plus", " +") || "";
|
|
characteristic.src = `images/characteristic/${data.characteristic}.svg`;
|
|
difficultyLabel.textContent = "";
|
|
type.textContent = !custom ? "OST" : wip ? "WIP" : "";
|
|
bsrKey.textContent = data.BSRKey || "???";
|
|
timeMultiplier = data.timeMultiplier || 1;
|
|
duration = data.duration / 1e3;
|
|
if (custom && !wip) {
|
|
document.body.classList.add("loading");
|
|
try {
|
|
const response = await fetch(`https://api.beatsaver.com/maps/hash/${data.level_id.substring(13, 53)}`);
|
|
const map = await response.json();
|
|
if (!map.id) return;
|
|
bsrKey.textContent = map.id;
|
|
mapper.textContent = map.metadata.levelAuthorName;
|
|
const diff = map.versions[0].diffs.find((d) => d.characteristic === data.characteristic && d.difficulty === data.difficulty);
|
|
if (diff?.label) difficultyLabel.textContent = diff.label;
|
|
} finally {
|
|
document.body.classList.remove("loading");
|
|
}
|
|
}
|
|
}
|
|
var timeText = must("timeText");
|
|
var timeBar = must("timeBar");
|
|
var intervalMs = 500;
|
|
var intervalId = 0;
|
|
var currentTime = 0;
|
|
function updateTime(time, paused) {
|
|
if (!settings.time) return;
|
|
setTime(time);
|
|
clearInterval(intervalId);
|
|
if (paused) return;
|
|
intervalId = window.setInterval(() => setTime(currentTime + intervalMs * timeMultiplier / 1e3), intervalMs);
|
|
}
|
|
function setTime(time) {
|
|
currentTime = time;
|
|
timeText.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`;
|
|
timeBar.style.width = `${currentTime / (duration || Infinity) * 100}%`;
|
|
}
|
|
function formatTime(t) {
|
|
t = Math.floor(t);
|
|
const minutes = Math.floor(t / 60);
|
|
const seconds = t - minutes * 60;
|
|
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
|
}
|
|
var accuracy = must("accuracy");
|
|
var mistakes = must("mistakes");
|
|
function updateScore(score) {
|
|
if (!settings.score) return;
|
|
accuracy.textContent = (score.accuracy * 100).toFixed(1);
|
|
mistakes.textContent = score.missCount ? String(score.missCount) : "";
|
|
accuracy.classList.toggle("failed", score.currentHealth === 0);
|
|
}
|
|
var settings = {
|
|
cover: true,
|
|
mapInfo: true,
|
|
time: true,
|
|
score: true,
|
|
bsr: false,
|
|
debug: false,
|
|
right: false,
|
|
bottom: true,
|
|
scale: 1,
|
|
fade: 300
|
|
};
|
|
var defaults = structuredClone(settings);
|
|
var style = document.createElement("style");
|
|
function loadSettings() {
|
|
const params = new URLSearchParams(location.hash.slice(1));
|
|
let css = "";
|
|
for (const [key, def] of Object.entries(defaults)) {
|
|
const value = parseJson(params.get(key) || "null") ?? def;
|
|
settings[key] = value;
|
|
if (typeof def === "boolean") document.body.classList.toggle(key, Boolean(value));
|
|
else css += `--${key}: ${value}; `;
|
|
}
|
|
style.textContent = `:root { ${css}}`;
|
|
}
|
|
function saveSettings() {
|
|
const params = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(settings)) {
|
|
if (value !== defaults[key]) params.set(key, JSON.stringify(value));
|
|
}
|
|
location.replace(`#${params.toString()}`);
|
|
}
|
|
window.onhashchange = loadSettings;
|
|
loadSettings();
|
|
document.head.appendChild(style);
|
|
for (const key of [
|
|
"cover",
|
|
"mapInfo",
|
|
"time",
|
|
"score",
|
|
"bsr",
|
|
"debug"
|
|
]) {
|
|
const input = must(`${key}Input`);
|
|
input.checked = settings[key];
|
|
input.oninput = () => {
|
|
settings[key] = input.checked;
|
|
saveSettings();
|
|
if (key === "debug") void loadRequestQueue();
|
|
};
|
|
}
|
|
var scale = must("scaleInput");
|
|
scale.valueAsNumber = settings.scale * 100;
|
|
scale.oninput = () => {
|
|
settings.scale = scale.valueAsNumber / 100;
|
|
saveSettings();
|
|
};
|
|
var position = must("positionInput");
|
|
position.value = JSON.stringify([
|
|
settings.right,
|
|
settings.bottom
|
|
]);
|
|
position.onchange = () => {
|
|
[settings.right, settings.bottom] = parseJson(position.value);
|
|
saveSettings();
|
|
};
|
|
var fade = must("fadeInput");
|
|
fade.valueAsNumber = settings.fade;
|
|
fade.oninput = () => {
|
|
settings.fade = fade.valueAsNumber;
|
|
saveSettings();
|
|
};
|
|
document.documentElement.onclick = () => document.body.classList.toggle("preview");
|
|
must("settings").onclick = (e) => e.stopPropagation();
|
|
var MAX_REQUESTS = 10;
|
|
var REQUEST_POLL_MS = 5e3;
|
|
var requestListEl = must("requestList");
|
|
var requestOverlayEl = must("requestOverlay");
|
|
var requestEmptyEl = must("requestEmpty");
|
|
var requestTitleCache = /* @__PURE__ */ new Map();
|
|
function useRequestHistorySim() {
|
|
return settings.debug || new URLSearchParams(location.search).get("debug") === "1";
|
|
}
|
|
function requestJsonFilenames() {
|
|
const explicit = new URLSearchParams(location.search).get("requests");
|
|
if (explicit) return [
|
|
explicit
|
|
];
|
|
if (useRequestHistorySim()) return [
|
|
"ChatRequest.json",
|
|
"database.json"
|
|
];
|
|
return [
|
|
"ChatRequest.json"
|
|
];
|
|
}
|
|
function loadJsonNextToPage(fileName) {
|
|
const base = new URL(fileName, location.href);
|
|
if (base.protocol !== "http:" && base.protocol !== "https:") {
|
|
throw new Error("not-http");
|
|
}
|
|
const busted = new URL(base.href);
|
|
busted.searchParams.set("t", String(Date.now()));
|
|
return fetch(busted.href, {
|
|
cache: "no-store"
|
|
}).then((res) => {
|
|
if (!res.ok) throw new Error(String(res.status));
|
|
return res.json();
|
|
});
|
|
}
|
|
async function loadRequestPayload() {
|
|
let lastErr;
|
|
for (const name of requestJsonFilenames()) {
|
|
try {
|
|
return await loadJsonNextToPage(name);
|
|
} catch (e) {
|
|
lastErr = e;
|
|
}
|
|
}
|
|
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
}
|
|
function requesterLine(item) {
|
|
const parts = [
|
|
item.npr,
|
|
item.rqn
|
|
].filter(Boolean);
|
|
return parts.length ? parts.join(" ") : item.rqn || "";
|
|
}
|
|
async function enrichRequestTitle(key, titleEl) {
|
|
if (requestTitleCache.has(key)) {
|
|
titleEl.textContent = requestTitleCache.get(key) ?? "";
|
|
return;
|
|
}
|
|
try {
|
|
const response = await fetch(`https://api.beatsaver.com/maps/id/${encodeURIComponent(key)}`);
|
|
if (!response.ok) return;
|
|
const map = await response.json();
|
|
const name = map.metadata?.songName ?? map.name;
|
|
if (name && typeof name === "string") {
|
|
requestTitleCache.set(key, name);
|
|
titleEl.textContent = name;
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
function renderRequestList(items) {
|
|
requestListEl.replaceChildren();
|
|
requestOverlayEl.classList.toggle("has-items", items.length > 0);
|
|
for (const item of items) {
|
|
const li = document.createElement("li");
|
|
li.className = "request-item";
|
|
const titleEl = document.createElement("span");
|
|
titleEl.className = "request-title";
|
|
titleEl.textContent = `!bsr ${item.key}`;
|
|
li.appendChild(titleEl);
|
|
const who = requesterLine(item);
|
|
if (who) {
|
|
const meta = document.createElement("span");
|
|
meta.className = "request-meta";
|
|
meta.textContent = who;
|
|
li.appendChild(meta);
|
|
}
|
|
requestListEl.appendChild(li);
|
|
void enrichRequestTitle(item.key, titleEl);
|
|
}
|
|
}
|
|
async function loadRequestQueue() {
|
|
try {
|
|
const data = await loadRequestPayload();
|
|
requestEmptyEl.textContent = "No pending requests";
|
|
requestOverlayEl.classList.remove("request-load-failed");
|
|
const raw = useRequestHistorySim() ? data.history ?? [] : data.queue ?? [];
|
|
const items = raw.slice(0, MAX_REQUESTS);
|
|
renderRequestList(items);
|
|
} catch {
|
|
requestEmptyEl.textContent = "whupsy, database file missing";
|
|
requestOverlayEl.classList.add("request-load-failed");
|
|
renderRequestList([]);
|
|
}
|
|
}
|
|
void loadRequestQueue();
|
|
window.setInterval(() => void loadRequestQueue(), REQUEST_POLL_MS);
|