diff --git a/README.md b/README.md index 91f74fa..893eec2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Beat Saber Overlay -Beat Saber stream overlay, originally based on [twitch.tv/iza_k](https://github.com/ibillingsley/BeatSaber-Overlay) but rewritten in Deno TypeScript. Requires [BeatSaberPlus](https://github.com/hardcpp/BeatSaberPlus) +Beat Saber stream overlay, originally based on the [overlay by iza_k](https://github.com/ibillingsley/BeatSaber-Overlay) but rewritten in Deno TypeScript. Requires [BeatSaberPlus](https://github.com/hardcpp/BeatSaberPlus) ### Preview diff --git a/index.js b/index.js index f61c3aa..88ac278 100644 --- a/index.js +++ b/index.js @@ -54,6 +54,22 @@ function beatleaderUrl(path) { } return `${BASE_URL2}${path}`; } +function normalizeBeatLeaderDifficultyName(value) { + return (value ?? "").toLowerCase().replace(/\s+/g, "").replace("expert+", "expertplus"); +} +function normalizeBeatLeaderModeName(value) { + return (value ?? "").toLowerCase().replace(/\s+/g, ""); +} +function leaderboardsMatchingPlayMode(leaderboards, characteristic2, difficultyRaw) { + const modeNeedle = normalizeBeatLeaderModeName(characteristic2); + const diffNeedle = normalizeBeatLeaderDifficultyName(difficultyRaw); + if (!modeNeedle || !diffNeedle) return []; + return leaderboards.filter((lb) => { + const mode = normalizeBeatLeaderModeName(lb.difficulty?.modeName); + const diff = normalizeBeatLeaderDifficultyName(lb.difficulty?.difficultyName); + return mode === modeNeedle && diff === diffNeedle; + }); +} async function fetchBLLeaderboardsByHash(hash) { const path = `/leaderboards/hash/${encodeURIComponent(hash)}`; try { @@ -273,6 +289,8 @@ var friendsRelationCache = null; var friendScoreRequestId = 0; var mapInfoRequestId = 0; var rawLevelHash = ""; +var currentPlayCharacteristic = ""; +var currentPlayDifficulty = ""; function resolvedHashFromBeatSaverMap(map, fallback) { const v = map.versions?.[0]?.hash; if (typeof v === "string" && v.length > 0) return v.toLowerCase().trim(); @@ -291,6 +309,7 @@ async function applyDebugSong() { const raw = settings.debugSongId.trim(); if (!raw) return; const reqId = ++mapInfoRequestId; + beginFriendScoresForNewMapContext(); document.body.classList.add("loading"); try { const map = await fetchBeatSaverMapForDebug(raw); @@ -314,6 +333,8 @@ async function applyDebugSong() { const resolved = resolvedHashFromBeatSaverMap(map, fallbackHash); rawLevelHash = resolved || fallbackHash; currentMapHash = resolved || fallbackHash; + currentPlayCharacteristic = "Standard"; + currentPlayDifficulty = "ExpertPlus"; const v0 = map.versions?.[0]; const coverUrl = v0?.coverURL?.trim(); cover.src = coverUrl || "images/unknown.svg"; @@ -370,6 +391,8 @@ async function updateMapInfo(data) { void applyDebugSong(); return; } + currentPlayCharacteristic = data.characteristic; + currentPlayDifficulty = data.difficulty; const reqId = ++mapInfoRequestId; const custom = data.level_id.startsWith("custom_level_"); const wip = custom && data.level_id.endsWith("WIP"); @@ -387,6 +410,7 @@ async function updateMapInfo(data) { bsrKey.textContent = custom && !wip ? "\u2026" : custom ? rawLevelHash || "???" : "???"; timeMultiplier = data.timeMultiplier || 1; duration = data.duration / 1e3; + beginFriendScoresForNewMapContext(); if (custom && !wip) { document.body.classList.add("loading"); try { @@ -521,6 +545,23 @@ function renderFriendScores(items) { function friendsRelationListKey(playerId) { return `${playerId}\0${settings.friendMode}`; } +function beginFriendScoresForNewMapContext() { + friendScoreRequestId += 1; + if (!settings.friends) return; + if (!currentMapHash) { + clearFriendScores("No map loaded"); + return; + } + const playerId = getEffectivePlayerId(); + if (!playerId) { + clearFriendScores("Waiting for BeatLeader player id"); + return; + } + friendScoresList.replaceChildren(); + friendScoresPanel.classList.remove("has-items"); + friendScoresPanel.classList.add("is-loading"); + friendScoresEmpty.textContent = "Loading mutual friend scores..."; +} async function refreshMapFriendScores() { const hash = currentMapHash; if (!settings.friends) { @@ -536,6 +577,8 @@ async function refreshMapFriendScores() { clearFriendScores("Waiting for BeatLeader player id"); return; } + friendScoresList.replaceChildren(); + friendScoresPanel.classList.remove("has-items"); friendScoresPanel.classList.add("is-loading"); friendScoresEmpty.textContent = "Loading mutual friend scores..."; const requestId = ++friendScoreRequestId; @@ -559,6 +602,11 @@ async function refreshMapFriendScores() { clearFriendScores("No BeatLeader leaderboards found"); return; } + const forPlayMode = leaderboardsMatchingPlayMode(leaderboards, currentPlayCharacteristic, currentPlayDifficulty); + if (forPlayMode.length === 0) { + clearFriendScores("No BeatLeader leaderboard for this difficulty"); + return; + } const friendById = new Map(friends.map((f) => [ f.id, f @@ -569,7 +617,7 @@ async function refreshMapFriendScores() { clearFriendScores(relationLabel); return; } - const scores = await fetchAllMapScoresByHash(hash, leaderboards); + const scores = await fetchAllMapScoresByHash(hash, forPlayMode); if (requestId !== friendScoreRequestId) return; const bestByPlayer = /* @__PURE__ */ new Map(); for (const score of scores) { diff --git a/src/client/beatleader.ts b/src/client/beatleader.ts index 0599766..461facb 100644 --- a/src/client/beatleader.ts +++ b/src/client/beatleader.ts @@ -28,6 +28,31 @@ function beatleaderUrl(path: string): string { return `${BASE_URL}${path}`; } +/** Match BS+ / BeatSaver difficulty strings to BeatLeader `difficultyName` (handles Expert+ vs ExpertPlus). */ +export function normalizeBeatLeaderDifficultyName(value: string | null | undefined): string { + return (value ?? "").toLowerCase().replace(/\s+/g, "").replace("expert+", "expertplus"); +} + +function normalizeBeatLeaderModeName(value: string | null | undefined): string { + return (value ?? "").toLowerCase().replace(/\s+/g, ""); +} + +/** Keep only the leaderboard row for the played characteristic + difficulty (hash can list every diff). */ +export function leaderboardsMatchingPlayMode( + leaderboards: BeatLeaderLeaderboard[], + characteristic: string, + difficultyRaw: string, +): BeatLeaderLeaderboard[] { + const modeNeedle = normalizeBeatLeaderModeName(characteristic); + const diffNeedle = normalizeBeatLeaderDifficultyName(difficultyRaw); + if (!modeNeedle || !diffNeedle) return []; + return leaderboards.filter((lb) => { + const mode = normalizeBeatLeaderModeName(lb.difficulty?.modeName); + const diff = normalizeBeatLeaderDifficultyName(lb.difficulty?.difficultyName); + return mode === modeNeedle && diff === diffNeedle; + }); +} + export async function fetchBLLeaderboardsByHash(hash: string): Promise { const path = `/leaderboards/hash/${encodeURIComponent(hash)}`; try { diff --git a/src/client/index.ts b/src/client/index.ts index 4db0e24..dbdc82a 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -18,6 +18,7 @@ import { fetchBeatLeaderPlayer, fetchBLLeaderboardsByHash, fetchFriends, + leaderboardsMatchingPlayMode, normalizeAccuracy, } from "./beatleader.ts"; import { mergeOverlayConfigResponse, type OverlayConfigApiBody } from "./overlay-config.ts"; @@ -120,6 +121,9 @@ let friendScoreRequestId = 0; let mapInfoRequestId = 0; /** Hex hash from BS+ `level_id` (before BeatSaver version hash). */ let rawLevelHash = ""; +/** BeatLeader friend scores are limited to this characteristic + difficulty (from BS+ mapInfo, or debug BSR defaults). */ +let currentPlayCharacteristic = ""; +let currentPlayDifficulty = ""; function beatLeaderboardId(lb: BeatLeaderLeaderboard): string { const id = lb.id ?? lb.leaderboardId; @@ -148,6 +152,7 @@ async function applyDebugSong() { const raw = settings.debugSongId.trim(); if (!raw) return; const reqId = ++mapInfoRequestId; + beginFriendScoresForNewMapContext(); document.body.classList.add("loading"); try { const map = await fetchBeatSaverMapForDebug(raw); @@ -171,6 +176,9 @@ async function applyDebugSong() { const resolved = resolvedHashFromBeatSaverMap(map, fallbackHash); rawLevelHash = resolved || fallbackHash; currentMapHash = resolved || fallbackHash; + // Debug BSR has no BS+ difficulty; assume Standard ExpertPlus for BeatLeader lookup. + currentPlayCharacteristic = "Standard"; + currentPlayDifficulty = "ExpertPlus"; const v0 = map.versions?.[0]; const coverUrl = v0?.coverURL?.trim(); @@ -237,6 +245,8 @@ async function updateMapInfo(data: MapInfo) { void applyDebugSong(); return; } + currentPlayCharacteristic = data.characteristic; + currentPlayDifficulty = data.difficulty; const reqId = ++mapInfoRequestId; const custom = data.level_id.startsWith("custom_level_"); const wip = custom && data.level_id.endsWith("WIP"); @@ -256,6 +266,8 @@ async function updateMapInfo(data: MapInfo) { timeMultiplier = data.timeMultiplier || 1; duration = data.duration / 1000; + beginFriendScoresForNewMapContext(); + if (custom && !wip) { document.body.classList.add("loading"); try { @@ -409,6 +421,28 @@ function friendsRelationListKey(playerId: string): string { return `${playerId}\0${settings.friendMode}`; } +/** + * Call synchronously when map identity / difficulty changes so stale in-flight fetches cannot repaint, + * and the panel does not keep showing the previous map’s scores while BeatLeader loads. + */ +function beginFriendScoresForNewMapContext() { + friendScoreRequestId += 1; + if (!settings.friends) return; + if (!currentMapHash) { + clearFriendScores("No map loaded"); + return; + } + const playerId = getEffectivePlayerId(); + if (!playerId) { + clearFriendScores("Waiting for BeatLeader player id"); + return; + } + friendScoresList.replaceChildren(); + friendScoresPanel.classList.remove("has-items"); + friendScoresPanel.classList.add("is-loading"); + friendScoresEmpty.textContent = "Loading mutual friend scores..."; +} + async function refreshMapFriendScores() { const hash = currentMapHash; if (!settings.friends) { @@ -424,6 +458,8 @@ async function refreshMapFriendScores() { clearFriendScores("Waiting for BeatLeader player id"); return; } + friendScoresList.replaceChildren(); + friendScoresPanel.classList.remove("has-items"); friendScoresPanel.classList.add("is-loading"); friendScoresEmpty.textContent = "Loading mutual friend scores..."; const requestId = ++friendScoreRequestId; @@ -447,6 +483,11 @@ async function refreshMapFriendScores() { clearFriendScores("No BeatLeader leaderboards found"); return; } + const forPlayMode = leaderboardsMatchingPlayMode(leaderboards, currentPlayCharacteristic, currentPlayDifficulty); + if (forPlayMode.length === 0) { + clearFriendScores("No BeatLeader leaderboard for this difficulty"); + return; + } const friendById = new Map(friends.map((f) => [f.id, f])); const mutualFriendIds = new Set(friends.map((f) => f.id)); if (mutualFriendIds.size === 0) { @@ -458,7 +499,7 @@ async function refreshMapFriendScores() { clearFriendScores(relationLabel); return; } - const scores = await fetchAllMapScoresByHash(hash, leaderboards); + const scores = await fetchAllMapScoresByHash(hash, forPlayMode); if (requestId !== friendScoreRequestId) return; const bestByPlayer = new Map(); for (const score of scores) {