2026-04-11 16:19:48 -07:00

368 lines
11 KiB
TypeScript

import type {
BeatSaberPlusEvent,
ChatRequestEntry,
ChatRequestPayload,
MapInfo,
Score,
} from "./types.ts";
function must<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (!element) throw new Error(`Missing element: ${id}`);
return element as T;
}
function parseJson<T>(raw: string): T {
return JSON.parse(raw) as T;
}
// WebSocket connection
const beatSaberPlus = {
// https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay
url: "ws://localhost:2947/socket",
onMessage: (e: MessageEvent<string>) => {
const data = parseJson<BeatSaberPlusEvent>(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;
}
},
};
const provider = beatSaberPlus;
const retryMs = 10000;
let 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: CloseEvent) {
console.log(`Connection closed. code: ${e.code}, reason: ${e.reason}, clean: ${e.wasClean}`);
setTimeout(connect, retryMs);
}
connect();
// Map info
const cover = must<HTMLImageElement>("coverImg");
const title = must<HTMLElement>("title");
const subTitle = must<HTMLElement>("subTitle");
const artist = must<HTMLElement>("artist");
const mapper = must<HTMLElement>("mapper");
const difficulty = must<HTMLElement>("difficulty");
const characteristic = must<HTMLImageElement>("characteristicImg");
const difficultyLabel = must<HTMLElement>("difficultyLabel");
const type = must<HTMLElement>("type");
const bsrKey = must<HTMLElement>("bsrKey");
let timeMultiplier = 1;
let duration = 0;
async function updateMapInfo(data: MapInfo) {
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 = ""; // BS+ does not provide label
type.textContent = !custom ? "OST" : wip ? "WIP" : "";
bsrKey.textContent = data.BSRKey || "???"; // Always empty?
timeMultiplier = data.timeMultiplier || 1;
duration = data.duration / 1000;
// Fetch extra info from BeatSaver
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; // Replace mapper name with full authors list
const diff = map.versions[0].diffs.find(
(d: { characteristic: string; difficulty: string; label?: string }) =>
d.characteristic === data.characteristic && d.difficulty === data.difficulty,
);
if (diff?.label) difficultyLabel.textContent = diff.label;
} finally {
document.body.classList.remove("loading");
}
}
}
// Song time
const timeText = must<HTMLElement>("timeText");
const timeBar = must<HTMLElement>("timeBar");
const intervalMs = 500;
let intervalId = 0;
let currentTime = 0;
function updateTime(time: number, paused: boolean) {
if (!settings.time) return;
setTime(time);
clearInterval(intervalId);
if (paused) return;
intervalId = window.setInterval(() => setTime(currentTime + intervalMs * timeMultiplier / 1000), intervalMs);
}
function setTime(time: number) {
currentTime = time;
timeText.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`;
timeBar.style.width = `${currentTime / (duration || Infinity) * 100}%`;
}
function formatTime(t: number) {
t = Math.floor(t);
const minutes = Math.floor(t / 60);
const seconds = t - minutes * 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
// Score
const accuracy = must<HTMLElement>("accuracy");
const mistakes = must<HTMLElement>("mistakes");
function updateScore(score: 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);
}
// Settings
interface Settings {
cover: boolean;
mapInfo: boolean;
time: boolean;
score: boolean;
bsr: boolean;
debug: boolean;
right: boolean;
bottom: boolean;
scale: number;
fade: number;
}
const settings: Settings = {
cover: true,
mapInfo: true,
time: true,
score: true,
bsr: false,
debug: false,
right: false,
bottom: true,
scale: 1,
fade: 300,
};
const defaults = structuredClone(settings);
const style = document.createElement("style");
function loadSettings() {
const params = new URLSearchParams(location.hash.slice(1));
let css = "";
for (const [key, def] of Object.entries(defaults) as [keyof Settings, Settings[keyof Settings]][]) {
const value = (parseJson<Settings[keyof Settings] | null>(params.get(key) || "null") ?? def);
(settings as Record<keyof Settings, Settings[keyof 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) as [keyof Settings, Settings[keyof Settings]][]) {
if (value !== defaults[key]) params.set(key, JSON.stringify(value));
}
location.replace(`#${params.toString()}`);
}
window.onhashchange = loadSettings;
loadSettings();
document.head.appendChild(style);
// Settings UI
for (const key of ["cover", "mapInfo", "time", "score", "bsr", "debug"] as const) {
const input = must<HTMLInputElement>(`${key}Input`);
input.checked = settings[key];
input.oninput = () => {
settings[key] = input.checked;
saveSettings();
if (key === "debug") void loadRequestQueue();
};
}
const scale = must<HTMLInputElement>("scaleInput");
scale.valueAsNumber = settings.scale * 100;
scale.oninput = () => {
settings.scale = scale.valueAsNumber / 100;
saveSettings();
};
const position = must<HTMLSelectElement>("positionInput");
position.value = JSON.stringify([settings.right, settings.bottom]);
position.onchange = () => {
[settings.right, settings.bottom] = parseJson<[boolean, boolean]>(position.value);
saveSettings();
};
const fade = must<HTMLInputElement>("fadeInput");
fade.valueAsNumber = settings.fade;
fade.oninput = () => {
settings.fade = fade.valueAsNumber;
saveSettings();
};
document.documentElement.onclick = () => document.body.classList.toggle("preview");
must<HTMLElement>("settings").onclick = (e: MouseEvent) => e.stopPropagation();
// Song request queue (JSON from same origin as page; poll). See docs/testing.md for ?requests=
const MAX_REQUESTS = 10;
const REQUEST_POLL_MS = 5000;
const requestListEl = must<HTMLOListElement>("requestList");
const requestOverlayEl = must<HTMLElement>("requestOverlay");
const requestEmptyEl = must<HTMLElement>("requestEmpty");
const requestTitleCache = new Map<string, string>();
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: string) {
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() as Promise<ChatRequestPayload>;
});
}
async function loadRequestPayload() {
let lastErr: unknown;
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: ChatRequestEntry) {
const parts = [item.npr, item.rqn].filter(Boolean);
return parts.length ? parts.join(" ") : item.rqn || "";
}
async function enrichRequestTitle(key: string, titleEl: HTMLElement) {
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 {
// keep !bsr placeholder
}
}
function renderRequestList(items: ChatRequestEntry[]) {
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);