Actually paginate leaderboards

This commit is contained in:
pleb
2026-04-11 19:16:54 -07:00
parent 8ed38d05a7
commit 9dbd17eb49
7 changed files with 1272 additions and 417 deletions
+42 -6
View File
@@ -47,6 +47,46 @@ body {
flex-direction: column; flex-direction: column;
} }
/* Debug HUD (body.debug): below main overlay, still visible during body.loading */
#debugHud {
display: none;
position: absolute;
left: 0;
bottom: 0;
z-index: 6;
max-width: min(100%, 48rem);
padding: 0.35rem 0.55rem;
font-size: 1.15rem;
font-weight: 600;
line-height: 1.3;
white-space: normal;
background: rgba(0, 0, 0, 0.62);
border-radius: 0.35rem;
pointer-events: none;
}
body.debug #debugHud {
display: block;
}
#debugHud .debugHud-title {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 0.15rem;
opacity: 0.95;
}
#debugHud .debugHud-k {
opacity: 0.72;
margin-right: 0.25rem;
}
#debugHud code {
font-family: ui-monospace, "Cascadia Code", monospace;
font-size: 0.92em;
word-break: break-all;
}
#songOverlay { #songOverlay {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -349,8 +389,8 @@ span:empty {
} }
.friend-avatar { .friend-avatar {
width: 32px; width: 1.5rem;
height: 32px; height: 1.5rem;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
flex-shrink: 0; flex-shrink: 0;
@@ -416,7 +456,3 @@ body.bottom #time {
body:not(.debug) #mockBsrSetting { body:not(.debug) #mockBsrSetting {
display: none; display: none;
} }
body:not(.debug) #debugPlayerSetting {
display: none;
}
+15 -1
View File
@@ -44,6 +44,20 @@
<div id="friendScoresEmpty">No map loaded</div> <div id="friendScoresEmpty">No map loaded</div>
</div> </div>
</div> </div>
<div id="debugHud">
<div class="debugHud-title">Debug</div>
<div><span class="debugHud-k">level_id</span> <code id="debugHudLevelId"></code></div>
<div><span class="debugHud-k">hash (level_id)</span> <code id="debugHudRawHash"></code></div>
<div><span class="debugHud-k">hash (BeatLeader)</span> <code id="debugHudHash"></code></div>
<div><span class="debugHud-k">BS+ BSRKey</span> <code id="debugHudBsPlusBsr"></code></div>
<div><span class="debugHud-k">BeatSaver id</span> <code id="debugHudBeatSaverId"></code></div>
<div><span class="debugHud-k">BeatSaver</span> <span id="debugHudBeatSaverNote"></span></div>
<div><span class="debugHud-k">char / diff</span> <span id="debugHudCharDiff"></span></div>
<div><span class="debugHud-k">BS+ playerPlatformId</span> <code id="debugHudHandshake"></code></div>
<div><span class="debugHud-k">BeatLeader id (effective)</span> <code id="debugHudBlId"></code></div>
<div><span class="debugHud-k">BeatLeader leaderboard ids</span> <code id="debugHudBlLeaderboards"></code></div>
<div><span class="debugHud-k">friend scores</span> <span id="debugHudFriends"></span></div>
</div>
<div id="requestOverlay" aria-live="polite"> <div id="requestOverlay" aria-live="polite">
<div id="requestHeader">Song requests</div> <div id="requestHeader">Song requests</div>
<ol id="requestList"></ol> <ol id="requestList"></ol>
@@ -71,6 +85,7 @@
<option value="following">Following (I follow them)</option> <option value="following">Following (I follow them)</option>
<option value="followers">Followers (they follow me)</option> <option value="followers">Followers (they follow me)</option>
</select></label> </select></label>
<label id="beatLeaderPlayerSetting">BeatLeader player id: <input id="beatLeaderPlayerInput" type="text" placeholder="7656119… or alias"></label>
<label>Show BSR / map id: <input id="bsrInput" type="checkbox"></label> <label>Show BSR / map id: <input id="bsrInput" type="checkbox"></label>
<label>Position: <select id="positionInput"> <label>Position: <select id="positionInput">
<option value="[false,false]">Top left</option> <option value="[false,false]">Top left</option>
@@ -82,7 +97,6 @@
<label>Fade (ms): <input id="fadeInput" type="number" min="0" max="5000" step="10"></label> <label>Fade (ms): <input id="fadeInput" type="number" min="0" max="5000" step="10"></label>
<label>Debug: <input id="debugInput" type="checkbox"></label> <label>Debug: <input id="debugInput" type="checkbox"></label>
<label id="mockBsrSetting">Mock BSR key: <input id="mockBsrInput" type="text" placeholder="e.g. 4f4e4"></label> <label id="mockBsrSetting">Mock BSR key: <input id="mockBsrInput" type="text" placeholder="e.g. 4f4e4"></label>
<label id="debugPlayerSetting">Mock BeatLeader player id: <input id="debugPlayerInput" type="text" placeholder="7656119..."></label>
<br> <br>
<strong>About</strong> <strong>About</strong>
<a href="https://github.com/ibillingsley/BeatSaber-Overlay" target="_blank">This was forked from Iza's overlay</a> <a href="https://github.com/ibillingsley/BeatSaber-Overlay" target="_blank">This was forked from Iza's overlay</a>
+532 -88
View File
@@ -26,10 +26,36 @@ async function fetchBeatSaverMapById(mapId) {
} }
} }
// src/client/overlay-server-log.ts
function mirrorOverlayLog(scope, phase, detail) {
if (typeof location === "undefined") return;
if (location.protocol !== "http:" && location.protocol !== "https:") return;
const body = JSON.stringify({
scope,
phase,
detail
});
void fetch("/api/overlay-log", {
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8"
},
body,
keepalive: true
}).catch(() => {
});
}
// src/client/beatleader.ts // src/client/beatleader.ts
var BASE_URL2 = "https://api.beatleader.com"; var BASE_URL2 = "https://api.beatleader.com";
var PAGE_SIZE = 100; var PAGE_SIZE = 100;
var MAX_LEADERBOARD_SCORE_PAGES = 2e3;
var USE_RUNTIME_PROXY2 = typeof document !== "undefined"; var USE_RUNTIME_PROXY2 = typeof document !== "undefined";
function blDiag(phase, detail) {
if (!USE_RUNTIME_PROXY2) return;
console.log(`[BS+ overlay] beatleader:${phase}`, detail);
mirrorOverlayLog("beatleader", phase, detail);
}
function beatleaderUrl(path) { function beatleaderUrl(path) {
if (USE_RUNTIME_PROXY2) { if (USE_RUNTIME_PROXY2) {
return `/api/beatleader?path=${encodeURIComponent(path)}`; return `/api/beatleader?path=${encodeURIComponent(path)}`;
@@ -37,60 +63,168 @@ function beatleaderUrl(path) {
return `${BASE_URL2}${path}`; return `${BASE_URL2}${path}`;
} }
async function fetchBLLeaderboardsByHash(hash) { async function fetchBLLeaderboardsByHash(hash) {
const path = `/leaderboards/hash/${encodeURIComponent(hash)}`;
blDiag("leaderboardsByHash", {
path,
hash
});
try { try {
const res = await fetch(beatleaderUrl(`/leaderboards/hash/${encodeURIComponent(hash)}`)); const res = await fetch(beatleaderUrl(path));
if (!res.ok) return []; if (!res.ok) {
blDiag("leaderboardsByHash", {
path,
hash,
reason: "http-not-ok",
status: res.status,
statusText: res.statusText
});
return [];
}
const data = await res.json(); const data = await res.json();
const leaderboards = Array.isArray(data) ? data : Array.isArray(data.leaderboards) ? data.leaderboards : []; const leaderboards = Array.isArray(data) ? data : Array.isArray(data.leaderboards) ? data.leaderboards : [];
blDiag("leaderboardsByHash", {
path,
hash,
count: leaderboards.length
});
return leaderboards; return leaderboards;
} catch { } catch (err) {
blDiag("leaderboardsByHash", {
path,
hash,
reason: "fetch-error",
error: String(err)
});
return []; return [];
} }
} }
async function resolveBeatLeaderPlayerId(playerId) { async function resolveBeatLeaderPlayerId(playerId) {
const path = `/player/${encodeURIComponent(playerId)}`;
blDiag("resolvePlayer", {
path,
playerId
});
try { try {
const res = await fetch(beatleaderUrl(`/player/${encodeURIComponent(playerId)}`)); const res = await fetch(beatleaderUrl(path));
if (!res.ok) return playerId; if (!res.ok) {
blDiag("resolvePlayer", {
path,
playerId,
reason: "http-not-ok",
status: res.status,
usingId: playerId
});
return playerId;
}
const data = await res.json(); const data = await res.json();
const canonicalId = data.id; const canonicalId = data.id;
return canonicalId == null ? playerId : String(canonicalId); const out = canonicalId == null ? playerId : String(canonicalId);
} catch { blDiag("resolvePlayer", {
path,
playerId,
canonicalId: out,
changed: out !== playerId
});
return out;
} catch (err) {
blDiag("resolvePlayer", {
path,
playerId,
reason: "fetch-error",
error: String(err),
usingId: playerId
});
return playerId; return playerId;
} }
} }
async function fetchLeaderboardScoresById(leaderboardId, maxPages = 20) { async function fetchLeaderboardScoresById(leaderboardId, maxPages = MAX_LEADERBOARD_SCORE_PAGES) {
const scores = []; const scores = [];
for (let page = 1; page <= maxPages; page += 1) { const pageSize = PAGE_SIZE;
let page = 1;
let hitPageCap = false;
for (; ; ) {
if (page > maxPages) {
hitPageCap = true;
break;
}
const qs = new URLSearchParams({ const qs = new URLSearchParams({
leaderboardContext: "general", leaderboardContext: "general",
page: String(page), page: String(page),
sortBy: "rank", sortBy: "rank",
order: "desc" order: "desc",
count: String(pageSize)
}); });
const url = beatleaderUrl(`/leaderboard/${encodeURIComponent(leaderboardId)}?${qs}`); const path = `/leaderboard/${encodeURIComponent(leaderboardId)}?${qs}`;
const url = beatleaderUrl(path);
let res; let res;
try { try {
res = await fetch(url); res = await fetch(url);
} catch { } catch (err) {
blDiag("leaderboardScores", {
leaderboardId,
page,
path,
reason: "fetch-error",
error: String(err)
});
break;
}
if (!res.ok) {
blDiag("leaderboardScores", {
leaderboardId,
page,
path,
reason: "http-not-ok",
status: res.status,
statusText: res.statusText
});
break; break;
} }
if (!res.ok) break;
const payload = await res.json(); const payload = await res.json();
const batch = Array.isArray(payload.scores) ? payload.scores : []; const batch = Array.isArray(payload.scores) ? payload.scores : [];
if (page === 1) {
blDiag("leaderboardScores", {
leaderboardId,
page,
path,
firstPageBatchSize: batch.length,
pageSize
});
}
if (batch.length === 0) break; if (batch.length === 0) break;
scores.push(...batch); scores.push(...batch);
if (batch.length < PAGE_SIZE) break; if (batch.length < pageSize) break;
page += 1;
} }
blDiag("leaderboardScoresTotal", {
leaderboardId,
totalScores: scores.length,
...hitPageCap ? {
warning: "hit-max-pages-cap",
maxPages
} : {}
});
return scores; return scores;
} }
async function fetchAllMapScoresByHash(hash, leaderboards, maxPagesPerLeaderboard = 20) { async function fetchAllMapScoresByHash(hash, leaderboards, maxPagesPerLeaderboard = MAX_LEADERBOARD_SCORE_PAGES) {
const ids = leaderboards.map((lb) => lb.id == null ? "" : String(lb.id)).filter(Boolean);
blDiag("fetchAllMapScoresByHash", {
hash,
leaderboardCount: leaderboards.length,
leaderboardIds: ids
});
const requests = leaderboards.map((lb) => { const requests = leaderboards.map((lb) => {
const leaderboardId = lb.id == null ? null : String(lb.id); const leaderboardId = lb.id == null ? null : String(lb.id);
if (!leaderboardId) return Promise.resolve([]); if (!leaderboardId) return Promise.resolve([]);
return fetchLeaderboardScoresById(leaderboardId, maxPagesPerLeaderboard); return fetchLeaderboardScoresById(leaderboardId, maxPagesPerLeaderboard);
}); });
const batches = await Promise.all(requests); const batches = await Promise.all(requests);
return batches.flat(); const flat = batches.flat();
blDiag("fetchAllMapScoresByHash", {
hash,
totalScores: flat.length
});
return flat;
} }
async function fetchFollowersPage(playerId, type2, page, count) { async function fetchFollowersPage(playerId, type2, page, count) {
const qs = new URLSearchParams({ const qs = new URLSearchParams({
@@ -98,13 +232,43 @@ async function fetchFollowersPage(playerId, type2, page, count) {
page: String(page), page: String(page),
count: String(count) count: String(count)
}); });
const url = beatleaderUrl(`/player/${encodeURIComponent(playerId)}/followers?${qs}`); const path = `/player/${encodeURIComponent(playerId)}/followers?${qs}`;
const url = beatleaderUrl(path);
try { try {
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) return []; if (!response.ok) {
blDiag("followersPage", {
playerId,
type: type2,
page,
path,
reason: "http-not-ok",
status: response.status,
statusText: response.statusText
});
return [];
}
const data = await response.json(); const data = await response.json();
return Array.isArray(data) ? data : []; const rows = Array.isArray(data) ? data : [];
} catch { if (page === 1) {
blDiag("followersPage", {
playerId,
type: type2,
page,
path,
count: rows.length
});
}
return rows;
} catch (err) {
blDiag("followersPage", {
playerId,
type: type2,
page,
path,
reason: "fetch-error",
error: String(err)
});
return []; return [];
} }
} }
@@ -126,18 +290,46 @@ function normalizeFollowerEntry(entry) {
} }
async function fetchFriends(playerId, mode, maxPages = 100) { async function fetchFriends(playerId, mode, maxPages = 100) {
const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId); const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId);
blDiag("fetchFriendsStart", {
playerId,
canonicalPlayerId,
mode,
maxPages
});
const [following, followers] = await Promise.all([ const [following, followers] = await Promise.all([
fetchAllFollowers(canonicalPlayerId, "Following", maxPages), fetchAllFollowers(canonicalPlayerId, "Following", maxPages),
fetchAllFollowers(canonicalPlayerId, "Followers", maxPages) fetchAllFollowers(canonicalPlayerId, "Followers", maxPages)
]); ]);
blDiag("fetchFriendsLists", {
canonicalPlayerId,
mode,
followingCount: following.length,
followersCount: followers.length
});
const followingIds = new Set(following.map((entry) => String(entry.id))); const followingIds = new Set(following.map((entry) => String(entry.id)));
if (mode === "following") { if (mode === "following") {
return following.map((entry) => normalizeFollowerEntry(entry)); const out2 = following.map((entry) => normalizeFollowerEntry(entry));
blDiag("fetchFriendsResult", {
mode,
count: out2.length
});
return out2;
} }
if (mode === "followers") { if (mode === "followers") {
return followers.map((entry) => normalizeFollowerEntry(entry)); const out2 = followers.map((entry) => normalizeFollowerEntry(entry));
blDiag("fetchFriendsResult", {
mode,
count: out2.length
});
return out2;
} }
return followers.filter((entry) => followingIds.has(String(entry.id))).map((entry) => normalizeFollowerEntry(entry)); const out = followers.filter((entry) => followingIds.has(String(entry.id))).map((entry) => normalizeFollowerEntry(entry));
blDiag("fetchFriendsResult", {
mode: "mutual",
count: out.length,
reasonIfEmpty: out.length === 0 ? following.length === 0 || followers.length === 0 ? "missing-following-or-followers-list" : "no-intersection" : void 0
});
return out;
} }
function normalizeAccuracy(value) { function normalizeAccuracy(value) {
if (typeof value !== "number" || !Number.isFinite(value)) return null; if (typeof value !== "number" || !Number.isFinite(value)) return null;
@@ -153,6 +345,42 @@ function must(id) {
function parseJson(raw) { function parseJson(raw) {
return JSON.parse(raw); return JSON.parse(raw);
} }
var settings = {
cover: true,
mapInfo: true,
time: true,
score: true,
friends: true,
friendMode: "mutual",
bsr: false,
debug: false,
mockBsr: "4f4e4",
debugPlayerId: "76561199407393962",
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()}`);
}
var beatSaberPlus = { var beatSaberPlus = {
// https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay // https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay
url: "ws://localhost:2947/socket", url: "ws://localhost:2947/socket",
@@ -180,6 +408,10 @@ var beatSaberPlus = {
break; break;
case "handshake": case "handshake":
currentPlayerPlatformId = data.playerPlatformId || ""; currentPlayerPlatformId = data.playerPlatformId || "";
console.log("[BS+ overlay] BS+ handshake", {
playerPlatformId: currentPlayerPlatformId || "(empty)"
});
updateDebugHud();
void refreshMapFriendScores(); void refreshMapFriendScores();
break; break;
default: default:
@@ -192,6 +424,68 @@ var provider = beatSaberPlus;
var retryMs = 1e4; var retryMs = 1e4;
var retries = 0; var retries = 0;
var currentPlayerPlatformId = ""; var currentPlayerPlatformId = "";
function getEffectivePlayerId() {
const configured = settings.debugPlayerId.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 "";
}
var currentMapHash = "";
var friendsRelationCacheKey = "";
var friendsRelationCache = null;
var friendScoreRequestId = 0;
var mapInfoRequestId = 0;
var lastMapLevelId = "";
var lastBsPlusBsrKey = "";
var lastCharDiffStr = "";
var lastBeatSaverIdDisplay = "\u2014";
var lastBeatSaverNote = "\u2014";
var lastFriendScoresDebug = "\u2014";
var rawLevelHash = "";
var lastBeatLeaderLeaderboardIds = "\u2014";
function formatErr(err) {
return err instanceof Error ? err.message : String(err);
}
var debugHud = {
levelId: must("debugHudLevelId"),
rawHash: must("debugHudRawHash"),
hash: must("debugHudHash"),
bsPlusBsr: must("debugHudBsPlusBsr"),
beatSaverId: must("debugHudBeatSaverId"),
beatSaverNote: must("debugHudBeatSaverNote"),
charDiff: must("debugHudCharDiff"),
handshake: must("debugHudHandshake"),
blId: must("debugHudBlId"),
blLeaderboards: must("debugHudBlLeaderboards"),
friends: must("debugHudFriends")
};
function updateDebugHud() {
if (!settings.debug) return;
debugHud.levelId.textContent = lastMapLevelId || "\u2014";
debugHud.rawHash.textContent = rawLevelHash || "\u2014";
debugHud.hash.textContent = currentMapHash || "\u2014";
debugHud.bsPlusBsr.textContent = lastBsPlusBsrKey || "\u2014";
debugHud.beatSaverId.textContent = lastBeatSaverIdDisplay;
debugHud.beatSaverNote.textContent = lastBeatSaverNote;
debugHud.charDiff.textContent = lastCharDiffStr || "\u2014";
debugHud.handshake.textContent = currentPlayerPlatformId || "\u2014";
debugHud.blId.textContent = getEffectivePlayerId() || "\u2014";
debugHud.blLeaderboards.textContent = lastBeatLeaderLeaderboardIds;
debugHud.friends.textContent = lastFriendScoresDebug;
}
function beatLeaderboardId(lb) {
const id = lb.id ?? lb.leaderboardId;
return id == null ? "" : String(id);
}
function resolvedHashFromBeatSaverMap(map, fallback) {
const v = map.versions?.[0]?.hash;
if (typeof v === "string" && v.length > 0) return v.toLowerCase().trim();
return fallback;
}
function connect() { function connect() {
console.log(`Connecting to ${provider.url} (attempt ${retries++})`); console.log(`Connecting to ${provider.url} (attempt ${retries++})`);
const ws = new WebSocket(provider.url); const ws = new WebSocket(provider.url);
@@ -221,9 +515,31 @@ var bsrKey = must("bsrKey");
var timeMultiplier = 1; var timeMultiplier = 1;
var duration = 0; var duration = 0;
async function updateMapInfo(data) { async function updateMapInfo(data) {
const reqId = ++mapInfoRequestId;
const custom = data.level_id.startsWith("custom_level_"); const custom = data.level_id.startsWith("custom_level_");
const wip = custom && data.level_id.endsWith("WIP"); const wip = custom && data.level_id.endsWith("WIP");
currentMapHash = custom ? data.level_id.substring(13, 53).toLowerCase() : ""; rawLevelHash = custom ? data.level_id.substring(13, 53).toLowerCase() : "";
currentMapHash = rawLevelHash;
lastMapLevelId = data.level_id;
lastBsPlusBsrKey = data.BSRKey || "";
lastCharDiffStr = `${data.characteristic} / ${data.difficulty}`;
lastBeatLeaderLeaderboardIds = "\u2014";
console.log("[BS+ overlay] map: new song", {
level_id: data.level_id,
hashLevelId: rawLevelHash || "(none)",
custom,
wip,
bsPlusBsrKey: data.BSRKey || "(empty, not used for APIs)",
characteristic: data.characteristic,
difficulty: data.difficulty
});
if (settings.debug) {
console.log("[BS+ overlay] map: detail", {
requestId: reqId,
name: data.name,
artist: data.artist
});
}
cover.src = data.coverRaw ? `data:image/jpeg;base64,${data.coverRaw}` : "images/unknown.svg"; cover.src = data.coverRaw ? `data:image/jpeg;base64,${data.coverRaw}` : "images/unknown.svg";
title.textContent = data.name || ""; title.textContent = data.name || "";
subTitle.textContent = data.sub_name || ""; subTitle.textContent = data.sub_name || "";
@@ -233,25 +549,68 @@ async function updateMapInfo(data) {
characteristic.src = `images/characteristic/${data.characteristic}.svg`; characteristic.src = `images/characteristic/${data.characteristic}.svg`;
difficultyLabel.textContent = ""; difficultyLabel.textContent = "";
type.textContent = !custom ? "OST" : wip ? "WIP" : ""; type.textContent = !custom ? "OST" : wip ? "WIP" : "";
bsrKey.textContent = data.BSRKey || "???"; bsrKey.textContent = custom && !wip ? "\u2026" : custom ? rawLevelHash || "???" : "???";
timeMultiplier = data.timeMultiplier || 1; timeMultiplier = data.timeMultiplier || 1;
duration = data.duration / 1e3; duration = data.duration / 1e3;
void refreshMapFriendScores(); lastBeatSaverIdDisplay = "\u2014";
lastBeatSaverNote = custom && !wip ? "loading\u2026" : wip ? "WIP (no BeatSaver)" : !custom ? "OST (no hash)" : "\u2014";
updateDebugHud();
if (custom && !wip) { if (custom && !wip) {
document.body.classList.add("loading"); document.body.classList.add("loading");
try { try {
const map = await fetchBeatSaverMeta(currentMapHash); console.log("[BS+ overlay] map: BeatSaver lookup by hash (from level_id)", rawLevelHash);
if (!map?.id) return; const map = await fetchBeatSaverMeta(rawLevelHash);
bsrKey.textContent = map.id; if (reqId !== mapInfoRequestId) return;
mapper.textContent = map.metadata?.levelAuthorName || ""; if (!map?.id) {
const diff = map.versions?.[0]?.diffs?.find((d) => d.characteristic === data.characteristic && d.difficulty === data.difficulty); lastBeatSaverIdDisplay = "\u2014";
if (diff?.label) difficultyLabel.textContent = diff.label; lastBeatSaverNote = "BeatSaver: no map (check hash / proxy)";
currentMapHash = rawLevelHash;
console.warn("[BS+ overlay] map: BeatSaver miss \u2014 BeatLeader will use level_id hash", {
hashLevelId: rawLevelHash
});
} else {
lastBeatSaverIdDisplay = map.id;
lastBeatSaverNote = "ok";
const resolved = resolvedHashFromBeatSaverMap(map, rawLevelHash);
if (resolved !== rawLevelHash) {
console.log("[BS+ overlay] map: using BeatSaver version hash for BeatLeader", {
hashLevelId: rawLevelHash,
hashBeatLeader: resolved,
beatSaverId: map.id
});
}
currentMapHash = resolved;
console.log("[BS+ overlay] map: BeatSaver ok", {
beatSaverId: map.id,
hashBeatLeader: currentMapHash
});
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 (err) {
if (reqId !== mapInfoRequestId) return;
lastBeatSaverIdDisplay = "\u2014";
lastBeatSaverNote = `error: ${formatErr(err)}`;
currentMapHash = rawLevelHash;
console.error("[BS+ overlay] map: BeatSaver fetch failed \u2014 BeatLeader will use level_id hash", err);
} finally { } finally {
document.body.classList.remove("loading"); document.body.classList.remove("loading");
if (reqId === mapInfoRequestId) {
updateDebugHud();
void refreshMapFriendScores();
}
} }
} else { } else {
bsrKey.textContent = "???"; if (custom && wip) {
bsrKey.textContent = rawLevelHash || "???";
} else {
bsrKey.textContent = "???";
}
difficultyLabel.textContent = ""; difficultyLabel.textContent = "";
updateDebugHud();
void refreshMapFriendScores();
} }
} }
var timeText = must("timeText"); var timeText = must("timeText");
@@ -284,8 +643,6 @@ var friendScoresList = must("friendScoresList");
var friendScoresEmpty = must("friendScoresEmpty"); var friendScoresEmpty = must("friendScoresEmpty");
var friendScoresHeaderText = must("friendScoresHeaderText"); var friendScoresHeaderText = must("friendScoresHeaderText");
var friendScoresHeaderImg = must("friendScoresHeaderImg"); var friendScoresHeaderImg = must("friendScoresHeaderImg");
var currentMapHash = "";
var friendScoreRequestId = 0;
function updateScore(score) { function updateScore(score) {
if (!settings.score) return; if (!settings.score) return;
accuracy.textContent = (score.accuracy * 100).toFixed(1); accuracy.textContent = (score.accuracy * 100).toFixed(1);
@@ -332,31 +689,99 @@ function renderFriendScores(items) {
friendScoresList.appendChild(li); friendScoresList.appendChild(li);
} }
} }
function friendsDiag(message, detail = {}) {
if (!settings.friends) return;
console.log(`[BS+ overlay] friends:${message}`, detail);
mirrorOverlayLog("friends", message, detail);
}
function friendsRelationListKey(playerId) {
return `${playerId}\0${settings.friendMode}`;
}
async function refreshMapFriendScores() { async function refreshMapFriendScores() {
const hash = currentMapHash; const hash = currentMapHash;
if (!settings.friends) { if (!settings.friends) {
lastFriendScoresDebug = "off";
lastBeatLeaderLeaderboardIds = "\u2014";
updateDebugHud();
clearFriendScores("Disabled in settings"); clearFriendScores("Disabled in settings");
return; return;
} }
if (!hash) { if (!hash) {
friendsDiag("skip", {
reason: "no-map-hash",
hint: "Need custom map level_id hash or resolved BeatSaver hash"
});
lastFriendScoresDebug = "no hash";
lastBeatLeaderLeaderboardIds = "\u2014";
updateDebugHud();
clearFriendScores("No map loaded"); clearFriendScores("No map loaded");
return; return;
} }
const playerId = getEffectivePlayerId(); const playerId = getEffectivePlayerId();
if (!playerId) { if (!playerId) {
friendsDiag("skip", {
reason: "no-player-id",
hint: "Wait for BS+ handshake (playerPlatformId) or set debug BeatLeader id in settings"
});
lastFriendScoresDebug = "no BeatLeader player id";
lastBeatLeaderLeaderboardIds = "\u2014";
updateDebugHud();
clearFriendScores("Waiting for BeatLeader player id"); clearFriendScores("Waiting for BeatLeader player id");
return; return;
} }
friendScoresPanel.classList.add("is-loading"); friendScoresPanel.classList.add("is-loading");
friendScoresEmpty.textContent = "Loading mutual friend scores..."; friendScoresEmpty.textContent = "Loading mutual friend scores...";
lastFriendScoresDebug = "loading\u2026";
updateDebugHud();
const requestId = ++friendScoreRequestId; const requestId = ++friendScoreRequestId;
friendsDiag("start", {
requestId,
hash,
playerId,
friendMode: settings.friendMode,
debugPlayerOverride: Boolean(settings.debug && settings.debugPlayerId.trim())
});
try { try {
const relKey = friendsRelationListKey(playerId);
const friendsPromise = (async () => {
if (friendsRelationCache !== null && relKey === friendsRelationCacheKey) {
friendsDiag("friends-list-cache", {
hit: true,
friendCount: friendsRelationCache.length
});
return friendsRelationCache;
}
const fetched = await fetchFriends(playerId, settings.friendMode);
friendsRelationCacheKey = relKey;
friendsRelationCache = fetched;
friendsDiag("friends-list-cache", {
hit: false,
friendCount: fetched.length
});
return fetched;
})();
const [leaderboards, friends] = await Promise.all([ const [leaderboards, friends] = await Promise.all([
fetchBLLeaderboardsByHash(hash), fetchBLLeaderboardsByHash(hash),
fetchFriends(playerId, settings.friendMode) friendsPromise
]); ]);
if (requestId !== friendScoreRequestId) return; if (requestId !== friendScoreRequestId) return;
const leaderboardIds = leaderboards.map(beatLeaderboardId).filter(Boolean);
lastBeatLeaderLeaderboardIds = leaderboardIds.length ? leaderboardIds.join(", ") : "none";
updateDebugHud();
friendsDiag("parallel-fetch-done", {
hash,
leaderboardCount: leaderboards.length,
leaderboardIds,
friendCount: friends.length
});
if (leaderboards.length === 0) { if (leaderboards.length === 0) {
friendsDiag("empty-ui", {
reason: "no-beatleader-leaderboards-for-hash",
hash,
hint: "BeatLeader has no leaderboards for this map hash (wrong hash, unranked, or API/proxy error \u2014 see beatleader:leaderboardsByHash logs)"
});
lastFriendScoresDebug = "0 leaderboards for hash";
updateDebugHud();
clearFriendScores("No BeatLeader leaderboards found"); clearFriendScores("No BeatLeader leaderboards found");
return; return;
} }
@@ -366,12 +791,25 @@ async function refreshMapFriendScores() {
])); ]));
const mutualFriendIds = new Set(friends.map((f) => f.id)); const mutualFriendIds = new Set(friends.map((f) => f.id));
if (mutualFriendIds.size === 0) { if (mutualFriendIds.size === 0) {
friendsDiag("empty-ui", {
reason: "no-friends-for-mode",
friendMode: settings.friendMode,
hash,
hint: "Following/followers lists empty or mutual mode has no intersection \u2014 see beatleader:fetchFriendsResult"
});
lastFriendScoresDebug = `0 friends (${settings.friendMode})`;
updateDebugHud();
const relationLabel = settings.friendMode === "following" ? "No followed BeatLeader players" : settings.friendMode === "followers" ? "No BeatLeader followers" : "No mutual BeatLeader followers"; const relationLabel = settings.friendMode === "following" ? "No followed BeatLeader players" : settings.friendMode === "followers" ? "No BeatLeader followers" : "No mutual BeatLeader followers";
clearFriendScores(relationLabel); clearFriendScores(relationLabel);
return; return;
} }
const scores = await fetchAllMapScoresByHash(hash, leaderboards); const scores = await fetchAllMapScoresByHash(hash, leaderboards);
if (requestId !== friendScoreRequestId) return; if (requestId !== friendScoreRequestId) return;
friendsDiag("scores-aggregated", {
hash,
rawScoreRows: scores.length,
friendIdsInRelation: mutualFriendIds.size
});
const bestByPlayer = /* @__PURE__ */ new Map(); const bestByPlayer = /* @__PURE__ */ new Map();
for (const score of scores) { for (const score of scores) {
const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null); const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null);
@@ -393,55 +831,57 @@ async function refreshMapFriendScores() {
} }
} }
const sorted = Array.from(bestByPlayer.values()).sort((a, b) => b.acc - a.acc); const sorted = Array.from(bestByPlayer.values()).sort((a, b) => b.acc - a.acc);
if (sorted.length === 0 && scores.length > 0) {
friendsDiag("empty-ui", {
reason: "no-friend-scores-on-leaderboards",
hash,
rawScoreRows: scores.length,
friendIdsInRelation: mutualFriendIds.size,
hint: "Leaderboard scores exist but none match friend ids (playerId on scores vs BeatLeader friend ids)"
});
} else if (sorted.length === 0 && scores.length === 0) {
friendsDiag("empty-ui", {
reason: "no-scores-on-map-leaderboards",
hash,
leaderboardIds,
hint: "No ranked rows returned for these leaderboards \u2014 see beatleader:leaderboardScores logs"
});
} else {
friendsDiag("done", {
rows: sorted.length,
hash
});
}
lastFriendScoresDebug = `${leaderboards.length} LB, ${friends.length} friends, ${scores.length} scores \u2192 ${sorted.length} rows`;
updateDebugHud();
renderFriendScores(sorted); renderFriendScores(sorted);
} catch { } catch (err) {
if (requestId !== friendScoreRequestId) return; if (requestId !== friendScoreRequestId) return;
friendsDiag("error", {
message: formatErr(err),
hash,
playerId
});
lastFriendScoresDebug = `error: ${formatErr(err)}`;
lastBeatLeaderLeaderboardIds = "\u2014";
updateDebugHud();
clearFriendScores("Failed loading BeatLeader scores"); clearFriendScores("Failed loading BeatLeader scores");
} }
} }
var settings = {
cover: true,
mapInfo: true,
time: true,
score: true,
friends: true,
friendMode: "mutual",
bsr: false,
debug: false,
mockBsr: "4f4e4",
debugPlayerId: "76561199407393962",
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()}`);
}
async function applyMockMapFromBsr() { async function applyMockMapFromBsr() {
const key = settings.mockBsr.trim(); const key = settings.mockBsr.trim();
if (!settings.debug || !key) return; if (!settings.debug || !key) return;
const map = await fetchBeatSaverMapById(key); const map = await fetchBeatSaverMapById(key);
if (!map) return; if (!map) {
console.warn("[BS+ overlay] map: mock BSR lookup returned nothing", {
key
});
return;
}
const hash = map.versions?.[0]?.hash?.toLowerCase?.() || ""; const hash = map.versions?.[0]?.hash?.toLowerCase?.() || "";
rawLevelHash = hash;
currentMapHash = hash; currentMapHash = hash;
lastBeatLeaderLeaderboardIds = "\u2014";
title.textContent = map.metadata?.songName || map.name || title.textContent || ""; title.textContent = map.metadata?.songName || map.name || title.textContent || "";
subTitle.textContent = map.metadata?.songSubName || ""; subTitle.textContent = map.metadata?.songSubName || "";
artist.textContent = map.metadata?.songAuthorName || ""; artist.textContent = map.metadata?.songAuthorName || "";
@@ -450,20 +890,23 @@ async function applyMockMapFromBsr() {
type.textContent = "MOCK"; type.textContent = "MOCK";
const coverUrl = map.versions?.[0]?.coverURL; const coverUrl = map.versions?.[0]?.coverURL;
if (coverUrl) cover.src = coverUrl; if (coverUrl) cover.src = coverUrl;
lastMapLevelId = `mock:${key}`;
lastBsPlusBsrKey = "";
lastCharDiffStr = "";
lastBeatSaverIdDisplay = map.id || "\u2014";
lastBeatSaverNote = "mock BSR";
updateDebugHud();
console.log("[BS+ overlay] map: mock from BSR key", {
key,
hash: currentMapHash,
mapId: map.id
});
void refreshMapFriendScores(); void refreshMapFriendScores();
} }
function getEffectivePlayerId() {
const raw = settings.debug && settings.debugPlayerId.trim() ? settings.debugPlayerId.trim() : currentPlayerPlatformId;
if (!raw) return "";
const steamIdCandidate = raw.match(/\d{17,20}/)?.[0];
if (steamIdCandidate) return steamIdCandidate;
if (/^\d+$/.test(raw)) return raw;
if (settings.debug) return raw;
return "";
}
window.onhashchange = loadSettings; window.onhashchange = loadSettings;
loadSettings(); loadSettings();
document.head.appendChild(style); document.head.appendChild(style);
updateDebugHud();
for (const key of [ for (const key of [
"cover", "cover",
"mapInfo", "mapInfo",
@@ -480,6 +923,7 @@ for (const key of [
saveSettings(); saveSettings();
if (key === "friends") void refreshMapFriendScores(); if (key === "friends") void refreshMapFriendScores();
if (key === "debug") { if (key === "debug") {
updateDebugHud();
void loadRequestQueue(); void loadRequestQueue();
void applyMockMapFromBsr(); void applyMockMapFromBsr();
void refreshMapFriendScores(); void refreshMapFriendScores();
@@ -500,12 +944,12 @@ mockBsrInput.oninput = () => {
saveSettings(); saveSettings();
void applyMockMapFromBsr(); void applyMockMapFromBsr();
}; };
var debugPlayerInput = must("debugPlayerInput"); var beatLeaderPlayerInput = must("beatLeaderPlayerInput");
debugPlayerInput.value = settings.debugPlayerId; beatLeaderPlayerInput.value = settings.debugPlayerId;
debugPlayerInput.oninput = () => { beatLeaderPlayerInput.oninput = () => {
settings.debugPlayerId = debugPlayerInput.value.trim(); settings.debugPlayerId = beatLeaderPlayerInput.value.trim();
saveSettings(); saveSettings();
if (settings.debug) void refreshMapFriendScores(); void refreshMapFriendScores();
}; };
void applyMockMapFromBsr(); void applyMockMapFromBsr();
var scale = must("scaleInput"); var scale = must("scaleInput");
+122 -22
View File
@@ -5,12 +5,26 @@ import type {
BeatLeaderScore, BeatLeaderScore,
BeatLeaderScoresResponse, BeatLeaderScoresResponse,
} from "./types.ts"; } from "./types.ts";
import { mirrorOverlayLog } from "./overlay-server-log.ts";
const BASE_URL = "https://api.beatleader.com"; const BASE_URL = "https://api.beatleader.com";
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
/**
* `/leaderboard/{id}` uses `page` + `count` like v5 scores. Without `count`, the API default page
* size is small, so `batch.length < PAGE_SIZE` stopped pagination after the first page.
* `MAX_LEADERBOARD_SCORE_PAGES` bounds total requests for pathological maps.
*/
const MAX_LEADERBOARD_SCORE_PAGES = 2000;
const USE_RUNTIME_PROXY = typeof document !== "undefined"; const USE_RUNTIME_PROXY = typeof document !== "undefined";
export type FriendMode = "mutual" | "following" | "followers"; export type FriendMode = "mutual" | "following" | "followers";
/** Browser overlay only: BeatLeader request/result tracing (Deno tests use no `document`). */
function blDiag(phase: string, detail: Record<string, unknown>) {
if (!USE_RUNTIME_PROXY) return;
console.log(`[BS+ overlay] beatleader:${phase}`, detail);
mirrorOverlayLog("beatleader", phase, detail);
}
function beatleaderUrl(path: string): string { function beatleaderUrl(path: string): string {
if (USE_RUNTIME_PROXY) { if (USE_RUNTIME_PROXY) {
return `/api/beatleader?path=${encodeURIComponent(path)}`; return `/api/beatleader?path=${encodeURIComponent(path)}`;
@@ -23,29 +37,44 @@ interface BeatLeaderPlayerLookup {
} }
export async function fetchBLLeaderboardsByHash(hash: string): Promise<BeatLeaderLeaderboard[]> { export async function fetchBLLeaderboardsByHash(hash: string): Promise<BeatLeaderLeaderboard[]> {
const path = `/leaderboards/hash/${encodeURIComponent(hash)}`;
blDiag("leaderboardsByHash", { path, hash });
try { try {
const res = await fetch(beatleaderUrl(`/leaderboards/hash/${encodeURIComponent(hash)}`)); const res = await fetch(beatleaderUrl(path));
if (!res.ok) return []; if (!res.ok) {
blDiag("leaderboardsByHash", { path, hash, reason: "http-not-ok", status: res.status, statusText: res.statusText });
return [];
}
const data = await res.json() as BeatLeaderLeaderboardsByHashResponse | BeatLeaderLeaderboard[]; const data = await res.json() as BeatLeaderLeaderboardsByHashResponse | BeatLeaderLeaderboard[];
const leaderboards = Array.isArray(data) const leaderboards = Array.isArray(data)
? data ? data
: Array.isArray(data.leaderboards) : Array.isArray(data.leaderboards)
? data.leaderboards ? data.leaderboards
: []; : [];
blDiag("leaderboardsByHash", { path, hash, count: leaderboards.length });
return leaderboards; return leaderboards;
} catch { } catch (err) {
blDiag("leaderboardsByHash", { path, hash, reason: "fetch-error", error: String(err) });
return []; return [];
} }
} }
async function resolveBeatLeaderPlayerId(playerId: string): Promise<string> { async function resolveBeatLeaderPlayerId(playerId: string): Promise<string> {
const path = `/player/${encodeURIComponent(playerId)}`;
blDiag("resolvePlayer", { path, playerId });
try { try {
const res = await fetch(beatleaderUrl(`/player/${encodeURIComponent(playerId)}`)); const res = await fetch(beatleaderUrl(path));
if (!res.ok) return playerId; if (!res.ok) {
blDiag("resolvePlayer", { path, playerId, reason: "http-not-ok", status: res.status, usingId: playerId });
return playerId;
}
const data = await res.json() as BeatLeaderPlayerLookup; const data = await res.json() as BeatLeaderPlayerLookup;
const canonicalId = data.id; const canonicalId = data.id;
return canonicalId == null ? playerId : String(canonicalId); const out = canonicalId == null ? playerId : String(canonicalId);
} catch { blDiag("resolvePlayer", { path, playerId, canonicalId: out, changed: out !== playerId });
return out;
} catch (err) {
blDiag("resolvePlayer", { path, playerId, reason: "fetch-error", error: String(err), usingId: playerId });
return playerId; return playerId;
} }
} }
@@ -84,45 +113,78 @@ interface BeatLeaderLeaderboardScoresResponse {
async function fetchLeaderboardScoresById( async function fetchLeaderboardScoresById(
leaderboardId: string, leaderboardId: string,
maxPages = 20, maxPages = MAX_LEADERBOARD_SCORE_PAGES,
): Promise<BeatLeaderScore[]> { ): Promise<BeatLeaderScore[]> {
const scores: BeatLeaderScore[] = []; const scores: BeatLeaderScore[] = [];
for (let page = 1; page <= maxPages; page += 1) { const pageSize = PAGE_SIZE;
let page = 1;
let hitPageCap = false;
for (;;) {
if (page > maxPages) {
hitPageCap = true;
break;
}
const qs = new URLSearchParams({ const qs = new URLSearchParams({
leaderboardContext: "general", leaderboardContext: "general",
page: String(page), page: String(page),
sortBy: "rank", sortBy: "rank",
order: "desc", order: "desc",
count: String(pageSize),
}); });
const url = beatleaderUrl(`/leaderboard/${encodeURIComponent(leaderboardId)}?${qs}`); const path = `/leaderboard/${encodeURIComponent(leaderboardId)}?${qs}`;
const url = beatleaderUrl(path);
let res: Response; let res: Response;
try { try {
res = await fetch(url); res = await fetch(url);
} catch { } catch (err) {
blDiag("leaderboardScores", { leaderboardId, page, path, reason: "fetch-error", error: String(err) });
break;
}
if (!res.ok) {
blDiag("leaderboardScores", {
leaderboardId,
page,
path,
reason: "http-not-ok",
status: res.status,
statusText: res.statusText,
});
break; break;
} }
if (!res.ok) break;
const payload = await res.json() as BeatLeaderLeaderboardScoresResponse; const payload = await res.json() as BeatLeaderLeaderboardScoresResponse;
const batch = Array.isArray(payload.scores) ? payload.scores : []; const batch = Array.isArray(payload.scores) ? payload.scores : [];
if (page === 1) {
blDiag("leaderboardScores", { leaderboardId, page, path, firstPageBatchSize: batch.length, pageSize });
}
if (batch.length === 0) break; if (batch.length === 0) break;
scores.push(...batch); scores.push(...batch);
if (batch.length < PAGE_SIZE) break; if (batch.length < pageSize) break;
page += 1;
} }
blDiag("leaderboardScoresTotal", {
leaderboardId,
totalScores: scores.length,
...(hitPageCap ? { warning: "hit-max-pages-cap", maxPages } : {}),
});
return scores; return scores;
} }
export async function fetchAllMapScoresByHash( export async function fetchAllMapScoresByHash(
hash: string, hash: string,
leaderboards: BeatLeaderLeaderboard[], leaderboards: BeatLeaderLeaderboard[],
maxPagesPerLeaderboard = 20, maxPagesPerLeaderboard = MAX_LEADERBOARD_SCORE_PAGES,
): Promise<BeatLeaderScore[]> { ): Promise<BeatLeaderScore[]> {
const ids = leaderboards.map((lb) => (lb.id == null ? "" : String(lb.id))).filter(Boolean);
blDiag("fetchAllMapScoresByHash", { hash, leaderboardCount: leaderboards.length, leaderboardIds: ids });
const requests = leaderboards.map((lb) => { const requests = leaderboards.map((lb) => {
const leaderboardId = lb.id == null ? null : String(lb.id); const leaderboardId = lb.id == null ? null : String(lb.id);
if (!leaderboardId) return Promise.resolve<BeatLeaderScore[]>([]); if (!leaderboardId) return Promise.resolve<BeatLeaderScore[]>([]);
return fetchLeaderboardScoresById(leaderboardId, maxPagesPerLeaderboard); return fetchLeaderboardScoresById(leaderboardId, maxPagesPerLeaderboard);
}); });
const batches = await Promise.all(requests); const batches = await Promise.all(requests);
return batches.flat(); const flat = batches.flat();
blDiag("fetchAllMapScoresByHash", { hash, totalScores: flat.length });
return flat;
} }
async function fetchFollowersPage( async function fetchFollowersPage(
@@ -136,13 +198,30 @@ async function fetchFollowersPage(
page: String(page), page: String(page),
count: String(count), count: String(count),
}); });
const url = beatleaderUrl(`/player/${encodeURIComponent(playerId)}/followers?${qs}`); const path = `/player/${encodeURIComponent(playerId)}/followers?${qs}`;
const url = beatleaderUrl(path);
try { try {
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) return []; if (!response.ok) {
blDiag("followersPage", {
playerId,
type,
page,
path,
reason: "http-not-ok",
status: response.status,
statusText: response.statusText,
});
return [];
}
const data = await response.json() as BeatLeaderFollower[]; const data = await response.json() as BeatLeaderFollower[];
return Array.isArray(data) ? data : []; const rows = Array.isArray(data) ? data : [];
} catch { if (page === 1) {
blDiag("followersPage", { playerId, type, page, path, count: rows.length });
}
return rows;
} catch (err) {
blDiag("followersPage", { playerId, type, page, path, reason: "fetch-error", error: String(err) });
return []; return [];
} }
} }
@@ -176,20 +255,41 @@ function normalizeFollowerEntry(entry: BeatLeaderFollower): BeatLeaderFollower {
/** Friend list for the given mode, with `avatar` / `name` from BeatLeader follower payloads. */ /** Friend list for the given mode, with `avatar` / `name` from BeatLeader follower payloads. */
export async function fetchFriends(playerId: string, mode: FriendMode, maxPages = 100): Promise<BeatLeaderFollower[]> { export async function fetchFriends(playerId: string, mode: FriendMode, maxPages = 100): Promise<BeatLeaderFollower[]> {
const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId); const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId);
blDiag("fetchFriendsStart", { playerId, canonicalPlayerId, mode, maxPages });
const [following, followers] = await Promise.all([ const [following, followers] = await Promise.all([
fetchAllFollowers(canonicalPlayerId, "Following", maxPages), fetchAllFollowers(canonicalPlayerId, "Following", maxPages),
fetchAllFollowers(canonicalPlayerId, "Followers", maxPages), fetchAllFollowers(canonicalPlayerId, "Followers", maxPages),
]); ]);
blDiag("fetchFriendsLists", {
canonicalPlayerId,
mode,
followingCount: following.length,
followersCount: followers.length,
});
const followingIds = new Set(following.map((entry) => String(entry.id))); const followingIds = new Set(following.map((entry) => String(entry.id)));
if (mode === "following") { if (mode === "following") {
return following.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower)); const out = following.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
blDiag("fetchFriendsResult", { mode, count: out.length });
return out;
} }
if (mode === "followers") { if (mode === "followers") {
return followers.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower)); const out = followers.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
blDiag("fetchFriendsResult", { mode, count: out.length });
return out;
} }
return followers const out = followers
.filter((entry) => followingIds.has(String(entry.id))) .filter((entry) => followingIds.has(String(entry.id)))
.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower)); .map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
blDiag("fetchFriendsResult", {
mode: "mutual",
count: out.length,
reasonIfEmpty: out.length === 0
? (following.length === 0 || followers.length === 0
? "missing-following-or-followers-list"
: "no-intersection")
: undefined,
});
return out;
} }
export async function fetchFriendIds(playerId: string, mode: FriendMode, maxPages = 100): Promise<Set<string>> { export async function fetchFriendIds(playerId: string, mode: FriendMode, maxPages = 100): Promise<Set<string>> {
+533 -300
View File
@@ -1,4 +1,6 @@
import type { import type {
BeatLeaderFollower,
BeatLeaderLeaderboard,
BeatLeaderScore, BeatLeaderScore,
BeatSaberPlusEvent, BeatSaberPlusEvent,
ChatRequestEntry, ChatRequestEntry,
@@ -14,6 +16,7 @@ import {
type FriendMode, type FriendMode,
normalizeAccuracy, normalizeAccuracy,
} from "./beatleader.ts"; } from "./beatleader.ts";
import { mirrorOverlayLog } from "./overlay-server-log.ts";
function must<T extends HTMLElement>(id: string): T { function must<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id); const element = document.getElementById(id);
@@ -25,288 +28,6 @@ function parseJson<T>(raw: string): T {
return JSON.parse(raw) as 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;
case "handshake":
currentPlayerPlatformId = data.playerPlatformId || "";
void refreshMapFriendScores();
break;
default:
console.log("message", e.data);
break;
}
},
};
const provider = beatSaberPlus;
const retryMs = 10000;
let retries = 0;
let currentPlayerPlatformId = "";
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");
currentMapHash = custom ? data.level_id.substring(13, 53).toLowerCase() : "";
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;
void refreshMapFriendScores();
// Fetch extra info from BeatSaver
if (custom && !wip) {
document.body.classList.add("loading");
try {
const map = await fetchBeatSaverMeta(currentMapHash);
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");
}
} else {
bsrKey.textContent = "???";
difficultyLabel.textContent = "";
}
}
// 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");
let currentMapHash = "";
let friendScoreRequestId = 0;
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);
}
}
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 [leaderboards, friends] = await Promise.all([
fetchBLLeaderboardsByHash(hash),
fetchFriends(playerId, settings.friendMode),
]);
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");
}
}
// Settings
interface Settings { interface Settings {
cover: boolean; cover: boolean;
mapInfo: boolean; mapInfo: boolean;
@@ -364,13 +85,528 @@ function saveSettings() {
location.replace(`#${params.toString()}`); 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 || "";
console.log("[BS+ overlay] BS+ handshake", { playerPlatformId: currentPlayerPlatformId || "(empty)" });
updateDebugHud();
void refreshMapFriendScores();
break;
default:
console.log("message", e.data);
break;
}
},
};
const provider = beatSaberPlus;
const retryMs = 10000;
let retries = 0;
let currentPlayerPlatformId = "";
function getEffectivePlayerId() {
const configured = settings.debugPlayerId.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;
let lastMapLevelId = "";
let lastBsPlusBsrKey = "";
let lastCharDiffStr = "";
let lastBeatSaverIdDisplay = "—";
let lastBeatSaverNote = "—";
let lastFriendScoresDebug = "—";
/** Hex hash from BS+ `level_id` (before BeatSaver version hash). */
let rawLevelHash = "";
let lastBeatLeaderLeaderboardIds = "—";
function formatErr(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
const debugHud = {
levelId: must<HTMLElement>("debugHudLevelId"),
rawHash: must<HTMLElement>("debugHudRawHash"),
hash: must<HTMLElement>("debugHudHash"),
bsPlusBsr: must<HTMLElement>("debugHudBsPlusBsr"),
beatSaverId: must<HTMLElement>("debugHudBeatSaverId"),
beatSaverNote: must<HTMLElement>("debugHudBeatSaverNote"),
charDiff: must<HTMLElement>("debugHudCharDiff"),
handshake: must<HTMLElement>("debugHudHandshake"),
blId: must<HTMLElement>("debugHudBlId"),
blLeaderboards: must<HTMLElement>("debugHudBlLeaderboards"),
friends: must<HTMLElement>("debugHudFriends"),
} as const;
function updateDebugHud() {
if (!settings.debug) return;
debugHud.levelId.textContent = lastMapLevelId || "—";
debugHud.rawHash.textContent = rawLevelHash || "—";
debugHud.hash.textContent = currentMapHash || "—";
debugHud.bsPlusBsr.textContent = lastBsPlusBsrKey || "—";
debugHud.beatSaverId.textContent = lastBeatSaverIdDisplay;
debugHud.beatSaverNote.textContent = lastBeatSaverNote;
debugHud.charDiff.textContent = lastCharDiffStr || "—";
debugHud.handshake.textContent = currentPlayerPlatformId || "—";
debugHud.blId.textContent = getEffectivePlayerId() || "—";
debugHud.blLeaderboards.textContent = lastBeatLeaderLeaderboardIds;
debugHud.friends.textContent = lastFriendScoresDebug;
}
function beatLeaderboardId(lb: BeatLeaderLeaderboard): string {
const id = lb.id ?? lb.leaderboardId;
return id == null ? "" : String(id);
}
function resolvedHashFromBeatSaverMap(map: NonNullable<Awaited<ReturnType<typeof fetchBeatSaverMeta>>>, fallback: string): string {
const v = map.versions?.[0]?.hash;
if (typeof v === "string" && v.length > 0) return v.toLowerCase().trim();
return fallback;
}
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 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;
lastMapLevelId = data.level_id;
lastBsPlusBsrKey = data.BSRKey || "";
lastCharDiffStr = `${data.characteristic} / ${data.difficulty}`;
lastBeatLeaderLeaderboardIds = "—";
console.log("[BS+ overlay] map: new song", {
level_id: data.level_id,
hashLevelId: rawLevelHash || "(none)",
custom,
wip,
bsPlusBsrKey: data.BSRKey || "(empty, not used for APIs)",
characteristic: data.characteristic,
difficulty: data.difficulty,
});
if (settings.debug) {
console.log("[BS+ overlay] map: detail", { requestId: reqId, name: data.name, artist: data.artist });
}
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" : "";
// Display: BeatSaver map id when resolved; never rely on BS+ BSRKey for lookups.
bsrKey.textContent = custom && !wip ? "…" : custom ? rawLevelHash || "???" : "???";
timeMultiplier = data.timeMultiplier || 1;
duration = data.duration / 1000;
lastBeatSaverIdDisplay = "—";
lastBeatSaverNote = custom && !wip ? "loading…" : wip ? "WIP (no BeatSaver)" : !custom ? "OST (no hash)" : "—";
updateDebugHud();
// Fetch BeatSaver first for custom maps; BeatLeader uses version hash (or level_id hash fallback), not BS+ BSRKey.
if (custom && !wip) {
document.body.classList.add("loading");
try {
console.log("[BS+ overlay] map: BeatSaver lookup by hash (from level_id)", rawLevelHash);
const map = await fetchBeatSaverMeta(rawLevelHash);
if (reqId !== mapInfoRequestId) return;
if (!map?.id) {
lastBeatSaverIdDisplay = "—";
lastBeatSaverNote = "BeatSaver: no map (check hash / proxy)";
currentMapHash = rawLevelHash;
console.warn("[BS+ overlay] map: BeatSaver miss — BeatLeader will use level_id hash", {
hashLevelId: rawLevelHash,
});
} else {
lastBeatSaverIdDisplay = map.id;
lastBeatSaverNote = "ok";
const resolved = resolvedHashFromBeatSaverMap(map, rawLevelHash);
if (resolved !== rawLevelHash) {
console.log("[BS+ overlay] map: using BeatSaver version hash for BeatLeader", {
hashLevelId: rawLevelHash,
hashBeatLeader: resolved,
beatSaverId: map.id,
});
}
currentMapHash = resolved;
console.log("[BS+ overlay] map: BeatSaver ok", {
beatSaverId: map.id,
hashBeatLeader: currentMapHash,
});
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 (err) {
if (reqId !== mapInfoRequestId) return;
lastBeatSaverIdDisplay = "—";
lastBeatSaverNote = `error: ${formatErr(err)}`;
currentMapHash = rawLevelHash;
console.error("[BS+ overlay] map: BeatSaver fetch failed — BeatLeader will use level_id hash", err);
} finally {
document.body.classList.remove("loading");
if (reqId === mapInfoRequestId) {
updateDebugHud();
void refreshMapFriendScores();
}
}
} else {
if (custom && wip) {
bsrKey.textContent = rawLevelHash || "???";
} else {
bsrKey.textContent = "???";
}
difficultyLabel.textContent = "";
updateDebugHud();
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);
}
}
/** Friend-score flow: always log to the browser/OBS console when the friends panel is enabled (not gated on debug). */
function friendsDiag(message: string, detail: Record<string, unknown> = {}) {
if (!settings.friends) return;
console.log(`[BS+ overlay] friends:${message}`, detail);
mirrorOverlayLog("friends", message, detail);
}
function friendsRelationListKey(playerId: string): string {
return `${playerId}\0${settings.friendMode}`;
}
async function refreshMapFriendScores() {
const hash = currentMapHash;
if (!settings.friends) {
lastFriendScoresDebug = "off";
lastBeatLeaderLeaderboardIds = "—";
updateDebugHud();
clearFriendScores("Disabled in settings");
return;
}
if (!hash) {
friendsDiag("skip", { reason: "no-map-hash", hint: "Need custom map level_id hash or resolved BeatSaver hash" });
lastFriendScoresDebug = "no hash";
lastBeatLeaderLeaderboardIds = "—";
updateDebugHud();
clearFriendScores("No map loaded");
return;
}
const playerId = getEffectivePlayerId();
if (!playerId) {
friendsDiag("skip", {
reason: "no-player-id",
hint: "Wait for BS+ handshake (playerPlatformId) or set debug BeatLeader id in settings",
});
lastFriendScoresDebug = "no BeatLeader player id";
lastBeatLeaderLeaderboardIds = "—";
updateDebugHud();
clearFriendScores("Waiting for BeatLeader player id");
return;
}
friendScoresPanel.classList.add("is-loading");
friendScoresEmpty.textContent = "Loading mutual friend scores...";
lastFriendScoresDebug = "loading…";
updateDebugHud();
const requestId = ++friendScoreRequestId;
friendsDiag("start", {
requestId,
hash,
playerId,
friendMode: settings.friendMode,
debugPlayerOverride: Boolean(settings.debug && settings.debugPlayerId.trim()),
});
try {
const relKey = friendsRelationListKey(playerId);
const friendsPromise: Promise<BeatLeaderFollower[]> = (async () => {
if (friendsRelationCache !== null && relKey === friendsRelationCacheKey) {
friendsDiag("friends-list-cache", { hit: true, friendCount: friendsRelationCache.length });
return friendsRelationCache;
}
const fetched = await fetchFriends(playerId, settings.friendMode);
friendsRelationCacheKey = relKey;
friendsRelationCache = fetched;
friendsDiag("friends-list-cache", { hit: false, friendCount: fetched.length });
return fetched;
})();
const [leaderboards, friends] = await Promise.all([
fetchBLLeaderboardsByHash(hash),
friendsPromise,
]);
if (requestId !== friendScoreRequestId) return;
const leaderboardIds = leaderboards.map(beatLeaderboardId).filter(Boolean);
lastBeatLeaderLeaderboardIds = leaderboardIds.length ? leaderboardIds.join(", ") : "none";
updateDebugHud();
friendsDiag("parallel-fetch-done", {
hash,
leaderboardCount: leaderboards.length,
leaderboardIds,
friendCount: friends.length,
});
if (leaderboards.length === 0) {
friendsDiag("empty-ui", {
reason: "no-beatleader-leaderboards-for-hash",
hash,
hint: "BeatLeader has no leaderboards for this map hash (wrong hash, unranked, or API/proxy error — see beatleader:leaderboardsByHash logs)",
});
lastFriendScoresDebug = "0 leaderboards for hash";
updateDebugHud();
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) {
friendsDiag("empty-ui", {
reason: "no-friends-for-mode",
friendMode: settings.friendMode,
hash,
hint: "Following/followers lists empty or mutual mode has no intersection — see beatleader:fetchFriendsResult",
});
lastFriendScoresDebug = `0 friends (${settings.friendMode})`;
updateDebugHud();
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;
friendsDiag("scores-aggregated", { hash, rawScoreRows: scores.length, friendIdsInRelation: mutualFriendIds.size });
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);
if (sorted.length === 0 && scores.length > 0) {
friendsDiag("empty-ui", {
reason: "no-friend-scores-on-leaderboards",
hash,
rawScoreRows: scores.length,
friendIdsInRelation: mutualFriendIds.size,
hint: "Leaderboard scores exist but none match friend ids (playerId on scores vs BeatLeader friend ids)",
});
} else if (sorted.length === 0 && scores.length === 0) {
friendsDiag("empty-ui", {
reason: "no-scores-on-map-leaderboards",
hash,
leaderboardIds,
hint: "No ranked rows returned for these leaderboards — see beatleader:leaderboardScores logs",
});
} else {
friendsDiag("done", { rows: sorted.length, hash });
}
lastFriendScoresDebug = `${leaderboards.length} LB, ${friends.length} friends, ${scores.length} scores → ${sorted.length} rows`;
updateDebugHud();
renderFriendScores(sorted);
} catch (err) {
if (requestId !== friendScoreRequestId) return;
friendsDiag("error", { message: formatErr(err), hash, playerId });
lastFriendScoresDebug = `error: ${formatErr(err)}`;
lastBeatLeaderLeaderboardIds = "—";
updateDebugHud();
clearFriendScores("Failed loading BeatLeader scores");
}
}
async function applyMockMapFromBsr() { async function applyMockMapFromBsr() {
const key = settings.mockBsr.trim(); const key = settings.mockBsr.trim();
if (!settings.debug || !key) return; if (!settings.debug || !key) return;
const map = await fetchBeatSaverMapById(key); const map = await fetchBeatSaverMapById(key);
if (!map) return; if (!map) {
console.warn("[BS+ overlay] map: mock BSR lookup returned nothing", { key });
return;
}
const hash = map.versions?.[0]?.hash?.toLowerCase?.() || ""; const hash = map.versions?.[0]?.hash?.toLowerCase?.() || "";
rawLevelHash = hash;
currentMapHash = hash; currentMapHash = hash;
lastBeatLeaderLeaderboardIds = "—";
title.textContent = map.metadata?.songName || map.name || title.textContent || ""; title.textContent = map.metadata?.songName || map.name || title.textContent || "";
subTitle.textContent = map.metadata?.songSubName || ""; subTitle.textContent = map.metadata?.songSubName || "";
artist.textContent = map.metadata?.songAuthorName || ""; artist.textContent = map.metadata?.songAuthorName || "";
@@ -379,24 +615,20 @@ async function applyMockMapFromBsr() {
type.textContent = "MOCK"; type.textContent = "MOCK";
const coverUrl = map.versions?.[0]?.coverURL; const coverUrl = map.versions?.[0]?.coverURL;
if (coverUrl) cover.src = coverUrl; if (coverUrl) cover.src = coverUrl;
lastMapLevelId = `mock:${key}`;
lastBsPlusBsrKey = "";
lastCharDiffStr = "";
lastBeatSaverIdDisplay = map.id || "—";
lastBeatSaverNote = "mock BSR";
updateDebugHud();
console.log("[BS+ overlay] map: mock from BSR key", { key, hash: currentMapHash, mapId: map.id });
void refreshMapFriendScores(); void refreshMapFriendScores();
} }
function getEffectivePlayerId() {
const raw = settings.debug && settings.debugPlayerId.trim()
? settings.debugPlayerId.trim()
: currentPlayerPlatformId;
if (!raw) return "";
const steamIdCandidate = raw.match(/\d{17,20}/)?.[0];
if (steamIdCandidate) return steamIdCandidate;
if (/^\d+$/.test(raw)) return raw;
if (settings.debug) return raw;
return "";
}
window.onhashchange = loadSettings; window.onhashchange = loadSettings;
loadSettings(); loadSettings();
document.head.appendChild(style); document.head.appendChild(style);
updateDebugHud();
// Settings UI // Settings UI
@@ -408,6 +640,7 @@ for (const key of ["cover", "mapInfo", "time", "score", "friends", "bsr", "debug
saveSettings(); saveSettings();
if (key === "friends") void refreshMapFriendScores(); if (key === "friends") void refreshMapFriendScores();
if (key === "debug") { if (key === "debug") {
updateDebugHud();
void loadRequestQueue(); void loadRequestQueue();
void applyMockMapFromBsr(); void applyMockMapFromBsr();
void refreshMapFriendScores(); void refreshMapFriendScores();
@@ -431,12 +664,12 @@ mockBsrInput.oninput = () => {
void applyMockMapFromBsr(); void applyMockMapFromBsr();
}; };
const debugPlayerInput = must<HTMLInputElement>("debugPlayerInput"); const beatLeaderPlayerInput = must<HTMLInputElement>("beatLeaderPlayerInput");
debugPlayerInput.value = settings.debugPlayerId; beatLeaderPlayerInput.value = settings.debugPlayerId;
debugPlayerInput.oninput = () => { beatLeaderPlayerInput.oninput = () => {
settings.debugPlayerId = debugPlayerInput.value.trim(); settings.debugPlayerId = beatLeaderPlayerInput.value.trim();
saveSettings(); saveSettings();
if (settings.debug) void refreshMapFriendScores(); void refreshMapFriendScores();
}; };
void applyMockMapFromBsr(); void applyMockMapFromBsr();
+15
View File
@@ -0,0 +1,15 @@
/**
* Mirrors overlay diagnostics to the Deno static server terminal (`POST /api/overlay-log`).
* Browser console still receives the same messages via `console.log`.
*/
export function mirrorOverlayLog(scope: string, phase: string, detail: Record<string, unknown>): void {
if (typeof location === "undefined") return;
if (location.protocol !== "http:" && location.protocol !== "https:") return;
const body = JSON.stringify({ scope, phase, detail });
void fetch("/api/overlay-log", {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body,
keepalive: true,
}).catch(() => {});
}
+13
View File
@@ -75,6 +75,18 @@ function isChatRequestFilename(pathname: string): boolean {
Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => { Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => {
const url = new URL(req.url); const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/api/overlay-log") {
try {
const raw = await req.text();
const parsed = JSON.parse(raw) as { scope?: string; phase?: string; detail?: unknown };
const scope = typeof parsed.scope === "string" ? parsed.scope : "?";
const phase = typeof parsed.phase === "string" ? parsed.phase : "?";
console.log(`[overlay] ${scope}:${phase}`, parsed.detail ?? "");
} catch {
console.log("[overlay] overlay-log: invalid JSON body");
}
return new Response(null, { status: 204 });
}
if (req.method === "GET" && url.pathname === "/api/beatleader") { if (req.method === "GET" && url.pathname === "/api/beatleader") {
return proxyApiRequest(req, "https://api.beatleader.com"); return proxyApiRequest(req, "https://api.beatleader.com");
} }
@@ -105,6 +117,7 @@ Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => {
}); });
console.log(`Overlay: http://127.0.0.1:${port}/index.html`); console.log(`Overlay: http://127.0.0.1:${port}/index.html`);
console.log("Friend/BeatLeader diagnostics from the page also print here (browser console has the same lines).");
if (chatRequestDatabase) { if (chatRequestDatabase) {
console.log(`Chat request database file: ${chatRequestDatabase}`); console.log(`Chat request database file: ${chatRequestDatabase}`);
} else { } else {