diff --git a/index.css b/index.css index 33ad7ec..9917565 100644 --- a/index.css +++ b/index.css @@ -344,12 +344,26 @@ span:empty { .friend-score-item { display: flex; - align-items: baseline; - gap: 0.7rem; + align-items: center; + gap: 0.55rem; +} + +.friend-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.friend-name { + flex: 1; + min-width: 0; } .friend-acc { font-weight: 700; + flex-shrink: 0; } /* Settings */ diff --git a/index.js b/index.js index a3ca9d5..649cc19 100644 --- a/index.js +++ b/index.js @@ -118,23 +118,26 @@ async function fetchAllFollowers(playerId, type2, maxPages = 100) { } return all; } -async function fetchFriendIds(playerId, mode, maxPages = 100) { +function normalizeFollowerEntry(entry) { + return { + ...entry, + id: String(entry.id) + }; +} +async function fetchFriends(playerId, mode, maxPages = 100) { const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId); const [following, followers] = await Promise.all([ fetchAllFollowers(canonicalPlayerId, "Following", maxPages), fetchAllFollowers(canonicalPlayerId, "Followers", maxPages) ]); const followingIds = new Set(following.map((entry) => String(entry.id))); - const followerIds = new Set(followers.map((entry) => String(entry.id))); - if (mode === "following") return followingIds; - if (mode === "followers") return followerIds; - const mutuals = /* @__PURE__ */ new Set(); - for (const id of followerIds) { - if (followingIds.has(id)) { - mutuals.add(id); - } + if (mode === "following") { + return following.map((entry) => normalizeFollowerEntry(entry)); } - return mutuals; + if (mode === "followers") { + return followers.map((entry) => normalizeFollowerEntry(entry)); + } + return followers.filter((entry) => followingIds.has(String(entry.id))).map((entry) => normalizeFollowerEntry(entry)); } function normalizeAccuracy(value) { if (typeof value !== "number" || !Number.isFinite(value)) return null; @@ -289,6 +292,13 @@ function updateScore(score) { 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; @@ -306,13 +316,19 @@ function renderFriendScores(items) { 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(name, acc); + li.append(acc, avatar, name); friendScoresList.appendChild(li); } } @@ -335,15 +351,20 @@ async function refreshMapFriendScores() { friendScoresEmpty.textContent = "Loading mutual friend scores..."; const requestId = ++friendScoreRequestId; try { - const [leaderboards, mutualFriendIds] = await Promise.all([ + const [leaderboards, friends] = await Promise.all([ fetchBLLeaderboardsByHash(hash), - fetchFriendIds(playerId, settings.friendMode) + 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); @@ -353,17 +374,21 @@ async function refreshMapFriendScores() { if (requestId !== friendScoreRequestId) return; const bestByPlayer = /* @__PURE__ */ new Map(); for (const score of scores) { - const playerId2 = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null); - const playerKey = playerId2 == null ? "" : String(playerId2); + 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 || playerKey, - acc + name: playerName || friendMeta?.name || playerKey, + acc, + avatar: fromScore ?? fromFriend }); } } diff --git a/src/client/beatleader.ts b/src/client/beatleader.ts index 99b6f25..aaf8dc2 100644 --- a/src/client/beatleader.ts +++ b/src/client/beatleader.ts @@ -166,6 +166,32 @@ export async function fetchMutualFriendIds(playerId: string, maxPages = 100): Pr return fetchFriendIds(playerId, "mutual", maxPages); } +function normalizeFollowerEntry(entry: BeatLeaderFollower): BeatLeaderFollower { + return { + ...entry, + id: String(entry.id), + }; +} + +/** Friend list for the given mode, with `avatar` / `name` from BeatLeader follower payloads. */ +export async function fetchFriends(playerId: string, mode: FriendMode, maxPages = 100): Promise { + const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId); + const [following, followers] = await Promise.all([ + fetchAllFollowers(canonicalPlayerId, "Following", maxPages), + fetchAllFollowers(canonicalPlayerId, "Followers", maxPages), + ]); + const followingIds = new Set(following.map((entry) => String(entry.id))); + if (mode === "following") { + return following.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower)); + } + if (mode === "followers") { + return followers.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower)); + } + return followers + .filter((entry) => followingIds.has(String(entry.id))) + .map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower)); +} + export async function fetchFriendIds(playerId: string, mode: FriendMode, maxPages = 100): Promise> { const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId); const [following, followers] = await Promise.all([ @@ -186,18 +212,7 @@ export async function fetchFriendIds(playerId: string, mode: FriendMode, maxPage } export async function fetchMutualFriends(playerId: string, maxPages = 100): Promise { - const canonicalPlayerId = await resolveBeatLeaderPlayerId(playerId); - const [following, followers] = await Promise.all([ - fetchAllFollowers(canonicalPlayerId, "Following", maxPages), - fetchAllFollowers(canonicalPlayerId, "Followers", maxPages), - ]); - const followingIds = new Set(following.map((entry) => String(entry.id))); - return followers - .filter((entry) => followingIds.has(String(entry.id))) - .map((entry) => ({ - ...entry, - id: String(entry.id), - })); + return fetchFriends(playerId, "mutual", maxPages); } export function normalizeAccuracy(value: number | null | undefined): number | null { diff --git a/src/client/index.ts b/src/client/index.ts index 51e78ee..4ef7312 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,4 +1,5 @@ import type { + BeatLeaderScore, BeatSaberPlusEvent, ChatRequestEntry, ChatRequestPayload, @@ -9,7 +10,7 @@ import { fetchBeatSaverMapById, fetchBeatSaverMeta } from "./beatsaver.ts"; import { fetchAllMapScoresByHash, fetchBLLeaderboardsByHash, - fetchFriendIds, + fetchFriends, type FriendMode, normalizeAccuracy, } from "./beatleader.ts"; @@ -190,6 +191,14 @@ function updateScore(score: Score) { 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; @@ -198,7 +207,7 @@ function clearFriendScores(message: string) { friendScoresPanel.classList.remove("has-items", "is-loading"); } -function renderFriendScores(items: Array<{ name: string; acc: number }>) { +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"); @@ -208,13 +217,19 @@ function renderFriendScores(items: Array<{ name: string; acc: number }>) { 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(name, acc); + li.append(acc, avatar, name); friendScoresList.appendChild(li); } } @@ -238,15 +253,17 @@ async function refreshMapFriendScores() { friendScoresEmpty.textContent = "Loading mutual friend scores..."; const requestId = ++friendScoreRequestId; try { - const [leaderboards, mutualFriendIds] = await Promise.all([ + const [leaderboards, friends] = await Promise.all([ fetchBLLeaderboardsByHash(hash), - fetchFriendIds(playerId, settings.friendMode), + 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" @@ -258,21 +275,25 @@ async function refreshMapFriendScores() { } const scores = await fetchAllMapScoresByHash(hash, leaderboards); if (requestId !== friendScoreRequestId) return; - const bestByPlayer = new Map(); + const bestByPlayer = new Map(); for (const score of scores) { - const playerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null); - const playerKey = playerId == null ? "" : String(playerId); + 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 || playerKey, + name: playerName || friendMeta?.name || playerKey, acc, + avatar: fromScore ?? fromFriend, }); } } diff --git a/src/client/types.ts b/src/client/types.ts index 72e2af2..4f8b0d3 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -114,6 +114,7 @@ export interface BeatLeaderScore { modifiedScore?: number | null; playerId?: string | number | null; playerName?: string | null; + playerAvatar?: string | null; player?: BeatLeaderPlayer | string | null; }