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(id: string): T { const element = document.getElementById(id); if (!element) throw new Error(`Missing element: ${id}`); return element as T; } function parseJson(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(params.get(key) || "null") ?? def); (settings as Record)[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) => { 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; 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 { 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("coverImg"); const title = must("title"); const subTitle = must("subTitle"); const artist = must("artist"); const mapper = must("mapper"); const difficulty = must("difficulty"); const characteristic = must("characteristicImg"); const difficultyLabel = must("difficultyLabel"); const type = must("type"); const bsrKey = must("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("timeText"); const timeBar = must("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("accuracy"); const mistakes = must("mistakes"); const friendScoresPanel = must("friendScores"); const friendScoresList = must("friendScoresList"); const friendScoresEmpty = must("friendScoresEmpty"); const friendScoresHeaderText = must("friendScoresHeaderText"); const friendScoresHeaderImg = must("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 = (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(); 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("requestList"); const requestOverlayEl = must("requestOverlay"); const requestEmptyEl = must("requestEmpty"); const requestTitleCache = new Map(); const requestTitleMisses = new Set(); 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; }); } 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(`${key}Input`); input.checked = settings[key]; input.oninput = () => { settings[key] = input.checked; saveSettings(); if (key === "friends") void refreshMapFriendScores(); }; } const friendModeInput = must("friendModeInput"); friendModeInput.value = settings.friendMode; friendModeInput.onchange = () => { settings.friendMode = friendModeInput.value as FriendMode; saveSettings(); void refreshMapFriendScores(); }; const beatLeaderPlayerInput = must("beatLeaderPlayerInput"); beatLeaderPlayerInput.value = settings.beatLeaderId; beatLeaderPlayerInput.oninput = () => { settings.beatLeaderId = beatLeaderPlayerInput.value.trim(); saveSettings(); void refreshMapFriendScores(); }; const scale = must("scaleInput"); scale.valueAsNumber = settings.scale * 100; scale.oninput = () => { settings.scale = scale.valueAsNumber / 100; saveSettings(); }; const position = must("positionInput"); position.value = JSON.stringify([settings.right, settings.bottom]); position.onchange = () => { [settings.right, settings.bottom] = parseJson<[boolean, boolean]>(position.value); saveSettings(); }; const fade = must("fadeInput"); fade.valueAsNumber = settings.fade; fade.oninput = () => { settings.fade = fade.valueAsNumber; saveSettings(); }; const debugSongIdInput = must("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("settings").onclick = (e: MouseEvent) => e.stopPropagation(); void loadRequestQueue(); window.setInterval(() => void loadRequestQueue(), REQUEST_POLL_MS); } void bootstrap();