653 lines
21 KiB
TypeScript

import type {
BeatLeaderFollower,
BeatLeaderLeaderboard,
BeatLeaderScore,
BeatSaberPlusEvent,
ChatRequestEntry,
ChatRequestPayload,
FriendMode,
MapInfo,
OverlaySettings,
Score,
} from "./types.ts";
import { OVERLAY_SETTINGS_INITIAL } from "./types.ts";
import type { BeatSaverMap } from "./beatsaver.ts";
import { fetchBeatSaverMapById, fetchBeatSaverMeta } from "./beatsaver.ts";
import {
fetchAllMapScoresByHash,
fetchBLLeaderboardsByHash,
fetchFriends,
normalizeAccuracy,
} from "./beatleader.ts";
import { mergeOverlayConfigResponse, type OverlayConfigApiBody } from "./overlay-config.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;
}
type Settings = OverlaySettings;
const settings: Settings = structuredClone(OVERLAY_SETTINGS_INITIAL);
const defaults = structuredClone(OVERLAY_SETTINGS_INITIAL);
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()}`);
}
// 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;
case "handshake":
currentPlayerPlatformId = data.playerPlatformId || "";
void refreshMapFriendScores();
break;
default:
break;
}
},
};
const provider = beatSaberPlus;
const retryMs = 10000;
let currentPlayerPlatformId = "";
function getEffectivePlayerId() {
const configured = settings.beatLeaderId.trim();
const raw = configured || currentPlayerPlatformId;
if (!raw) return "";
const steamIdCandidate = raw.match(/\d{17,20}/)?.[0];
if (steamIdCandidate) return steamIdCandidate;
if (/^\d+$/.test(raw)) return raw;
if (configured) return raw;
return "";
}
let currentMapHash = "";
/** Cached BeatLeader following/followers/mutual list; refetch only when player id or friend mode changes. */
let friendsRelationCacheKey = "";
let friendsRelationCache: BeatLeaderFollower[] | null = null;
let friendScoreRequestId = 0;
let mapInfoRequestId = 0;
/** Hex hash from BS+ `level_id` (before BeatSaver version hash). */
let rawLevelHash = "";
function beatLeaderboardId(lb: BeatLeaderLeaderboard): string {
const id = lb.id ?? lb.leaderboardId;
return id == null ? "" : String(id);
}
function resolvedHashFromBeatSaverMap(map: BeatSaverMap, fallback: string): string {
const v = map.versions?.[0]?.hash;
if (typeof v === "string" && v.length > 0) return v.toLowerCase().trim();
return fallback;
}
/** BeatLeader indexes maps by 40-char hex hash; BeatSaver accepts the same hash or a short map key (`/maps/id/…`). */
function looksLikeBeatSaverHash(s: string): boolean {
return /^[0-9a-f]{40}$/i.test(s.trim());
}
async function fetchBeatSaverMapForDebug(id: string): Promise<BeatSaverMap | null> {
const t = id.trim();
if (!t) return null;
if (looksLikeBeatSaverHash(t)) return fetchBeatSaverMeta(t.toLowerCase());
return fetchBeatSaverMapById(t);
}
async function applyDebugSong() {
const raw = settings.debugSongId.trim();
if (!raw) return;
const reqId = ++mapInfoRequestId;
document.body.classList.add("loading");
try {
const map = await fetchBeatSaverMapForDebug(raw);
if (reqId !== mapInfoRequestId) return;
if (!map?.id) {
rawLevelHash = "";
currentMapHash = "";
cover.src = "images/unknown.svg";
title.textContent = "BeatSaver not found";
subTitle.textContent = raw;
artist.textContent = "";
mapper.textContent = "";
difficulty.textContent = "";
characteristic.src = "images/characteristic/Standard.svg";
difficultyLabel.textContent = "";
type.textContent = "";
bsrKey.textContent = "";
return;
}
const fallbackHash = looksLikeBeatSaverHash(raw) ? raw.toLowerCase().trim() : "";
const resolved = resolvedHashFromBeatSaverMap(map, fallbackHash);
rawLevelHash = resolved || fallbackHash;
currentMapHash = resolved || fallbackHash;
const v0 = map.versions?.[0];
const coverUrl = v0?.coverURL?.trim();
cover.src = coverUrl || "images/unknown.svg";
title.textContent = map.metadata?.songName ?? map.name ?? "";
subTitle.textContent = map.metadata?.songSubName ?? "";
artist.textContent = map.metadata?.songAuthorName ?? "";
mapper.textContent = map.metadata?.levelAuthorName ?? "";
const firstDiff = v0?.diffs?.[0];
difficulty.textContent = firstDiff?.difficulty?.replace("Plus", " +") ?? "—";
characteristic.src = firstDiff
? `images/characteristic/${firstDiff.characteristic}.svg`
: "images/characteristic/Standard.svg";
difficultyLabel.textContent = firstDiff?.label ?? "";
type.textContent = "Custom";
bsrKey.textContent = map.id;
timeMultiplier = 1;
duration = 180;
if (reqId === mapInfoRequestId) {
setTime(0);
}
} catch {
if (reqId !== mapInfoRequestId) return;
rawLevelHash = "";
currentMapHash = "";
cover.src = "images/unknown.svg";
title.textContent = "BeatSaver request failed";
subTitle.textContent = raw;
} finally {
if (reqId === mapInfoRequestId) document.body.classList.remove("loading");
if (reqId === mapInfoRequestId) void refreshMapFriendScores();
}
}
function connect() {
const ws = new WebSocket(provider.url);
ws.onmessage = provider.onMessage;
ws.onclose = onClose;
}
function onClose(_e: CloseEvent) {
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) {
if (settings.debugSongId.trim()) {
void applyDebugSong();
return;
}
const reqId = ++mapInfoRequestId;
const custom = data.level_id.startsWith("custom_level_");
const wip = custom && data.level_id.endsWith("WIP");
rawLevelHash = custom ? data.level_id.substring(13, 53).toLowerCase() : "";
currentMapHash = rawLevelHash;
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 = custom && !wip ? "…" : custom ? rawLevelHash || "???" : "???";
timeMultiplier = data.timeMultiplier || 1;
duration = data.duration / 1000;
if (custom && !wip) {
document.body.classList.add("loading");
try {
const map = await fetchBeatSaverMeta(rawLevelHash);
if (reqId !== mapInfoRequestId) return;
if (!map?.id) {
currentMapHash = rawLevelHash;
} else {
const resolved = resolvedHashFromBeatSaverMap(map, rawLevelHash);
currentMapHash = resolved;
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;
}
} catch {
if (reqId !== mapInfoRequestId) return;
currentMapHash = rawLevelHash;
} finally {
document.body.classList.remove("loading");
if (reqId === mapInfoRequestId) void refreshMapFriendScores();
}
} else {
if (custom && wip) {
bsrKey.textContent = rawLevelHash || "???";
} else {
bsrKey.textContent = "???";
}
difficultyLabel.textContent = "";
void refreshMapFriendScores();
}
}
// 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");
const friendScoresPanel = must<HTMLElement>("friendScores");
const friendScoresList = must<HTMLOListElement>("friendScoresList");
const friendScoresEmpty = must<HTMLElement>("friendScoresEmpty");
const friendScoresHeaderText = must<HTMLElement>("friendScoresHeaderText");
const friendScoresHeaderImg = must<HTMLImageElement>("friendScoresHeaderImg");
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);
}
function avatarFromScore(score: BeatLeaderScore): string | null {
if (typeof score.player === "object" && score.player?.avatar) {
return score.player.avatar;
}
const url = score.playerAvatar?.trim();
return url || null;
}
function clearFriendScores(message: string) {
friendScoresList.replaceChildren();
friendScoresEmpty.textContent = message;
friendScoresHeaderText.textContent = "frenz?";
friendScoresHeaderImg.src = "assets/notlikesteve.webp";
friendScoresPanel.classList.remove("has-items", "is-loading");
}
function renderFriendScores(items: Array<{ name: string; acc: number; avatar: string | null }>) {
friendScoresList.replaceChildren();
friendScoresPanel.classList.toggle("has-items", items.length > 0);
friendScoresPanel.classList.remove("is-loading");
friendScoresEmpty.textContent = items.length ? "" : "No friend scores on this map";
friendScoresHeaderText.textContent = items.length ? "frenz!" : "frenz?";
friendScoresHeaderImg.src = items.length ? "assets/peepohigh.webp" : "assets/notlikesteve.webp";
for (const item of items) {
const li = document.createElement("li");
li.className = "friend-score-item";
const avatar = document.createElement("img");
avatar.className = "friend-avatar";
avatar.alt = "";
avatar.decoding = "async";
avatar.loading = "lazy";
avatar.src = item.avatar?.trim() || "images/unknown.svg";
const name = document.createElement("span");
name.className = "friend-name";
name.textContent = item.name;
const acc = document.createElement("span");
acc.className = "friend-acc";
acc.textContent = `${item.acc.toFixed(2)}%`;
li.append(acc, avatar, name);
friendScoresList.appendChild(li);
}
}
function friendsRelationListKey(playerId: string): string {
return `${playerId}\0${settings.friendMode}`;
}
async function refreshMapFriendScores() {
const hash = currentMapHash;
if (!settings.friends) {
clearFriendScores("Disabled in settings");
return;
}
if (!hash) {
clearFriendScores("No map loaded");
return;
}
const playerId = getEffectivePlayerId();
if (!playerId) {
clearFriendScores("Waiting for BeatLeader player id");
return;
}
friendScoresPanel.classList.add("is-loading");
friendScoresEmpty.textContent = "Loading mutual friend scores...";
const requestId = ++friendScoreRequestId;
try {
const relKey = friendsRelationListKey(playerId);
const friendsPromise: Promise<BeatLeaderFollower[]> = (async () => {
if (friendsRelationCache !== null && relKey === friendsRelationCacheKey) {
return friendsRelationCache;
}
const fetched = await fetchFriends(playerId, settings.friendMode);
friendsRelationCacheKey = relKey;
friendsRelationCache = fetched;
return fetched;
})();
const [leaderboards, friends] = await Promise.all([
fetchBLLeaderboardsByHash(hash),
friendsPromise,
]);
if (requestId !== friendScoreRequestId) return;
if (leaderboards.length === 0) {
clearFriendScores("No BeatLeader leaderboards found");
return;
}
const friendById = new Map(friends.map((f) => [f.id, f]));
const mutualFriendIds = new Set(friends.map((f) => f.id));
if (mutualFriendIds.size === 0) {
const relationLabel = settings.friendMode === "following"
? "No followed BeatLeader players"
: settings.friendMode === "followers"
? "No BeatLeader followers"
: "No mutual BeatLeader followers";
clearFriendScores(relationLabel);
return;
}
const scores = await fetchAllMapScoresByHash(hash, leaderboards);
if (requestId !== friendScoreRequestId) return;
const bestByPlayer = new Map<string, { name: string; acc: number; avatar: string | null }>();
for (const score of scores) {
const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null);
const playerKey = scorePlayerId == null ? "" : String(scorePlayerId);
if (!playerKey || !mutualFriendIds.has(playerKey)) continue;
const acc = normalizeAccuracy(score.accuracy ?? score.acc);
if (acc === null) continue;
const existing = bestByPlayer.get(playerKey);
if (!existing || acc > existing.acc) {
const friendMeta = friendById.get(playerKey);
const playerName =
score.playerName ||
(typeof score.player === "object" ? score.player?.name : typeof score.player === "string" ? score.player : null);
const fromScore = avatarFromScore(score);
const fromFriend = friendMeta?.avatar?.trim() || null;
bestByPlayer.set(playerKey, {
name: playerName || friendMeta?.name || playerKey,
acc,
avatar: fromScore ?? fromFriend,
});
}
}
const sorted = Array.from(bestByPlayer.values()).sort((a, b) => b.acc - a.acc);
renderFriendScores(sorted);
} catch {
if (requestId !== friendScoreRequestId) return;
clearFriendScores("Failed loading BeatLeader scores");
}
}
window.onhashchange = () => {
loadSettings();
const debugEl = document.getElementById("debugSongIdInput") as HTMLInputElement | null;
if (debugEl) debugEl.value = settings.debugSongId;
if (settings.debugSongId.trim()) void applyDebugSong();
else {
mapInfoRequestId += 1;
currentMapHash = "";
rawLevelHash = "";
void refreshMapFriendScores();
}
};
// Song request queue (JSON from same origin as page; poll)
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>();
const requestTitleMisses = new Set<string>();
function loadChatRequestJson() {
const base = new URL("ChatRequest.json", location.href);
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>;
});
}
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 (requestTitleMisses.has(key)) return;
if (requestTitleCache.has(key)) {
titleEl.textContent = requestTitleCache.get(key) ?? "";
return;
}
try {
const map = await fetchBeatSaverMapById(key);
if (!map) {
requestTitleMisses.add(key);
return;
}
const name = map.metadata?.songName ?? map.name;
if (name && typeof name === "string") {
requestTitleCache.set(key, name);
titleEl.textContent = name;
return;
}
requestTitleMisses.add(key);
} catch {
requestTitleMisses.add(key);
}
}
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 loadChatRequestJson();
requestEmptyEl.textContent = "No pending requests";
requestOverlayEl.classList.remove("request-load-failed");
const items = (data.queue ?? []).slice(0, MAX_REQUESTS);
renderRequestList(items);
} catch {
requestEmptyEl.textContent = "Request queue unavailable";
requestOverlayEl.classList.add("request-load-failed");
renderRequestList([]);
}
}
async function bootstrap() {
Object.assign(defaults, OVERLAY_SETTINGS_INITIAL);
try {
const url = new URL("/api/overlay-config", location.href);
const res = await fetch(url, { cache: "no-store" });
if (res.ok) {
const data = await res.json() as OverlayConfigApiBody;
mergeOverlayConfigResponse(defaults, data);
}
} catch {
// keep OVERLAY_SETTINGS_INITIAL (e.g. file:// or static hosting)
}
loadSettings();
document.head.appendChild(style);
if (settings.debugSongId.trim()) void applyDebugSong();
else void refreshMapFriendScores();
// Settings UI
for (const key of ["cover", "mapInfo", "time", "score", "friends", "bsr"] as const) {
const input = must<HTMLInputElement>(`${key}Input`);
input.checked = settings[key];
input.oninput = () => {
settings[key] = input.checked;
saveSettings();
if (key === "friends") void refreshMapFriendScores();
};
}
const friendModeInput = must<HTMLSelectElement>("friendModeInput");
friendModeInput.value = settings.friendMode;
friendModeInput.onchange = () => {
settings.friendMode = friendModeInput.value as FriendMode;
saveSettings();
void refreshMapFriendScores();
};
const beatLeaderPlayerInput = must<HTMLInputElement>("beatLeaderPlayerInput");
beatLeaderPlayerInput.value = settings.beatLeaderId;
beatLeaderPlayerInput.oninput = () => {
settings.beatLeaderId = beatLeaderPlayerInput.value.trim();
saveSettings();
void refreshMapFriendScores();
};
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();
};
const debugSongIdInput = must<HTMLInputElement>("debugSongIdInput");
debugSongIdInput.value = settings.debugSongId;
debugSongIdInput.oninput = () => {
settings.debugSongId = debugSongIdInput.value;
saveSettings();
if (!settings.debugSongId.trim()) {
mapInfoRequestId += 1;
currentMapHash = "";
rawLevelHash = "";
void refreshMapFriendScores();
} else {
void applyDebugSong();
}
};
document.documentElement.onclick = () => document.body.classList.toggle("preview");
must<HTMLElement>("settings").onclick = (e: MouseEvent) => e.stopPropagation();
void loadRequestQueue();
window.setInterval(() => void loadRequestQueue(), REQUEST_POLL_MS);
}
void bootstrap();