diff --git a/index.css b/index.css
index 5a06bfa..3330c61 100644
--- a/index.css
+++ b/index.css
@@ -317,6 +317,48 @@ span:empty {
max-width: 44rem;
}
+.personal-best-row {
+ display: none;
+ align-items: center;
+ gap: 0.45rem;
+ font-size: 1.3rem;
+ font-weight: 700;
+ opacity: 0.92;
+ line-height: 1.2;
+}
+
+#friendScores.has-personal-best .personal-best-row {
+ display: flex;
+}
+
+.personal-best-label {
+ font-size: 1rem;
+ font-weight: 700;
+ opacity: 0.75;
+ letter-spacing: 0.04em;
+}
+
+.personal-best-acc {
+ font-feature-settings: "tnum";
+ font-variant-numeric: tabular-nums;
+}
+
+.personal-best-delta {
+ font-size: 1.15rem;
+ font-weight: 700;
+ font-feature-settings: "tnum";
+ font-variant-numeric: tabular-nums;
+ min-width: 4.2ch;
+}
+
+.personal-best-delta.personal-best-delta--ahead {
+ color: #3ddc97;
+}
+
+.personal-best-delta.personal-best-delta--behind {
+ color: #ff6b6b;
+}
+
#friendScoresHeader {
font-size: 1.3rem;
font-weight: 700;
diff --git a/index.html b/index.html
index ab02353..e56002e 100644
--- a/index.html
+++ b/index.html
@@ -39,6 +39,11 @@
+
+ PB
+
+
+
No map loaded
diff --git a/index.js b/index.js
index 0c2734a..34d91bc 100644
--- a/index.js
+++ b/index.js
@@ -246,6 +246,9 @@ var beatSaberPlus = {
switch (data._event) {
case "gameState":
document.body.dataset.gameState = data.gameStateChanged;
+ if (data.gameStateChanged === "Menu") {
+ resetPersonalBestDeltaPlaceholder();
+ }
break;
case "mapInfo":
void updateMapInfo(data.mapInfoChanged);
@@ -475,6 +478,9 @@ var friendScoresEmpty = must("friendScoresEmpty");
var friendScoresHeaderText = must("friendScoresHeaderText");
var friendScoresPlayerAvatar = must("friendScoresPlayerAvatar");
var friendScoresHeaderImg = must("friendScoresHeaderImg");
+var personalBestAcc = must("personalBestAcc");
+var personalBestDelta = must("personalBestDelta");
+var currentPbAccuracyPercent = null;
var cachedConfiguredPlayerAvatarKey = "";
var cachedConfiguredPlayerAvatarSrc = "images/unknown.svg";
async function refreshConfiguredPlayerAvatar() {
@@ -497,11 +503,42 @@ async function refreshConfiguredPlayerAvatar() {
cachedConfiguredPlayerAvatarSrc = profile?.avatar?.trim() || "images/unknown.svg";
friendScoresPlayerAvatar.src = cachedConfiguredPlayerAvatarSrc;
}
+function applyPersonalBestRow(pbPercent) {
+ currentPbAccuracyPercent = pbPercent;
+ friendScoresPanel.classList.toggle("has-personal-best", pbPercent !== null);
+ if (pbPercent === null) {
+ personalBestAcc.textContent = "";
+ personalBestDelta.textContent = "";
+ personalBestDelta.classList.remove("personal-best-delta--ahead", "personal-best-delta--behind");
+ return;
+ }
+ personalBestAcc.textContent = `${pbPercent.toFixed(2)}%`;
+ resetPersonalBestDeltaPlaceholder();
+}
+function resetPersonalBestDeltaPlaceholder() {
+ if (currentPbAccuracyPercent === null) return;
+ personalBestDelta.textContent = "\u2014";
+ personalBestDelta.classList.remove("personal-best-delta--ahead", "personal-best-delta--behind");
+}
+function updatePersonalBestDelta(liveAccuracyPercent) {
+ if (currentPbAccuracyPercent === null) return;
+ const delta = liveAccuracyPercent - currentPbAccuracyPercent;
+ personalBestDelta.textContent = `${delta >= 0 ? "+" : ""}${delta.toFixed(2)}%`;
+ personalBestDelta.classList.toggle("personal-best-delta--ahead", delta > 5e-4);
+ personalBestDelta.classList.toggle("personal-best-delta--behind", delta < -5e-4);
+ if (Math.abs(delta) <= 5e-4) {
+ personalBestDelta.classList.remove("personal-best-delta--ahead", "personal-best-delta--behind");
+ }
+}
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);
+ if (settings.score) {
+ accuracy.textContent = (score.accuracy * 100).toFixed(1);
+ mistakes.textContent = score.missCount ? String(score.missCount) : "";
+ accuracy.classList.toggle("failed", score.currentHealth === 0);
+ }
+ if (settings.friends) {
+ updatePersonalBestDelta(score.accuracy * 100);
+ }
}
function avatarFromScore(score) {
if (typeof score.player === "object" && score.player?.avatar) {
@@ -511,6 +548,7 @@ function avatarFromScore(score) {
return url || null;
}
function clearFriendScores(message) {
+ applyPersonalBestRow(null);
friendScoresList.replaceChildren();
friendScoresEmpty.textContent = message;
friendScoresHeaderText.textContent = "frenz?";
@@ -548,6 +586,7 @@ function friendsRelationListKey(playerId) {
}
function beginFriendScoresForNewMapContext() {
friendScoreRequestId += 1;
+ applyPersonalBestRow(null);
if (!settings.friends) return;
if (!currentMapHash) {
clearFriendScores("No map loaded");
@@ -578,6 +617,7 @@ async function refreshMapFriendScores() {
clearFriendScores("Waiting for BeatLeader player id");
return;
}
+ applyPersonalBestRow(null);
friendScoresList.replaceChildren();
friendScoresPanel.classList.remove("has-items");
friendScoresPanel.classList.add("is-loading");
@@ -594,10 +634,12 @@ async function refreshMapFriendScores() {
friendsRelationCache = fetched;
return fetched;
})();
- const [leaderboards, friends] = await Promise.all([
+ const [leaderboards, friends, selfProfile] = await Promise.all([
fetchBLLeaderboardsByHash(hash),
- friendsPromise
+ friendsPromise,
+ fetchBeatLeaderPlayer(playerId)
]);
+ const myBeatLeaderId = selfProfile?.id ?? playerId;
if (requestId !== friendScoreRequestId) return;
if (leaderboards.length === 0) {
clearFriendScores("No BeatLeader leaderboards found");
@@ -620,6 +662,15 @@ async function refreshMapFriendScores() {
}
const scores = await fetchAllMapScoresByHash(hash, forPlayMode);
if (requestId !== friendScoreRequestId) return;
+ let playerPbAcc = 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 || playerKey !== String(myBeatLeaderId)) continue;
+ const acc = normalizeAccuracy(score.accuracy ?? score.acc);
+ if (acc === null) continue;
+ if (playerPbAcc === null || acc > playerPbAcc) playerPbAcc = acc;
+ }
const bestByPlayer = /* @__PURE__ */ new Map();
for (const score of scores) {
const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null);
@@ -640,6 +691,10 @@ async function refreshMapFriendScores() {
});
}
}
+ if (playerPbAcc !== null) {
+ bestByPlayer.delete(String(myBeatLeaderId));
+ }
+ applyPersonalBestRow(playerPbAcc);
const sorted = Array.from(bestByPlayer.values()).sort((a, b) => b.acc - a.acc);
renderFriendScores(sorted);
} catch {
diff --git a/src/client/index.ts b/src/client/index.ts
index 2a17775..13d0255 100644
--- a/src/client/index.ts
+++ b/src/client/index.ts
@@ -72,6 +72,9 @@ const beatSaberPlus = {
switch (data._event) {
case "gameState":
document.body.dataset.gameState = data.gameStateChanged;
+ if (data.gameStateChanged === "Menu") {
+ resetPersonalBestDeltaPlaceholder();
+ }
break;
case "mapInfo":
void updateMapInfo(data.mapInfoChanged);
@@ -342,6 +345,11 @@ const friendScoresEmpty = must
("friendScoresEmpty");
const friendScoresHeaderText = must("friendScoresHeaderText");
const friendScoresPlayerAvatar = must("friendScoresPlayerAvatar");
const friendScoresHeaderImg = must("friendScoresHeaderImg");
+const personalBestAcc = must("personalBestAcc");
+const personalBestDelta = must("personalBestDelta");
+
+/** BeatLeader PB (percent) for the current map + difficulty; null if none or friends panel inactive. */
+let currentPbAccuracyPercent: number | null = null;
let cachedConfiguredPlayerAvatarKey = "";
let cachedConfiguredPlayerAvatarSrc = "images/unknown.svg";
@@ -367,11 +375,45 @@ async function refreshConfiguredPlayerAvatar() {
friendScoresPlayerAvatar.src = cachedConfiguredPlayerAvatarSrc;
}
+function applyPersonalBestRow(pbPercent: number | null) {
+ currentPbAccuracyPercent = pbPercent;
+ friendScoresPanel.classList.toggle("has-personal-best", pbPercent !== null);
+ if (pbPercent === null) {
+ personalBestAcc.textContent = "";
+ personalBestDelta.textContent = "";
+ personalBestDelta.classList.remove("personal-best-delta--ahead", "personal-best-delta--behind");
+ return;
+ }
+ personalBestAcc.textContent = `${pbPercent.toFixed(2)}%`;
+ resetPersonalBestDeltaPlaceholder();
+}
+
+function resetPersonalBestDeltaPlaceholder() {
+ if (currentPbAccuracyPercent === null) return;
+ personalBestDelta.textContent = "—";
+ personalBestDelta.classList.remove("personal-best-delta--ahead", "personal-best-delta--behind");
+}
+
+function updatePersonalBestDelta(liveAccuracyPercent: number) {
+ if (currentPbAccuracyPercent === null) return;
+ const delta = liveAccuracyPercent - currentPbAccuracyPercent;
+ personalBestDelta.textContent = `${delta >= 0 ? "+" : ""}${delta.toFixed(2)}%`;
+ personalBestDelta.classList.toggle("personal-best-delta--ahead", delta > 0.0005);
+ personalBestDelta.classList.toggle("personal-best-delta--behind", delta < -0.0005);
+ if (Math.abs(delta) <= 0.0005) {
+ personalBestDelta.classList.remove("personal-best-delta--ahead", "personal-best-delta--behind");
+ }
+}
+
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);
+ if (settings.score) {
+ accuracy.textContent = (score.accuracy * 100).toFixed(1);
+ mistakes.textContent = score.missCount ? String(score.missCount) : "";
+ accuracy.classList.toggle("failed", score.currentHealth === 0);
+ }
+ if (settings.friends) {
+ updatePersonalBestDelta(score.accuracy * 100);
+ }
}
function avatarFromScore(score: BeatLeaderScore): string | null {
@@ -383,6 +425,7 @@ function avatarFromScore(score: BeatLeaderScore): string | null {
}
function clearFriendScores(message: string) {
+ applyPersonalBestRow(null);
friendScoresList.replaceChildren();
friendScoresEmpty.textContent = message;
friendScoresHeaderText.textContent = "frenz?";
@@ -427,6 +470,7 @@ function friendsRelationListKey(playerId: string): string {
*/
function beginFriendScoresForNewMapContext() {
friendScoreRequestId += 1;
+ applyPersonalBestRow(null);
if (!settings.friends) return;
if (!currentMapHash) {
clearFriendScores("No map loaded");
@@ -458,6 +502,7 @@ async function refreshMapFriendScores() {
clearFriendScores("Waiting for BeatLeader player id");
return;
}
+ applyPersonalBestRow(null);
friendScoresList.replaceChildren();
friendScoresPanel.classList.remove("has-items");
friendScoresPanel.classList.add("is-loading");
@@ -474,10 +519,12 @@ async function refreshMapFriendScores() {
friendsRelationCache = fetched;
return fetched;
})();
- const [leaderboards, friends] = await Promise.all([
+ const [leaderboards, friends, selfProfile] = await Promise.all([
fetchBLLeaderboardsByHash(hash),
friendsPromise,
+ fetchBeatLeaderPlayer(playerId),
]);
+ const myBeatLeaderId = selfProfile?.id ?? playerId;
if (requestId !== friendScoreRequestId) return;
if (leaderboards.length === 0) {
clearFriendScores("No BeatLeader leaderboards found");
@@ -501,6 +548,15 @@ async function refreshMapFriendScores() {
}
const scores = await fetchAllMapScoresByHash(hash, forPlayMode);
if (requestId !== friendScoreRequestId) return;
+ let playerPbAcc: number | null = 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 || playerKey !== String(myBeatLeaderId)) continue;
+ const acc = normalizeAccuracy(score.accuracy ?? score.acc);
+ if (acc === null) continue;
+ if (playerPbAcc === null || acc > playerPbAcc) playerPbAcc = acc;
+ }
const bestByPlayer = new Map();
for (const score of scores) {
const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null);
@@ -523,6 +579,10 @@ async function refreshMapFriendScores() {
});
}
}
+ if (playerPbAcc !== null) {
+ bestByPlayer.delete(String(myBeatLeaderId));
+ }
+ applyPersonalBestRow(playerPbAcc);
const sorted = Array.from(bestByPlayer.values()).sort((a, b) => b.acc - a.acc);
renderFriendScores(sorted);
} catch {