1093 lines
34 KiB
JavaScript
1093 lines
34 KiB
JavaScript
// src/client/beatsaver.ts
|
|
var BASE_URL = "https://api.beatsaver.com";
|
|
var USE_RUNTIME_PROXY = typeof document !== "undefined";
|
|
function beatsaverUrl(path) {
|
|
if (USE_RUNTIME_PROXY) {
|
|
return `/api/beatsaver?path=${encodeURIComponent(path)}`;
|
|
}
|
|
return `${BASE_URL}${path}`;
|
|
}
|
|
async function fetchBeatSaverMeta(hash) {
|
|
try {
|
|
const response = await fetch(beatsaverUrl(`/maps/hash/${encodeURIComponent(hash)}`));
|
|
if (!response.ok) return null;
|
|
return await response.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
async function fetchBeatSaverMapById(mapId) {
|
|
try {
|
|
const response = await fetch(beatsaverUrl(`/maps/id/${encodeURIComponent(mapId)}`));
|
|
if (!response.ok) return null;
|
|
return await response.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// 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
|
|
var BASE_URL2 = "https://api.beatleader.com";
|
|
var PAGE_SIZE = 100;
|
|
var MAX_LEADERBOARD_SCORE_PAGES = 2e3;
|
|
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) {
|
|
if (USE_RUNTIME_PROXY2) {
|
|
return `/api/beatleader?path=${encodeURIComponent(path)}`;
|
|
}
|
|
return `${BASE_URL2}${path}`;
|
|
}
|
|
async function fetchBLLeaderboardsByHash(hash) {
|
|
const path = `/leaderboards/hash/${encodeURIComponent(hash)}`;
|
|
blDiag("leaderboardsByHash", {
|
|
path,
|
|
hash
|
|
});
|
|
try {
|
|
const res = await fetch(beatleaderUrl(path));
|
|
if (!res.ok) {
|
|
blDiag("leaderboardsByHash", {
|
|
path,
|
|
hash,
|
|
reason: "http-not-ok",
|
|
status: res.status,
|
|
statusText: res.statusText
|
|
});
|
|
return [];
|
|
}
|
|
const data = await res.json();
|
|
const leaderboards = Array.isArray(data) ? data : Array.isArray(data.leaderboards) ? data.leaderboards : [];
|
|
blDiag("leaderboardsByHash", {
|
|
path,
|
|
hash,
|
|
count: leaderboards.length
|
|
});
|
|
return leaderboards;
|
|
} catch (err) {
|
|
blDiag("leaderboardsByHash", {
|
|
path,
|
|
hash,
|
|
reason: "fetch-error",
|
|
error: String(err)
|
|
});
|
|
return [];
|
|
}
|
|
}
|
|
async function resolveBeatLeaderPlayerId(playerId) {
|
|
const path = `/player/${encodeURIComponent(playerId)}`;
|
|
blDiag("resolvePlayer", {
|
|
path,
|
|
playerId
|
|
});
|
|
try {
|
|
const res = await fetch(beatleaderUrl(path));
|
|
if (!res.ok) {
|
|
blDiag("resolvePlayer", {
|
|
path,
|
|
playerId,
|
|
reason: "http-not-ok",
|
|
status: res.status,
|
|
usingId: playerId
|
|
});
|
|
return playerId;
|
|
}
|
|
const data = await res.json();
|
|
const canonicalId = data.id;
|
|
const out = canonicalId == null ? playerId : String(canonicalId);
|
|
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;
|
|
}
|
|
}
|
|
async function fetchLeaderboardScoresById(leaderboardId, maxPages = MAX_LEADERBOARD_SCORE_PAGES) {
|
|
const scores = [];
|
|
const pageSize = PAGE_SIZE;
|
|
let page = 1;
|
|
let hitPageCap = false;
|
|
for (; ; ) {
|
|
if (page > maxPages) {
|
|
hitPageCap = true;
|
|
break;
|
|
}
|
|
const qs = new URLSearchParams({
|
|
leaderboardContext: "general",
|
|
page: String(page),
|
|
sortBy: "rank",
|
|
order: "desc",
|
|
count: String(pageSize)
|
|
});
|
|
const path = `/leaderboard/${encodeURIComponent(leaderboardId)}?${qs}`;
|
|
const url = beatleaderUrl(path);
|
|
let res;
|
|
try {
|
|
res = await fetch(url);
|
|
} 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;
|
|
}
|
|
const payload = await res.json();
|
|
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;
|
|
scores.push(...batch);
|
|
if (batch.length < pageSize) break;
|
|
page += 1;
|
|
}
|
|
blDiag("leaderboardScoresTotal", {
|
|
leaderboardId,
|
|
totalScores: scores.length,
|
|
...hitPageCap ? {
|
|
warning: "hit-max-pages-cap",
|
|
maxPages
|
|
} : {}
|
|
});
|
|
return scores;
|
|
}
|
|
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 leaderboardId = lb.id == null ? null : String(lb.id);
|
|
if (!leaderboardId) return Promise.resolve([]);
|
|
return fetchLeaderboardScoresById(leaderboardId, maxPagesPerLeaderboard);
|
|
});
|
|
const batches = await Promise.all(requests);
|
|
const flat = batches.flat();
|
|
blDiag("fetchAllMapScoresByHash", {
|
|
hash,
|
|
totalScores: flat.length
|
|
});
|
|
return flat;
|
|
}
|
|
async function fetchFollowersPage(playerId, type2, page, count) {
|
|
const qs = new URLSearchParams({
|
|
type: type2,
|
|
page: String(page),
|
|
count: String(count)
|
|
});
|
|
const path = `/player/${encodeURIComponent(playerId)}/followers?${qs}`;
|
|
const url = beatleaderUrl(path);
|
|
try {
|
|
const response = await fetch(url);
|
|
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 rows = Array.isArray(data) ? data : [];
|
|
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 [];
|
|
}
|
|
}
|
|
async function fetchAllFollowers(playerId, type2, maxPages = 100) {
|
|
const all = [];
|
|
for (let page = 1; page <= maxPages; page += 1) {
|
|
const batch = await fetchFollowersPage(playerId, type2, page, PAGE_SIZE);
|
|
if (batch.length === 0) break;
|
|
all.push(...batch);
|
|
if (batch.length < PAGE_SIZE) break;
|
|
}
|
|
return all;
|
|
}
|
|
function normalizeFollowerEntry(entry) {
|
|
return {
|
|
...entry,
|
|
id: String(entry.id)
|
|
};
|
|
}
|
|
async function fetchFriends(playerId, mode, maxPages = 100) {
|
|
const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId);
|
|
blDiag("fetchFriendsStart", {
|
|
playerId,
|
|
canonicalPlayerId,
|
|
mode,
|
|
maxPages
|
|
});
|
|
const [following, followers] = await Promise.all([
|
|
fetchAllFollowers(canonicalPlayerId, "Following", 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)));
|
|
if (mode === "following") {
|
|
const out2 = following.map((entry) => normalizeFollowerEntry(entry));
|
|
blDiag("fetchFriendsResult", {
|
|
mode,
|
|
count: out2.length
|
|
});
|
|
return out2;
|
|
}
|
|
if (mode === "followers") {
|
|
const out2 = followers.map((entry) => normalizeFollowerEntry(entry));
|
|
blDiag("fetchFriendsResult", {
|
|
mode,
|
|
count: out2.length
|
|
});
|
|
return out2;
|
|
}
|
|
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) {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
return value <= 1 ? value * 100 : value;
|
|
}
|
|
|
|
// src/client/index.ts
|
|
function must(id) {
|
|
const element = document.getElementById(id);
|
|
if (!element) throw new Error(`Missing element: ${id}`);
|
|
return element;
|
|
}
|
|
function parseJson(raw) {
|
|
return JSON.parse(raw);
|
|
}
|
|
var 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 = {
|
|
// https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay
|
|
url: "ws://localhost:2947/socket",
|
|
onMessage: (e) => {
|
|
const data = parseJson(e.data);
|
|
switch (data._type) {
|
|
case "event":
|
|
switch (data._event) {
|
|
case "gameState":
|
|
document.body.dataset.gameState = data.gameStateChanged;
|
|
break;
|
|
case "mapInfo":
|
|
void updateMapInfo(data.mapInfoChanged);
|
|
break;
|
|
case "pause":
|
|
updateTime(data.pauseTime, true);
|
|
break;
|
|
case "resume":
|
|
updateTime(data.resumeTime, false);
|
|
break;
|
|
case "score":
|
|
updateScore(data.scoreEvent);
|
|
break;
|
|
}
|
|
break;
|
|
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;
|
|
}
|
|
}
|
|
};
|
|
var provider = beatSaberPlus;
|
|
var retryMs = 1e4;
|
|
var retries = 0;
|
|
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() {
|
|
console.log(`Connecting to ${provider.url} (attempt ${retries++})`);
|
|
const ws = new WebSocket(provider.url);
|
|
ws.onopen = onOpen;
|
|
ws.onmessage = provider.onMessage;
|
|
ws.onclose = onClose;
|
|
}
|
|
function onOpen() {
|
|
console.log("Connection open.");
|
|
retries = 0;
|
|
}
|
|
function onClose(e) {
|
|
console.log(`Connection closed. code: ${e.code}, reason: ${e.reason}, clean: ${e.wasClean}`);
|
|
setTimeout(connect, retryMs);
|
|
}
|
|
connect();
|
|
var cover = must("coverImg");
|
|
var title = must("title");
|
|
var subTitle = must("subTitle");
|
|
var artist = must("artist");
|
|
var mapper = must("mapper");
|
|
var difficulty = must("difficulty");
|
|
var characteristic = must("characteristicImg");
|
|
var difficultyLabel = must("difficultyLabel");
|
|
var type = must("type");
|
|
var bsrKey = must("bsrKey");
|
|
var timeMultiplier = 1;
|
|
var duration = 0;
|
|
async function updateMapInfo(data) {
|
|
const 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 = "\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";
|
|
title.textContent = data.name || "";
|
|
subTitle.textContent = data.sub_name || "";
|
|
artist.textContent = data.artist || "";
|
|
mapper.textContent = data.mapper || "";
|
|
difficulty.textContent = data.difficulty?.replace("Plus", " +") || "";
|
|
characteristic.src = `images/characteristic/${data.characteristic}.svg`;
|
|
difficultyLabel.textContent = "";
|
|
type.textContent = !custom ? "OST" : wip ? "WIP" : "";
|
|
bsrKey.textContent = custom && !wip ? "\u2026" : custom ? rawLevelHash || "???" : "???";
|
|
timeMultiplier = data.timeMultiplier || 1;
|
|
duration = data.duration / 1e3;
|
|
lastBeatSaverIdDisplay = "\u2014";
|
|
lastBeatSaverNote = custom && !wip ? "loading\u2026" : wip ? "WIP (no BeatSaver)" : !custom ? "OST (no hash)" : "\u2014";
|
|
updateDebugHud();
|
|
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 = "\u2014";
|
|
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 {
|
|
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();
|
|
}
|
|
}
|
|
var timeText = must("timeText");
|
|
var timeBar = must("timeBar");
|
|
var intervalMs = 500;
|
|
var intervalId = 0;
|
|
var currentTime = 0;
|
|
function updateTime(time, paused) {
|
|
if (!settings.time) return;
|
|
setTime(time);
|
|
clearInterval(intervalId);
|
|
if (paused) return;
|
|
intervalId = window.setInterval(() => setTime(currentTime + intervalMs * timeMultiplier / 1e3), intervalMs);
|
|
}
|
|
function setTime(time) {
|
|
currentTime = time;
|
|
timeText.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`;
|
|
timeBar.style.width = `${currentTime / (duration || Infinity) * 100}%`;
|
|
}
|
|
function formatTime(t) {
|
|
t = Math.floor(t);
|
|
const minutes = Math.floor(t / 60);
|
|
const seconds = t - minutes * 60;
|
|
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
|
}
|
|
var accuracy = must("accuracy");
|
|
var mistakes = must("mistakes");
|
|
var friendScoresPanel = must("friendScores");
|
|
var friendScoresList = must("friendScoresList");
|
|
var friendScoresEmpty = must("friendScoresEmpty");
|
|
var friendScoresHeaderText = must("friendScoresHeaderText");
|
|
var friendScoresHeaderImg = must("friendScoresHeaderImg");
|
|
function updateScore(score) {
|
|
if (!settings.score) return;
|
|
accuracy.textContent = (score.accuracy * 100).toFixed(1);
|
|
mistakes.textContent = score.missCount ? String(score.missCount) : "";
|
|
accuracy.classList.toggle("failed", score.currentHealth === 0);
|
|
}
|
|
function avatarFromScore(score) {
|
|
if (typeof score.player === "object" && score.player?.avatar) {
|
|
return score.player.avatar;
|
|
}
|
|
const url = score.playerAvatar?.trim();
|
|
return url || null;
|
|
}
|
|
function clearFriendScores(message) {
|
|
friendScoresList.replaceChildren();
|
|
friendScoresEmpty.textContent = message;
|
|
friendScoresHeaderText.textContent = "frenz?";
|
|
friendScoresHeaderImg.src = "assets/notlikesteve.webp";
|
|
friendScoresPanel.classList.remove("has-items", "is-loading");
|
|
}
|
|
function renderFriendScores(items) {
|
|
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 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() {
|
|
const hash = currentMapHash;
|
|
if (!settings.friends) {
|
|
lastFriendScoresDebug = "off";
|
|
lastBeatLeaderLeaderboardIds = "\u2014";
|
|
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 = "\u2014";
|
|
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 = "\u2014";
|
|
updateDebugHud();
|
|
clearFriendScores("Waiting for BeatLeader player id");
|
|
return;
|
|
}
|
|
friendScoresPanel.classList.add("is-loading");
|
|
friendScoresEmpty.textContent = "Loading mutual friend scores...";
|
|
lastFriendScoresDebug = "loading\u2026";
|
|
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 = (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 \u2014 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 \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";
|
|
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 = /* @__PURE__ */ 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);
|
|
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);
|
|
} catch (err) {
|
|
if (requestId !== friendScoreRequestId) return;
|
|
friendsDiag("error", {
|
|
message: formatErr(err),
|
|
hash,
|
|
playerId
|
|
});
|
|
lastFriendScoresDebug = `error: ${formatErr(err)}`;
|
|
lastBeatLeaderLeaderboardIds = "\u2014";
|
|
updateDebugHud();
|
|
clearFriendScores("Failed loading BeatLeader scores");
|
|
}
|
|
}
|
|
async function applyMockMapFromBsr() {
|
|
const key = settings.mockBsr.trim();
|
|
if (!settings.debug || !key) return;
|
|
const map = await fetchBeatSaverMapById(key);
|
|
if (!map) {
|
|
console.warn("[BS+ overlay] map: mock BSR lookup returned nothing", {
|
|
key
|
|
});
|
|
return;
|
|
}
|
|
const hash = map.versions?.[0]?.hash?.toLowerCase?.() || "";
|
|
rawLevelHash = hash;
|
|
currentMapHash = hash;
|
|
lastBeatLeaderLeaderboardIds = "\u2014";
|
|
title.textContent = map.metadata?.songName || map.name || title.textContent || "";
|
|
subTitle.textContent = map.metadata?.songSubName || "";
|
|
artist.textContent = map.metadata?.songAuthorName || "";
|
|
mapper.textContent = map.metadata?.levelAuthorName || "";
|
|
bsrKey.textContent = map.id || key;
|
|
type.textContent = "MOCK";
|
|
const coverUrl = map.versions?.[0]?.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();
|
|
}
|
|
window.onhashchange = loadSettings;
|
|
loadSettings();
|
|
document.head.appendChild(style);
|
|
updateDebugHud();
|
|
for (const key of [
|
|
"cover",
|
|
"mapInfo",
|
|
"time",
|
|
"score",
|
|
"friends",
|
|
"bsr",
|
|
"debug"
|
|
]) {
|
|
const input = must(`${key}Input`);
|
|
input.checked = settings[key];
|
|
input.oninput = () => {
|
|
settings[key] = input.checked;
|
|
saveSettings();
|
|
if (key === "friends") void refreshMapFriendScores();
|
|
if (key === "debug") {
|
|
updateDebugHud();
|
|
void loadRequestQueue();
|
|
void applyMockMapFromBsr();
|
|
void refreshMapFriendScores();
|
|
}
|
|
};
|
|
}
|
|
var friendModeInput = must("friendModeInput");
|
|
friendModeInput.value = settings.friendMode;
|
|
friendModeInput.onchange = () => {
|
|
settings.friendMode = friendModeInput.value;
|
|
saveSettings();
|
|
void refreshMapFriendScores();
|
|
};
|
|
var mockBsrInput = must("mockBsrInput");
|
|
mockBsrInput.value = settings.mockBsr;
|
|
mockBsrInput.oninput = () => {
|
|
settings.mockBsr = mockBsrInput.value.trim();
|
|
saveSettings();
|
|
void applyMockMapFromBsr();
|
|
};
|
|
var beatLeaderPlayerInput = must("beatLeaderPlayerInput");
|
|
beatLeaderPlayerInput.value = settings.debugPlayerId;
|
|
beatLeaderPlayerInput.oninput = () => {
|
|
settings.debugPlayerId = beatLeaderPlayerInput.value.trim();
|
|
saveSettings();
|
|
void refreshMapFriendScores();
|
|
};
|
|
void applyMockMapFromBsr();
|
|
var scale = must("scaleInput");
|
|
scale.valueAsNumber = settings.scale * 100;
|
|
scale.oninput = () => {
|
|
settings.scale = scale.valueAsNumber / 100;
|
|
saveSettings();
|
|
};
|
|
var position = must("positionInput");
|
|
position.value = JSON.stringify([
|
|
settings.right,
|
|
settings.bottom
|
|
]);
|
|
position.onchange = () => {
|
|
[settings.right, settings.bottom] = parseJson(position.value);
|
|
saveSettings();
|
|
};
|
|
var fade = must("fadeInput");
|
|
fade.valueAsNumber = settings.fade;
|
|
fade.oninput = () => {
|
|
settings.fade = fade.valueAsNumber;
|
|
saveSettings();
|
|
};
|
|
document.documentElement.onclick = () => document.body.classList.toggle("preview");
|
|
must("settings").onclick = (e) => e.stopPropagation();
|
|
var MAX_REQUESTS = 10;
|
|
var REQUEST_POLL_MS = 5e3;
|
|
var requestListEl = must("requestList");
|
|
var requestOverlayEl = must("requestOverlay");
|
|
var requestEmptyEl = must("requestEmpty");
|
|
var requestTitleCache = /* @__PURE__ */ new Map();
|
|
var requestTitleMisses = /* @__PURE__ */ new Set();
|
|
function useRequestHistorySim() {
|
|
return settings.debug || new URLSearchParams(location.search).get("debug") === "1";
|
|
}
|
|
function requestJsonFilenames() {
|
|
const explicit = new URLSearchParams(location.search).get("requests");
|
|
if (explicit) return [
|
|
explicit
|
|
];
|
|
if (useRequestHistorySim()) return [
|
|
"ChatRequest.json",
|
|
"database.json"
|
|
];
|
|
return [
|
|
"ChatRequest.json"
|
|
];
|
|
}
|
|
function loadJsonNextToPage(fileName) {
|
|
const base = new URL(fileName, location.href);
|
|
if (base.protocol !== "http:" && base.protocol !== "https:") {
|
|
throw new Error("not-http");
|
|
}
|
|
const busted = new URL(base.href);
|
|
busted.searchParams.set("t", String(Date.now()));
|
|
return fetch(busted.href, {
|
|
cache: "no-store"
|
|
}).then((res) => {
|
|
if (!res.ok) throw new Error(String(res.status));
|
|
return res.json();
|
|
});
|
|
}
|
|
async function loadRequestPayload() {
|
|
let lastErr;
|
|
for (const name of requestJsonFilenames()) {
|
|
try {
|
|
return await loadJsonNextToPage(name);
|
|
} catch (e) {
|
|
lastErr = e;
|
|
}
|
|
}
|
|
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
}
|
|
function requesterLine(item) {
|
|
const parts = [
|
|
item.npr,
|
|
item.rqn
|
|
].filter(Boolean);
|
|
return parts.length ? parts.join(" ") : item.rqn || "";
|
|
}
|
|
async function enrichRequestTitle(key, titleEl) {
|
|
if (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) {
|
|
requestListEl.replaceChildren();
|
|
requestOverlayEl.classList.toggle("has-items", items.length > 0);
|
|
for (const item of items) {
|
|
const li = document.createElement("li");
|
|
li.className = "request-item";
|
|
const titleEl = document.createElement("span");
|
|
titleEl.className = "request-title";
|
|
titleEl.textContent = `!bsr ${item.key}`;
|
|
li.appendChild(titleEl);
|
|
const who = requesterLine(item);
|
|
if (who) {
|
|
const meta = document.createElement("span");
|
|
meta.className = "request-meta";
|
|
meta.textContent = who;
|
|
li.appendChild(meta);
|
|
}
|
|
requestListEl.appendChild(li);
|
|
void enrichRequestTitle(item.key, titleEl);
|
|
}
|
|
}
|
|
async function loadRequestQueue() {
|
|
try {
|
|
const data = await loadRequestPayload();
|
|
requestEmptyEl.textContent = "No pending requests";
|
|
requestOverlayEl.classList.remove("request-load-failed");
|
|
const raw = useRequestHistorySim() ? data.history ?? [] : data.queue ?? [];
|
|
const items = raw.slice(0, MAX_REQUESTS);
|
|
renderRequestList(items);
|
|
} catch {
|
|
requestEmptyEl.textContent = "whupsy, database file missing";
|
|
requestOverlayEl.classList.add("request-load-failed");
|
|
renderRequestList([]);
|
|
}
|
|
}
|
|
void loadRequestQueue();
|
|
window.setInterval(() => void loadRequestQueue(), REQUEST_POLL_MS);
|