Compare commits
10 Commits
d73c1ac495
...
80539af66e
| Author | SHA1 | Date | |
|---|---|---|---|
| 80539af66e | |||
| d971936445 | |||
| 976c331834 | |||
| 77059fd2b8 | |||
| 29ba174197 | |||
| e43d177afe | |||
| 9dbd17eb49 | |||
| 8ed38d05a7 | |||
| 71340b6b1d | |||
| f2181c7cf3 |
@@ -0,0 +1,10 @@
|
||||
# Set default behavior to automatically normalize line endings
|
||||
* text=auto
|
||||
|
||||
# Force bash/shell scripts to always use LF
|
||||
*.sh text eol=lf
|
||||
*.bash text eol=lf
|
||||
|
||||
# Force Windows scripts to use CRLF
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
fonts/rajdhani-fontfacekit/
|
||||
ChatRequest.json
|
||||
chat-request-database.path
|
||||
overlay.toml
|
||||
*.code-workspace
|
||||
@@ -1,10 +1,6 @@
|
||||
# AGENTS.md
|
||||
|
||||
OBS/browser overlay that reads Beat Saber Plus Song Overlay events over WebSocket (`ws://localhost:2947/socket`). Serve the folder with **`deno task serve`** (see [`src/server/serve.ts`](src/server/serve.ts)) so the request-queue JSON loads from the same origin; configure the BS+ database path via `CHAT_REQUEST_DATABASE` or `chat-request-database.path`.
|
||||
|
||||
## Preference: HTTP only, no `file://`
|
||||
|
||||
Do **not** add or maintain code paths for opening the overlay as **`file://`**. The client assumes **`http:` / `https:`** for fetching JSON (cache-busted `fetch`). Do not reintroduce XHR/`file:` fallbacks or docs that suggest local file URLs—one supported way: serve with Deno (or another HTTP server) per [`README.md`](README.md).
|
||||
OBS/browser overlay that reads Beat Saber Plus Song Overlay events over WebSocket (`ws://localhost:2947/socket`). Serve the folder with **`deno task serve`** (see [`src/server/serve.ts`](src/server/serve.ts)) so the request-queue JSON loads from the same origin; configure **`overlay.toml`** (BS+ `Database.json` path, BeatLeader id, and optional UI defaults).
|
||||
|
||||
## Files of interest
|
||||
|
||||
@@ -13,7 +9,8 @@ Do **not** add or maintain code paths for opening the overlay as **`file://`**.
|
||||
- [`src/client/types.ts`](src/client/types.ts) — Shared TypeScript types for BS+ payloads/events and chat request JSON.
|
||||
- [`index.js`](index.js) — Bundled browser output generated from `src/client/index.ts` via `deno task build`.
|
||||
- [`index.css`](index.css) — Layout, theming, visibility toggles driven by body classes and CSS variables from settings.
|
||||
- [`src/server/serve.ts`](src/server/serve.ts) — Deno static server + optional mapping of `ChatRequest.json` / `database.json` to the real BS+ `Database.json` path.
|
||||
- [`src/server/serve.ts`](src/server/serve.ts) — Deno static server; reads **`overlay.toml`** and maps `ChatRequest.json` to BS+ `Database.json`; serves **`/api/overlay-config`** (`defaults` JSON for the overlay UI and BeatLeader id).
|
||||
- [`src/client/overlay-config.ts`](src/client/overlay-config.ts) — TOML field mapping and merging **`/api/overlay-config`** into page defaults.
|
||||
- [`deno.json`](deno.json) — Deno tasks and TypeScript compiler options (`build`, `serve`, `dev`).
|
||||
- [`images/`](images/) — Cover fallback (`unknown.svg`), characteristic icons under `images/characteristic/` (filenames match BS+ characteristic strings).
|
||||
- [`README.md`](README.md) — User-facing usage (Deno, OBS URL, BS+ module).
|
||||
@@ -24,16 +21,10 @@ Do **not** add or maintain code paths for opening the overlay as **`file://`**.
|
||||
|
||||
Beat Saber Plus itself (game mod) exposes the socket; this repo is only the HTML/CSS/TS client.
|
||||
|
||||
## Recent implementation notes (2026-04)
|
||||
## Implementation notes
|
||||
|
||||
- **Live API calls must be runtime-proxied**: browser/OBS hits CORS on BeatLeader/BeatSaver. Keep live requests same-origin via `src/server/serve.ts`:
|
||||
- `/api/beatleader?path=/...`
|
||||
- `/api/beatsaver?path=/...`
|
||||
- **Live API calls are proxied** same-origin via `src/server/serve.ts`: `/api/beatleader?path=/...`, `/api/beatsaver?path=/...` (CORS).
|
||||
- **Map correlation is hash-based**: resolve BeatSaver map/hash first, then BeatLeader leaderboards via `/leaderboards/hash/{hash}`.
|
||||
- **Score source for friend matching**: use `/leaderboard/{leaderboardId}` scores for stable `playerId` fields; do not rely on v5 score payload shape for player IDs.
|
||||
- **Mutual friends definition**: intersection of `/player/{id}/followers?type=Following` and `type=Followers` (paged by `page` + `count` only).
|
||||
- **Overlay feature added**: `#friendScores` panel shows best accuracy per mutual friend for current map, sorted DESC.
|
||||
- **Debug defaults**:
|
||||
- mock map key: `4f4e4`
|
||||
- debug BeatLeader player id: `76561199407393962`
|
||||
- both debug inputs must have no effect when debug is disabled.
|
||||
- **Friend scores** use `/leaderboard/{leaderboardId}` for stable `playerId` fields.
|
||||
- **Mutual friends**: intersection of `/player/{id}/followers?type=Following` and `type=Followers` (paged by `page` + `count`).
|
||||
- **`#friendScores`**: best accuracy per friend for the current map, sorted descending.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Beat Saber Overlay
|
||||
|
||||
Simple Beat Saber stream overlay for [twitch.tv/iza_k](https://www.twitch.tv/iza_k)
|
||||
Requires [BeatSaberPlus](https://github.com/hardcpp/BeatSaberPlus)
|
||||
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)
|
||||
|
||||
### Preview
|
||||
|
||||
@@ -9,26 +8,24 @@ Requires [BeatSaberPlus](https://github.com/hardcpp/BeatSaberPlus)
|
||||
|
||||
## Setup
|
||||
|
||||
Install [Deno](https://docs.deno.com/runtime/getting_started/installation/) and serve the overlay over HTTP (see below).
|
||||
* Clone the repo
|
||||
* Configure `overlay.toml`
|
||||
* Install [Deno](https://docs.deno.com/runtime/getting_started/installation/) (`winget install Denoland.Deno`)
|
||||
* Maybe run `deno task build`
|
||||
* Run `deno task serve`
|
||||
* In OBS add a Browser source and set the url to the served address (`http://127.0.0.1:8080/index.html`)
|
||||
|
||||
The browser overlay source is now TypeScript (`src/client/index.ts`) and is bundled to `index.js` with `deno task build` (run automatically by `deno task serve`).
|
||||
## Configuration
|
||||
|
||||
The server must know where Beat Saber Plus stores **`ChatRequest/Database.json`**. It then serves that file as `ChatRequest.json` and `database.json` (same data) over HTTP—no symlink.
|
||||
Configuration is `overlay.toml` in the repo root (copy from `overlay.toml.example`; the real file is gitignored). It can set:
|
||||
|
||||
**Easiest:** copy `chat-request-database.path.example` to **`chat-request-database.path`** in this repo and put **one line**: the absolute path to `Database.json` (gitignored, so it stays on your machine).
|
||||
- `chat_request_database` — absolute path to Beat Saber Plus `ChatRequest/Database.json`. The server serves that file as `ChatRequest.json` over HTTP (no symlink).
|
||||
- `beatleader_player_id` — default BeatLeader id for friend scores (the server logs a warning if this is missing).
|
||||
- Overlay UI defaults — optional toggles and layout matching the settings dialog: `cover`, `map_info`, `time`, `score`, `friends`, `friend_mode` (`mutual` \| `following` \| `followers`), `bsr`, `right`, `bottom`, `scale`, `fade`.
|
||||
|
||||
**Or** set the environment variable (overrides the path file):
|
||||
Mutual friend mode shows scores from players that you follow and that follow you back on Beatleader. Or in other words, users that are in both your following and follower list.
|
||||
|
||||
```powershell
|
||||
$env:CHAT_REQUEST_DATABASE = "C:\Users\pleb\BSManager\BSInstances\1.40.8\UserData\BeatSaberPlus\ChatRequest\Database.json"
|
||||
deno task serve
|
||||
```
|
||||
|
||||
Then open **`http://127.0.0.1:8080/index.html`** (use the same host and port the terminal prints). Set `PORT` if needed. In OBS, use that URL for the browser source.
|
||||
|
||||
If neither the path file nor `CHAT_REQUEST_DATABASE` is set, the overlay only finds a queue if you place a `ChatRequest.json` copy in the repo folder.
|
||||
|
||||
### Usage
|
||||
## Usage
|
||||
|
||||
Clone the repo and run `deno task serve` as above.
|
||||
|
||||
@@ -37,3 +34,7 @@ Clone the repo and run `deno task serve` as above.
|
||||
- `deno task build` - bundle/check `src/client/index.ts` for browser output (`index.js`)
|
||||
- `deno task serve` - build, then serve `index.html` and JSON files
|
||||
- `deno task dev` - watch and rebuild `index.js` when client TS changes
|
||||
|
||||
## Testing in a browser
|
||||
|
||||
Click anywhere on the page (outside the settings dialog) to toggle preview mode. The song overlay appears with the built-in placeholder labels so you can check layout, toggles, scale, and fade without a game connection.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 744 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"path": "../../src/BeatSaberPlus.wiki"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
C:\path\to\UserData\BeatSaberPlus\ChatRequest\Database.json
|
||||
@@ -3,6 +3,7 @@
|
||||
"specifiers": {
|
||||
"jsr:@std/assert@*": "1.0.19",
|
||||
"jsr:@std/cli@^1.0.28": "1.0.28",
|
||||
"jsr:@std/collections@^1.1.3": "1.1.6",
|
||||
"jsr:@std/encoding@^1.0.10": "1.0.10",
|
||||
"jsr:@std/fmt@^1.0.9": "1.0.9",
|
||||
"jsr:@std/fs@^1.0.23": "1.0.23",
|
||||
@@ -13,7 +14,8 @@
|
||||
"jsr:@std/net@^1.0.6": "1.0.6",
|
||||
"jsr:@std/path@*": "1.1.4",
|
||||
"jsr:@std/path@^1.1.4": "1.1.4",
|
||||
"jsr:@std/streams@^1.0.17": "1.0.17"
|
||||
"jsr:@std/streams@^1.0.17": "1.0.17",
|
||||
"jsr:@std/toml@*": "1.0.11"
|
||||
},
|
||||
"jsr": {
|
||||
"@std/assert@1.0.19": {
|
||||
@@ -25,6 +27,9 @@
|
||||
"@std/cli@1.0.28": {
|
||||
"integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a"
|
||||
},
|
||||
"@std/collections@1.1.6": {
|
||||
"integrity": "b458160ce65ea5ad35da05d0a5cbee4b583677c8b443a10d7beb0c4ac63f2baa"
|
||||
},
|
||||
"@std/encoding@1.0.10": {
|
||||
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
|
||||
},
|
||||
@@ -68,6 +73,12 @@
|
||||
},
|
||||
"@std/streams@1.0.17": {
|
||||
"integrity": "7859f3d9deed83cf4b41f19223d4a67661b3d3819e9fc117698f493bf5992140"
|
||||
},
|
||||
"@std/toml@1.0.11": {
|
||||
"integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715",
|
||||
"dependencies": [
|
||||
"jsr:@std/collections"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
# Architectural Decision Record
|
||||
|
||||
## Static web app with typed JavaScript
|
||||
|
||||
The overlay is plain HTML/CSS/JS with no bundler: run it locally with **`deno task serve`** or host the files on any static server. OBS uses an **http(s) URL** to the page.
|
||||
|
||||
Consequences: Ease of use
|
||||
|
||||
- Adding or updating the overlay is **copy files or point OBS at a URL**; nothing to build before use beyond optional Deno for local serving and the chat-request database path.
|
||||
- Type safety is **optional IDE assistance**, not enforced at publish time (there is no `tsc` in CI by default).
|
||||
|
||||
This dependency-free client shape stays easy to wire as an OBS Browser Source while keeping the codebase maintainable through JSDoc and `types.d.ts`.
|
||||
|
||||
## BeatSaberPlus
|
||||
|
||||
Because I already use it.
|
||||
@@ -1,37 +0,0 @@
|
||||
# Testing in a browser
|
||||
|
||||
Run **`deno task serve`** from the repo (see [README](../README.md)), then open the overlay in Chromium (Chrome, Edge) or Firefox.
|
||||
|
||||
## Open the page
|
||||
|
||||
Use the URL the server prints, for example:
|
||||
|
||||
`http://127.0.0.1:8080/index.html?scale=1.5`
|
||||
|
||||
Settings live in the **URL fragment** (after `#`). Put query parameters **before** the hash if you use both: `index.html?debug=1#…`
|
||||
|
||||
## Preview the song overlay (no Beat Saber)
|
||||
|
||||
**Click anywhere** on the page (outside the settings dialog) to toggle **preview** mode. The song overlay appears with the built-in placeholder labels so you can check layout, toggles, scale, and fade without a game connection.
|
||||
|
||||
## Request list simulation
|
||||
|
||||
Enable **Debug** in the settings dialog (or add **`?debug=1`** to the URL). The song requests list then uses the **`history`** array from the JSON instead of **`queue`**, so you can see how entries look with the same shape as real data (`key`, `rqn`, `npr`, etc.). The header title is unchanged.
|
||||
|
||||
The Deno server exposes Beat Saber Plus data as **`ChatRequest.json`** and **`database.json`** (same file). With debug, the page tries **`ChatRequest.json`** first, then **`database.json`**. To load a different filename, add **`?requests=yourfile.json`**.
|
||||
|
||||
## What you should see
|
||||
|
||||
- **Developer tools → Console:** log lines such as `Connecting to ws://localhost:2947/socket` on load. If [Beat Saber Plus](https://github.com/hardcpp/BeatSaberPlus) is **not** running with the Song Overlay module listening on that port, the socket will close and the script **retries every 10 seconds**—that is expected.
|
||||
- **With Beat Saber running** and BS+ Song Overlay enabled: you should see `Connection open.` when the WebSocket succeeds, and map info, time, and score update while you play.
|
||||
|
||||
## Quick UI checks without the game
|
||||
|
||||
- **Click** the page (outside the settings dialog) to toggle **preview** and open the settings strip.
|
||||
- Change checkboxes and values in the dialog; the **URL hash** should update and layout should reflect toggles and scale.
|
||||
|
||||
## Live data path
|
||||
|
||||
End-to-end testing needs the same pieces as streaming: **Beat Saber**, **Beat Saber Plus** with the **Song Overlay** module active, so `ws://localhost:2947/socket` accepts connections. The browser page only connects to that local WebSocket; it does not start the game server.
|
||||
|
||||
For custom maps, the overlay may request **BeatSaver** over HTTPS; use **Network** in devtools if those requests fail (offline, blocked, or API errors).
|
||||
@@ -307,11 +307,28 @@ span:empty {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
opacity: 0.92;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
#friendScoresPlayerAvatar {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#friendScoresHeaderImg {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
#friendScoresList {
|
||||
margin: 0;
|
||||
padding-left: 2rem;
|
||||
padding-inline-start: 2rem;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -335,12 +352,26 @@ span:empty {
|
||||
|
||||
.friend-score-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.7rem;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.friend-avatar {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
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 */
|
||||
@@ -390,10 +421,34 @@ body.bottom #time {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
body:not(.debug) #mockBsrSetting {
|
||||
display: none;
|
||||
#settings .beatLeaderPlayerRow,
|
||||
#settings .debugSongIdRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
body:not(.debug) #debugPlayerSetting {
|
||||
display: none;
|
||||
#debugSongIdInput {
|
||||
flex: 0 0 10rem;
|
||||
width: 10rem;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
/* One wrapper sets weight; inner <button> uses font: inherit (avoids UA bold + body 600). */
|
||||
.debugSongIdHint {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.debugSongIdHint button {
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #a8d4ff;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.debugSongIdHint button:hover {
|
||||
color: #cfe9ff;
|
||||
}
|
||||
|
||||
+15
-5
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="friendScores" aria-live="polite">
|
||||
<div id="friendScoresHeader">Mutual friends on BeatLeader</div>
|
||||
<div id="friendScoresHeader"><img id="friendScoresPlayerAvatar" src="images/unknown.svg" alt=""> <span id="friendScoresHeaderText">frenz</span> <img id="friendScoresHeaderImg" src="assets/peepohigh.webp" alt=""></div>
|
||||
<ol id="friendScoresList"></ol>
|
||||
<div id="friendScoresEmpty">No map loaded</div>
|
||||
</div>
|
||||
@@ -66,6 +66,15 @@
|
||||
<label>Show time: <input id="timeInput" type="checkbox"></label>
|
||||
<label>Show score: <input id="scoreInput" type="checkbox"></label>
|
||||
<label>Show friend scores: <input id="friendsInput" type="checkbox"></label>
|
||||
<label>Friend list mode: <select id="friendModeInput">
|
||||
<option value="mutual">Followed + follower (mutual)</option>
|
||||
<option value="following">Following (I follow them)</option>
|
||||
<option value="followers">Followers (they follow me)</option>
|
||||
</select></label>
|
||||
<label id="beatLeaderPlayerSetting">Player id: <span class="beatLeaderPlayerRow">
|
||||
<span class="debugSongIdHint">e.g. <button type="button" id="beatLeaderPlayerExample" title="Fill with pleb's numeric id">pleb</button></span>
|
||||
<input id="beatLeaderPlayerInput" type="text" placeholder="7656119… or alias" spellcheck="false" autocomplete="off">
|
||||
</span></label>
|
||||
<label>Show BSR / map id: <input id="bsrInput" type="checkbox"></label>
|
||||
<label>Position: <select id="positionInput">
|
||||
<option value="[false,false]">Top left</option>
|
||||
@@ -75,12 +84,13 @@
|
||||
</select></label>
|
||||
<label>Scale (%): <input id="scaleInput" type="number" min="10" max="1000" step="5"></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 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>
|
||||
<label>Debug BSR ID: <span class="debugSongIdRow">
|
||||
<span class="debugSongIdHint">e.g. <button type="button" id="debugSongIdExample" title="Fill with next example BSR id (cycles)">43239</button></span>
|
||||
<input id="debugSongIdInput" class="debugSongIdInput" type="text" placeholder="e.g. 4f4e4 or 40-char hash" spellcheck="false" autocomplete="off">
|
||||
</span></label>
|
||||
<br>
|
||||
<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 is a fork of Iza's overlay</a>
|
||||
</dialog>
|
||||
<script type="module" src="index.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
// src/client/types.ts
|
||||
var OVERLAY_SETTINGS_INITIAL = {
|
||||
cover: true,
|
||||
mapInfo: true,
|
||||
time: true,
|
||||
score: true,
|
||||
friends: true,
|
||||
friendMode: "mutual",
|
||||
bsr: false,
|
||||
beatLeaderId: "",
|
||||
right: false,
|
||||
bottom: true,
|
||||
scale: 1,
|
||||
fade: 300,
|
||||
debugSongId: ""
|
||||
};
|
||||
|
||||
// src/client/beatsaver.ts
|
||||
var BASE_URL = "https://api.beatsaver.com";
|
||||
var USE_RUNTIME_PROXY = typeof document !== "undefined";
|
||||
@@ -29,6 +46,7 @@ async function fetchBeatSaverMapById(mapId) {
|
||||
// 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 beatleaderUrl(path) {
|
||||
if (USE_RUNTIME_PROXY2) {
|
||||
@@ -36,38 +54,68 @@ 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 {
|
||||
const res = await fetch(beatleaderUrl(`/leaderboards/hash/${encodeURIComponent(hash)}`));
|
||||
const res = await fetch(beatleaderUrl(path));
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const leaderboards = Array.isArray(data) ? data : Array.isArray(data.leaderboards) ? data.leaderboards : [];
|
||||
return leaderboards;
|
||||
return Array.isArray(data) ? data : Array.isArray(data.leaderboards) ? data.leaderboards : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
async function resolveBeatLeaderPlayerId(playerId) {
|
||||
async function fetchBeatLeaderPlayer(playerId) {
|
||||
const path = `/player/${encodeURIComponent(playerId)}`;
|
||||
try {
|
||||
const res = await fetch(beatleaderUrl(`/player/${encodeURIComponent(playerId)}`));
|
||||
if (!res.ok) return playerId;
|
||||
const res = await fetch(beatleaderUrl(path));
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const canonicalId = data.id;
|
||||
return canonicalId == null ? playerId : String(canonicalId);
|
||||
const id = data.id == null ? playerId : String(data.id);
|
||||
const avatar = typeof data.avatar === "string" ? data.avatar.trim() || null : null;
|
||||
return {
|
||||
id,
|
||||
avatar
|
||||
};
|
||||
} catch {
|
||||
return playerId;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function fetchLeaderboardScoresById(leaderboardId, maxPages = 20) {
|
||||
async function resolveBeatLeaderPlayerId(playerId) {
|
||||
const p = await fetchBeatLeaderPlayer(playerId);
|
||||
return p?.id ?? playerId;
|
||||
}
|
||||
async function fetchLeaderboardScoresById(leaderboardId, maxPages = MAX_LEADERBOARD_SCORE_PAGES) {
|
||||
const scores = [];
|
||||
for (let page = 1; page <= maxPages; page += 1) {
|
||||
const pageSize = PAGE_SIZE;
|
||||
let page = 1;
|
||||
for (; ; ) {
|
||||
if (page > maxPages) break;
|
||||
const qs = new URLSearchParams({
|
||||
leaderboardContext: "general",
|
||||
page: String(page),
|
||||
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;
|
||||
try {
|
||||
res = await fetch(url);
|
||||
@@ -79,11 +127,12 @@ async function fetchLeaderboardScoresById(leaderboardId, maxPages = 20) {
|
||||
const batch = Array.isArray(payload.scores) ? payload.scores : [];
|
||||
if (batch.length === 0) break;
|
||||
scores.push(...batch);
|
||||
if (batch.length < PAGE_SIZE) break;
|
||||
if (batch.length < pageSize) break;
|
||||
page += 1;
|
||||
}
|
||||
return scores;
|
||||
}
|
||||
async function fetchAllMapScoresByHash(hash, leaderboards, maxPagesPerLeaderboard = 20) {
|
||||
async function fetchAllMapScoresByHash(hash, leaderboards, maxPagesPerLeaderboard = MAX_LEADERBOARD_SCORE_PAGES) {
|
||||
const requests = leaderboards.map((lb) => {
|
||||
const leaderboardId = lb.id == null ? null : String(lb.id);
|
||||
if (!leaderboardId) return Promise.resolve([]);
|
||||
@@ -98,7 +147,8 @@ async function fetchFollowersPage(playerId, type2, page, count) {
|
||||
page: String(page),
|
||||
count: String(count)
|
||||
});
|
||||
const url = beatleaderUrl(`/player/${encodeURIComponent(playerId)}/followers?${qs}`);
|
||||
const path = `/player/${encodeURIComponent(playerId)}/followers?${qs}`;
|
||||
const url = beatleaderUrl(path);
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return [];
|
||||
@@ -118,27 +168,43 @@ async function fetchAllFollowers(playerId, type2, maxPages = 100) {
|
||||
}
|
||||
return all;
|
||||
}
|
||||
async function fetchMutualFriendIds(playerId, 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 mutuals = /* @__PURE__ */ new Set();
|
||||
for (const entry of followers) {
|
||||
const id = String(entry.id);
|
||||
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;
|
||||
return value <= 1 ? value * 100 : value;
|
||||
}
|
||||
|
||||
// src/client/overlay-config.ts
|
||||
function mergeOverlayConfigResponse(target, body) {
|
||||
const d = body.defaults;
|
||||
if (!d) return;
|
||||
const out = target;
|
||||
for (const key of Object.keys(d)) {
|
||||
const v = d[key];
|
||||
if (v !== void 0) out[key] = v;
|
||||
}
|
||||
}
|
||||
|
||||
// src/client/index.ts
|
||||
function must(id) {
|
||||
const element = document.getElementById(id);
|
||||
@@ -148,6 +214,27 @@ function must(id) {
|
||||
function parseJson(raw) {
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
var settings = structuredClone(OVERLAY_SETTINGS_INITIAL);
|
||||
var defaults = structuredClone(OVERLAY_SETTINGS_INITIAL);
|
||||
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",
|
||||
@@ -175,31 +262,115 @@ var beatSaberPlus = {
|
||||
break;
|
||||
case "handshake":
|
||||
currentPlayerPlatformId = data.playerPlatformId || "";
|
||||
void refreshConfiguredPlayerAvatar();
|
||||
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.beatLeaderId.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 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();
|
||||
return fallback;
|
||||
}
|
||||
function looksLikeBeatSaverHash(s) {
|
||||
return /^[0-9a-f]{40}$/i.test(s.trim());
|
||||
}
|
||||
async function fetchBeatSaverMapForDebug(id) {
|
||||
const t = id.trim();
|
||||
if (!t) return null;
|
||||
if (looksLikeBeatSaverHash(t)) return fetchBeatSaverMeta(t.toLowerCase());
|
||||
return fetchBeatSaverMapById(t);
|
||||
}
|
||||
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);
|
||||
if (reqId !== mapInfoRequestId) return;
|
||||
if (!map?.id) {
|
||||
rawLevelHash = "";
|
||||
currentMapHash = "";
|
||||
cover.src = "images/unknown.svg";
|
||||
title.textContent = "BeatSaver not found";
|
||||
subTitle.textContent = raw;
|
||||
artist.textContent = "";
|
||||
mapper.textContent = "";
|
||||
difficulty.textContent = "";
|
||||
characteristic.src = "images/characteristic/Standard.svg";
|
||||
difficultyLabel.textContent = "";
|
||||
type.textContent = "";
|
||||
bsrKey.textContent = "";
|
||||
return;
|
||||
}
|
||||
const fallbackHash = looksLikeBeatSaverHash(raw) ? raw.toLowerCase().trim() : "";
|
||||
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";
|
||||
title.textContent = map.metadata?.songName ?? map.name ?? "";
|
||||
subTitle.textContent = map.metadata?.songSubName ?? "";
|
||||
artist.textContent = map.metadata?.songAuthorName ?? "";
|
||||
mapper.textContent = map.metadata?.levelAuthorName ?? "";
|
||||
const firstDiff = v0?.diffs?.[0];
|
||||
difficulty.textContent = firstDiff?.difficulty?.replace("Plus", " +") ?? "\u2014";
|
||||
characteristic.src = firstDiff ? `images/characteristic/${firstDiff.characteristic}.svg` : "images/characteristic/Standard.svg";
|
||||
difficultyLabel.textContent = firstDiff?.label ?? "";
|
||||
type.textContent = "Custom";
|
||||
bsrKey.textContent = map.id;
|
||||
timeMultiplier = 1;
|
||||
duration = 180;
|
||||
if (reqId === mapInfoRequestId) {
|
||||
setTime(0);
|
||||
}
|
||||
} catch {
|
||||
if (reqId !== mapInfoRequestId) return;
|
||||
rawLevelHash = "";
|
||||
currentMapHash = "";
|
||||
cover.src = "images/unknown.svg";
|
||||
title.textContent = "BeatSaver request failed";
|
||||
subTitle.textContent = raw;
|
||||
} finally {
|
||||
if (reqId === mapInfoRequestId) document.body.classList.remove("loading");
|
||||
if (reqId === mapInfoRequestId) void refreshMapFriendScores();
|
||||
}
|
||||
}
|
||||
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}`);
|
||||
function onClose(_e) {
|
||||
setTimeout(connect, retryMs);
|
||||
}
|
||||
connect();
|
||||
@@ -216,9 +387,17 @@ var bsrKey = must("bsrKey");
|
||||
var timeMultiplier = 1;
|
||||
var duration = 0;
|
||||
async function updateMapInfo(data) {
|
||||
if (settings.debugSongId.trim()) {
|
||||
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");
|
||||
currentMapHash = custom ? data.level_id.substring(13, 53).toLowerCase() : "";
|
||||
rawLevelHash = custom ? data.level_id.substring(13, 53).toLowerCase() : "";
|
||||
currentMapHash = rawLevelHash;
|
||||
cover.src = data.coverRaw ? `data:image/jpeg;base64,${data.coverRaw}` : "images/unknown.svg";
|
||||
title.textContent = data.name || "";
|
||||
subTitle.textContent = data.sub_name || "";
|
||||
@@ -228,25 +407,40 @@ async function updateMapInfo(data) {
|
||||
characteristic.src = `images/characteristic/${data.characteristic}.svg`;
|
||||
difficultyLabel.textContent = "";
|
||||
type.textContent = !custom ? "OST" : wip ? "WIP" : "";
|
||||
bsrKey.textContent = data.BSRKey || "???";
|
||||
bsrKey.textContent = custom && !wip ? "\u2026" : custom ? rawLevelHash || "???" : "???";
|
||||
timeMultiplier = data.timeMultiplier || 1;
|
||||
duration = data.duration / 1e3;
|
||||
void refreshMapFriendScores();
|
||||
beginFriendScoresForNewMapContext();
|
||||
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;
|
||||
const map = await fetchBeatSaverMeta(rawLevelHash);
|
||||
if (reqId !== mapInfoRequestId) return;
|
||||
if (!map?.id) {
|
||||
currentMapHash = rawLevelHash;
|
||||
} else {
|
||||
const resolved = resolvedHashFromBeatSaverMap(map, rawLevelHash);
|
||||
currentMapHash = resolved;
|
||||
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 {
|
||||
if (reqId !== mapInfoRequestId) return;
|
||||
currentMapHash = rawLevelHash;
|
||||
} finally {
|
||||
document.body.classList.remove("loading");
|
||||
if (reqId === mapInfoRequestId) void refreshMapFriendScores();
|
||||
}
|
||||
} else {
|
||||
bsrKey.textContent = "???";
|
||||
if (custom && wip) {
|
||||
bsrKey.textContent = rawLevelHash || "???";
|
||||
} else {
|
||||
bsrKey.textContent = "???";
|
||||
}
|
||||
difficultyLabel.textContent = "";
|
||||
void refreshMapFriendScores();
|
||||
}
|
||||
}
|
||||
var timeText = must("timeText");
|
||||
@@ -277,37 +471,97 @@ var mistakes = must("mistakes");
|
||||
var friendScoresPanel = must("friendScores");
|
||||
var friendScoresList = must("friendScoresList");
|
||||
var friendScoresEmpty = must("friendScoresEmpty");
|
||||
var currentMapHash = "";
|
||||
var friendScoreRequestId = 0;
|
||||
var friendScoresHeaderText = must("friendScoresHeaderText");
|
||||
var friendScoresPlayerAvatar = must("friendScoresPlayerAvatar");
|
||||
var friendScoresHeaderImg = must("friendScoresHeaderImg");
|
||||
var cachedConfiguredPlayerAvatarKey = "";
|
||||
var cachedConfiguredPlayerAvatarSrc = "images/unknown.svg";
|
||||
async function refreshConfiguredPlayerAvatar() {
|
||||
const key = `${settings.beatLeaderId.trim()}|${currentPlayerPlatformId}`;
|
||||
const pid = getEffectivePlayerId();
|
||||
if (!pid) {
|
||||
cachedConfiguredPlayerAvatarKey = "";
|
||||
cachedConfiguredPlayerAvatarSrc = "images/unknown.svg";
|
||||
friendScoresPlayerAvatar.src = cachedConfiguredPlayerAvatarSrc;
|
||||
return;
|
||||
}
|
||||
if (key === cachedConfiguredPlayerAvatarKey) {
|
||||
friendScoresPlayerAvatar.src = cachedConfiguredPlayerAvatarSrc;
|
||||
return;
|
||||
}
|
||||
const profile = await fetchBeatLeaderPlayer(pid);
|
||||
const keyAfter = `${settings.beatLeaderId.trim()}|${currentPlayerPlatformId}`;
|
||||
if (keyAfter !== key) return;
|
||||
cachedConfiguredPlayerAvatarKey = key;
|
||||
cachedConfiguredPlayerAvatarSrc = profile?.avatar?.trim() || "images/unknown.svg";
|
||||
friendScoresPlayerAvatar.src = cachedConfiguredPlayerAvatarSrc;
|
||||
}
|
||||
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 mutual scores on this map";
|
||||
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(name, acc);
|
||||
li.append(acc, avatar, name);
|
||||
friendScoresList.appendChild(li);
|
||||
}
|
||||
}
|
||||
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) {
|
||||
@@ -323,38 +577,65 @@ 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;
|
||||
try {
|
||||
const [leaderboards, mutualFriendIds] = await Promise.all([
|
||||
const relKey = friendsRelationListKey(playerId);
|
||||
const friendsPromise = (async () => {
|
||||
if (friendsRelationCache !== null && relKey === friendsRelationCacheKey) {
|
||||
return friendsRelationCache;
|
||||
}
|
||||
const fetched = await fetchFriends(playerId, settings.friendMode);
|
||||
friendsRelationCacheKey = relKey;
|
||||
friendsRelationCache = fetched;
|
||||
return fetched;
|
||||
})();
|
||||
const [leaderboards, friends] = await Promise.all([
|
||||
fetchBLLeaderboardsByHash(hash),
|
||||
fetchMutualFriendIds(playerId)
|
||||
friendsPromise
|
||||
]);
|
||||
if (requestId !== friendScoreRequestId) return;
|
||||
if (leaderboards.length === 0) {
|
||||
clearFriendScores("No BeatLeader leaderboards found");
|
||||
return;
|
||||
}
|
||||
if (mutualFriendIds.size === 0) {
|
||||
clearFriendScores("No mutual BeatLeader followers");
|
||||
const forPlayMode = leaderboardsMatchingPlayMode(leaderboards, currentPlayCharacteristic, currentPlayDifficulty);
|
||||
if (forPlayMode.length === 0) {
|
||||
clearFriendScores("No BeatLeader leaderboard for this difficulty");
|
||||
return;
|
||||
}
|
||||
const scores = await fetchAllMapScoresByHash(hash, leaderboards);
|
||||
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, forPlayMode);
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -365,157 +646,40 @@ async function refreshMapFriendScores() {
|
||||
clearFriendScores("Failed loading BeatLeader scores");
|
||||
}
|
||||
}
|
||||
var settings = {
|
||||
cover: true,
|
||||
mapInfo: true,
|
||||
time: true,
|
||||
score: true,
|
||||
friends: true,
|
||||
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}; `;
|
||||
window.onhashchange = () => {
|
||||
loadSettings();
|
||||
void refreshConfiguredPlayerAvatar();
|
||||
const debugEl = document.getElementById("debugSongIdInput");
|
||||
if (debugEl) debugEl.value = settings.debugSongId;
|
||||
if (settings.debugSongId.trim()) void applyDebugSong();
|
||||
else {
|
||||
mapInfoRequestId += 1;
|
||||
currentMapHash = "";
|
||||
rawLevelHash = "";
|
||||
void refreshMapFriendScores();
|
||||
}
|
||||
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() {
|
||||
const key = settings.mockBsr.trim();
|
||||
if (!settings.debug || !key) return;
|
||||
const map = await fetchBeatSaverMapById(key);
|
||||
if (!map) return;
|
||||
const hash = map.versions?.[0]?.hash?.toLowerCase?.() || "";
|
||||
currentMapHash = hash;
|
||||
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;
|
||||
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;
|
||||
loadSettings();
|
||||
document.head.appendChild(style);
|
||||
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") {
|
||||
void loadRequestQueue();
|
||||
void applyMockMapFromBsr();
|
||||
void refreshMapFriendScores();
|
||||
}
|
||||
};
|
||||
}
|
||||
var mockBsrInput = must("mockBsrInput");
|
||||
mockBsrInput.value = settings.mockBsr;
|
||||
mockBsrInput.oninput = () => {
|
||||
settings.mockBsr = mockBsrInput.value.trim();
|
||||
saveSettings();
|
||||
void applyMockMapFromBsr();
|
||||
};
|
||||
var debugPlayerInput = must("debugPlayerInput");
|
||||
debugPlayerInput.value = settings.debugPlayerId;
|
||||
debugPlayerInput.oninput = () => {
|
||||
settings.debugPlayerId = debugPlayerInput.value.trim();
|
||||
saveSettings();
|
||||
if (settings.debug) 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 DEBUG_BSR_EXAMPLE_IDS = [
|
||||
"43239",
|
||||
"4b55e",
|
||||
"49201",
|
||||
"35a5e",
|
||||
"2c25a",
|
||||
"3864b",
|
||||
"2d205",
|
||||
"41d08",
|
||||
"e298"
|
||||
];
|
||||
var debugBsrExampleIndex = 0;
|
||||
var requestListEl = must("requestList");
|
||||
var requestOverlayEl = must("requestOverlay");
|
||||
var requestEmptyEl = must("requestEmpty");
|
||||
var requestTitleCache = /* @__PURE__ */ new Map();
|
||||
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");
|
||||
}
|
||||
var requestTitleMisses = /* @__PURE__ */ new Set();
|
||||
function loadChatRequestJson() {
|
||||
const base = new URL("ChatRequest.json", location.href);
|
||||
const busted = new URL(base.href);
|
||||
busted.searchParams.set("t", String(Date.now()));
|
||||
return fetch(busted.href, {
|
||||
@@ -525,17 +689,6 @@ function loadJsonNextToPage(fileName) {
|
||||
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,
|
||||
@@ -544,19 +697,26 @@ function requesterLine(item) {
|
||||
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) return;
|
||||
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) {
|
||||
@@ -582,17 +742,124 @@ function renderRequestList(items) {
|
||||
}
|
||||
async function loadRequestQueue() {
|
||||
try {
|
||||
const data = await loadRequestPayload();
|
||||
const data = await loadChatRequestJson();
|
||||
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);
|
||||
const items = (data.queue ?? []).slice(0, MAX_REQUESTS);
|
||||
renderRequestList(items);
|
||||
} catch {
|
||||
requestEmptyEl.textContent = "whupsy, database file missing";
|
||||
requestEmptyEl.textContent = "Request queue unavailable";
|
||||
requestOverlayEl.classList.add("request-load-failed");
|
||||
renderRequestList([]);
|
||||
}
|
||||
}
|
||||
void loadRequestQueue();
|
||||
window.setInterval(() => void loadRequestQueue(), REQUEST_POLL_MS);
|
||||
async function bootstrap() {
|
||||
Object.assign(defaults, OVERLAY_SETTINGS_INITIAL);
|
||||
try {
|
||||
const url = new URL("/api/overlay-config", location.href);
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store"
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
mergeOverlayConfigResponse(defaults, data);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
loadSettings();
|
||||
document.head.appendChild(style);
|
||||
void refreshConfiguredPlayerAvatar();
|
||||
if (settings.debugSongId.trim()) void applyDebugSong();
|
||||
else void refreshMapFriendScores();
|
||||
for (const key of [
|
||||
"cover",
|
||||
"mapInfo",
|
||||
"time",
|
||||
"score",
|
||||
"friends",
|
||||
"bsr"
|
||||
]) {
|
||||
const input = must(`${key}Input`);
|
||||
input.checked = settings[key];
|
||||
input.oninput = () => {
|
||||
settings[key] = input.checked;
|
||||
saveSettings();
|
||||
if (key === "friends") void refreshMapFriendScores();
|
||||
};
|
||||
}
|
||||
const friendModeInput = must("friendModeInput");
|
||||
friendModeInput.value = settings.friendMode;
|
||||
friendModeInput.onchange = () => {
|
||||
settings.friendMode = friendModeInput.value;
|
||||
saveSettings();
|
||||
void refreshMapFriendScores();
|
||||
};
|
||||
const beatLeaderPlayerInput = must("beatLeaderPlayerInput");
|
||||
beatLeaderPlayerInput.value = settings.beatLeaderId;
|
||||
beatLeaderPlayerInput.oninput = () => {
|
||||
settings.beatLeaderId = beatLeaderPlayerInput.value.trim();
|
||||
saveSettings();
|
||||
void refreshConfiguredPlayerAvatar();
|
||||
void refreshMapFriendScores();
|
||||
};
|
||||
must("beatLeaderPlayerExample").onclick = () => {
|
||||
beatLeaderPlayerInput.value = "76561199407393962";
|
||||
beatLeaderPlayerInput.dispatchEvent(new Event("input", {
|
||||
bubbles: true
|
||||
}));
|
||||
};
|
||||
const scale = must("scaleInput");
|
||||
scale.valueAsNumber = settings.scale * 100;
|
||||
scale.oninput = () => {
|
||||
settings.scale = scale.valueAsNumber / 100;
|
||||
saveSettings();
|
||||
};
|
||||
const position = must("positionInput");
|
||||
position.value = JSON.stringify([
|
||||
settings.right,
|
||||
settings.bottom
|
||||
]);
|
||||
position.onchange = () => {
|
||||
[settings.right, settings.bottom] = parseJson(position.value);
|
||||
saveSettings();
|
||||
};
|
||||
const fade = must("fadeInput");
|
||||
fade.valueAsNumber = settings.fade;
|
||||
fade.oninput = () => {
|
||||
settings.fade = fade.valueAsNumber;
|
||||
saveSettings();
|
||||
};
|
||||
const debugSongIdInput = must("debugSongIdInput");
|
||||
debugSongIdInput.value = settings.debugSongId;
|
||||
debugSongIdInput.oninput = () => {
|
||||
settings.debugSongId = debugSongIdInput.value;
|
||||
saveSettings();
|
||||
if (!settings.debugSongId.trim()) {
|
||||
mapInfoRequestId += 1;
|
||||
currentMapHash = "";
|
||||
rawLevelHash = "";
|
||||
void refreshMapFriendScores();
|
||||
} else {
|
||||
void applyDebugSong();
|
||||
}
|
||||
};
|
||||
const debugSongIdExampleBtn = must("debugSongIdExample");
|
||||
const syncDebugBsrExampleButton = () => {
|
||||
debugSongIdExampleBtn.textContent = DEBUG_BSR_EXAMPLE_IDS[debugBsrExampleIndex];
|
||||
};
|
||||
syncDebugBsrExampleButton();
|
||||
debugSongIdExampleBtn.onclick = () => {
|
||||
const id = DEBUG_BSR_EXAMPLE_IDS[debugBsrExampleIndex];
|
||||
debugBsrExampleIndex = (debugBsrExampleIndex + 1) % DEBUG_BSR_EXAMPLE_IDS.length;
|
||||
debugSongIdInput.value = id;
|
||||
debugSongIdInput.dispatchEvent(new Event("input", {
|
||||
bubbles: true
|
||||
}));
|
||||
syncDebugBsrExampleButton();
|
||||
};
|
||||
document.documentElement.onclick = () => document.body.classList.toggle("preview");
|
||||
must("settings").onclick = (e) => e.stopPropagation();
|
||||
void loadRequestQueue();
|
||||
window.setInterval(() => void loadRequestQueue(), REQUEST_POLL_MS);
|
||||
}
|
||||
void bootstrap();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copy to overlay.toml (gitignored) and adjust for your machine.
|
||||
|
||||
# Absolute path to Beat Saber Plus ChatRequest Database.json (optional if ChatRequest.json is in the repo)
|
||||
chat_request_database = "C:\\path\\to\\UserData\\BeatSaberPlus\\ChatRequest\\Database.json"
|
||||
|
||||
# BeatLeader Steam / platform id for friend scores (recommended)
|
||||
beatleader_player_id = "76561199407393962"
|
||||
|
||||
# UI toggles (optional; URL hash still overrides when present)
|
||||
cover = true
|
||||
map_info = true
|
||||
time = true
|
||||
score = true
|
||||
friends = true
|
||||
friend_mode = "mutual" # mutual | following | followers
|
||||
bsr = false
|
||||
right = false
|
||||
bottom = true
|
||||
scale = 1.0
|
||||
fade = 300
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"path": "../../src/plebsaber.stream"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
+77
-69
@@ -2,12 +2,23 @@ import type {
|
||||
BeatLeaderFollower,
|
||||
BeatLeaderLeaderboard,
|
||||
BeatLeaderLeaderboardsByHashResponse,
|
||||
BeatLeaderPlayer,
|
||||
BeatLeaderScore,
|
||||
BeatLeaderScoresResponse,
|
||||
FriendMode,
|
||||
} from "./types.ts";
|
||||
|
||||
interface BeatLeaderLeaderboardScoresResponse {
|
||||
scores?: BeatLeaderScore[];
|
||||
}
|
||||
|
||||
const BASE_URL = "https://api.beatleader.com";
|
||||
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";
|
||||
|
||||
function beatleaderUrl(path: string): string {
|
||||
@@ -17,83 +28,84 @@ function beatleaderUrl(path: string): string {
|
||||
return `${BASE_URL}${path}`;
|
||||
}
|
||||
|
||||
interface BeatLeaderPlayerLookup {
|
||||
id?: string | number | null;
|
||||
/** 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<BeatLeaderLeaderboard[]> {
|
||||
const path = `/leaderboards/hash/${encodeURIComponent(hash)}`;
|
||||
try {
|
||||
const res = await fetch(beatleaderUrl(`/leaderboards/hash/${encodeURIComponent(hash)}`));
|
||||
const res = await fetch(beatleaderUrl(path));
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json() as BeatLeaderLeaderboardsByHashResponse | BeatLeaderLeaderboard[];
|
||||
const leaderboards = Array.isArray(data)
|
||||
return Array.isArray(data)
|
||||
? data
|
||||
: Array.isArray(data.leaderboards)
|
||||
? data.leaderboards
|
||||
: [];
|
||||
return leaderboards;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBeatLeaderPlayerId(playerId: string): Promise<string> {
|
||||
export async function fetchBeatLeaderPlayer(playerId: string): Promise<{ id: string; avatar: string | null } | null> {
|
||||
const path = `/player/${encodeURIComponent(playerId)}`;
|
||||
try {
|
||||
const res = await fetch(beatleaderUrl(`/player/${encodeURIComponent(playerId)}`));
|
||||
if (!res.ok) return playerId;
|
||||
const data = await res.json() as BeatLeaderPlayerLookup;
|
||||
const canonicalId = data.id;
|
||||
return canonicalId == null ? playerId : String(canonicalId);
|
||||
const res = await fetch(beatleaderUrl(path));
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as BeatLeaderPlayer;
|
||||
const id = data.id == null ? playerId : String(data.id);
|
||||
const avatar = typeof data.avatar === "string" ? data.avatar.trim() || null : null;
|
||||
return { id, avatar };
|
||||
} catch {
|
||||
return playerId;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAllLeaderboardScoresByHash(
|
||||
hash: string,
|
||||
diff: string,
|
||||
mode: string,
|
||||
maxPages = 20,
|
||||
): Promise<BeatLeaderScore[]> {
|
||||
const scores: BeatLeaderScore[] = [];
|
||||
for (let page = 1; page <= maxPages; page += 1) {
|
||||
const qs = new URLSearchParams({ page: String(page), count: String(PAGE_SIZE) });
|
||||
const url = beatleaderUrl(
|
||||
`/v5/scores/${encodeURIComponent(hash)}/${encodeURIComponent(diff)}/${encodeURIComponent(mode)}?${qs}`,
|
||||
);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
if (!res.ok) break;
|
||||
const payload = await res.json() as BeatLeaderScoresResponse | BeatLeaderScore[];
|
||||
const batch = Array.isArray(payload) ? payload : payload.data ?? [];
|
||||
if (batch.length === 0) break;
|
||||
scores.push(...batch);
|
||||
if (batch.length < PAGE_SIZE) break;
|
||||
}
|
||||
return scores;
|
||||
}
|
||||
|
||||
interface BeatLeaderLeaderboardScoresResponse {
|
||||
scores?: BeatLeaderScore[];
|
||||
async function resolveBeatLeaderPlayerId(playerId: string): Promise<string> {
|
||||
const p = await fetchBeatLeaderPlayer(playerId);
|
||||
return p?.id ?? playerId;
|
||||
}
|
||||
|
||||
async function fetchLeaderboardScoresById(
|
||||
leaderboardId: string,
|
||||
maxPages = 20,
|
||||
maxPages = MAX_LEADERBOARD_SCORE_PAGES,
|
||||
): Promise<BeatLeaderScore[]> {
|
||||
const scores: BeatLeaderScore[] = [];
|
||||
for (let page = 1; page <= maxPages; page += 1) {
|
||||
const pageSize = PAGE_SIZE;
|
||||
let page = 1;
|
||||
for (;;) {
|
||||
if (page > maxPages) break;
|
||||
const qs = new URLSearchParams({
|
||||
leaderboardContext: "general",
|
||||
page: String(page),
|
||||
sortBy: "rank",
|
||||
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;
|
||||
try {
|
||||
res = await fetch(url);
|
||||
@@ -105,7 +117,8 @@ async function fetchLeaderboardScoresById(
|
||||
const batch = Array.isArray(payload.scores) ? payload.scores : [];
|
||||
if (batch.length === 0) break;
|
||||
scores.push(...batch);
|
||||
if (batch.length < PAGE_SIZE) break;
|
||||
if (batch.length < pageSize) break;
|
||||
page += 1;
|
||||
}
|
||||
return scores;
|
||||
}
|
||||
@@ -113,7 +126,7 @@ async function fetchLeaderboardScoresById(
|
||||
export async function fetchAllMapScoresByHash(
|
||||
hash: string,
|
||||
leaderboards: BeatLeaderLeaderboard[],
|
||||
maxPagesPerLeaderboard = 20,
|
||||
maxPagesPerLeaderboard = MAX_LEADERBOARD_SCORE_PAGES,
|
||||
): Promise<BeatLeaderScore[]> {
|
||||
const requests = leaderboards.map((lb) => {
|
||||
const leaderboardId = lb.id == null ? null : String(lb.id);
|
||||
@@ -135,7 +148,8 @@ async function fetchFollowersPage(
|
||||
page: String(page),
|
||||
count: String(count),
|
||||
});
|
||||
const url = beatleaderUrl(`/player/${encodeURIComponent(playerId)}/followers?${qs}`);
|
||||
const path = `/player/${encodeURIComponent(playerId)}/followers?${qs}`;
|
||||
const url = beatleaderUrl(path);
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return [];
|
||||
@@ -161,36 +175,30 @@ async function fetchAllFollowers(
|
||||
return all;
|
||||
}
|
||||
|
||||
export async function fetchMutualFriendIds(playerId: string, maxPages = 100): Promise<Set<string>> {
|
||||
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 mutuals = new Set<string>();
|
||||
for (const entry of followers) {
|
||||
const id = String(entry.id);
|
||||
if (followingIds.has(id)) {
|
||||
mutuals.add(id);
|
||||
}
|
||||
}
|
||||
return mutuals;
|
||||
function normalizeFollowerEntry(entry: BeatLeaderFollower): BeatLeaderFollower {
|
||||
return {
|
||||
...entry,
|
||||
id: String(entry.id),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchMutualFriends(playerId: string, maxPages = 100): Promise<BeatLeaderFollower[]> {
|
||||
/** 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[]> {
|
||||
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) => ({
|
||||
...entry,
|
||||
id: String(entry.id),
|
||||
}));
|
||||
.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
|
||||
}
|
||||
|
||||
export function normalizeAccuracy(value: number | null | undefined): number | null {
|
||||
|
||||
+446
-223
@@ -1,17 +1,27 @@
|
||||
import type {
|
||||
BeatLeaderFollower,
|
||||
BeatLeaderLeaderboard,
|
||||
BeatLeaderScore,
|
||||
BeatSaberPlusEvent,
|
||||
ChatRequestEntry,
|
||||
ChatRequestPayload,
|
||||
FriendMode,
|
||||
MapInfo,
|
||||
OverlaySettings,
|
||||
Score,
|
||||
} from "./types.ts";
|
||||
import { OVERLAY_SETTINGS_INITIAL } from "./types.ts";
|
||||
import type { BeatSaverMap } from "./beatsaver.ts";
|
||||
import { fetchBeatSaverMapById, fetchBeatSaverMeta } from "./beatsaver.ts";
|
||||
import {
|
||||
fetchAllMapScoresByHash,
|
||||
fetchBeatLeaderPlayer,
|
||||
fetchBLLeaderboardsByHash,
|
||||
fetchMutualFriendIds,
|
||||
fetchFriends,
|
||||
leaderboardsMatchingPlayMode,
|
||||
normalizeAccuracy,
|
||||
} from "./beatleader.ts";
|
||||
import { mergeOverlayConfigResponse, type OverlayConfigApiBody } from "./overlay-config.ts";
|
||||
|
||||
function must<T extends HTMLElement>(id: string): T {
|
||||
const element = document.getElementById(id);
|
||||
@@ -23,6 +33,33 @@ function parseJson<T>(raw: string): T {
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
||||
type Settings = OverlaySettings;
|
||||
|
||||
const settings: Settings = structuredClone(OVERLAY_SETTINGS_INITIAL);
|
||||
|
||||
const defaults = structuredClone(OVERLAY_SETTINGS_INITIAL);
|
||||
const style = document.createElement("style");
|
||||
|
||||
function loadSettings() {
|
||||
const params = new URLSearchParams(location.hash.slice(1));
|
||||
let css = "";
|
||||
for (const [key, def] of Object.entries(defaults) as [keyof Settings, Settings[keyof Settings]][]) {
|
||||
const value = (parseJson<Settings[keyof Settings] | null>(params.get(key) || "null") ?? def);
|
||||
(settings as Record<keyof Settings, Settings[keyof 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) as [keyof Settings, Settings[keyof Settings]][]) {
|
||||
if (value !== defaults[key]) params.set(key, JSON.stringify(value));
|
||||
}
|
||||
location.replace(`#${params.toString()}`);
|
||||
}
|
||||
|
||||
// WebSocket connection
|
||||
|
||||
const beatSaberPlus = {
|
||||
@@ -52,10 +89,10 @@ const beatSaberPlus = {
|
||||
break;
|
||||
case "handshake":
|
||||
currentPlayerPlatformId = data.playerPlatformId || "";
|
||||
void refreshConfiguredPlayerAvatar();
|
||||
void refreshMapFriendScores();
|
||||
break;
|
||||
default:
|
||||
console.log("message", e.data);
|
||||
break;
|
||||
}
|
||||
},
|
||||
@@ -63,24 +100,126 @@ const beatSaberPlus = {
|
||||
|
||||
const provider = beatSaberPlus;
|
||||
const retryMs = 10000;
|
||||
let retries = 0;
|
||||
let currentPlayerPlatformId = "";
|
||||
|
||||
function getEffectivePlayerId() {
|
||||
const configured = settings.beatLeaderId.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;
|
||||
/** 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;
|
||||
return id == null ? "" : String(id);
|
||||
}
|
||||
|
||||
function resolvedHashFromBeatSaverMap(map: BeatSaverMap, fallback: string): string {
|
||||
const v = map.versions?.[0]?.hash;
|
||||
if (typeof v === "string" && v.length > 0) return v.toLowerCase().trim();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** BeatLeader indexes maps by 40-char hex hash; BeatSaver accepts the same hash or a short map key (`/maps/id/…`). */
|
||||
function looksLikeBeatSaverHash(s: string): boolean {
|
||||
return /^[0-9a-f]{40}$/i.test(s.trim());
|
||||
}
|
||||
|
||||
async function fetchBeatSaverMapForDebug(id: string): Promise<BeatSaverMap | null> {
|
||||
const t = id.trim();
|
||||
if (!t) return null;
|
||||
if (looksLikeBeatSaverHash(t)) return fetchBeatSaverMeta(t.toLowerCase());
|
||||
return fetchBeatSaverMapById(t);
|
||||
}
|
||||
|
||||
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);
|
||||
if (reqId !== mapInfoRequestId) return;
|
||||
if (!map?.id) {
|
||||
rawLevelHash = "";
|
||||
currentMapHash = "";
|
||||
cover.src = "images/unknown.svg";
|
||||
title.textContent = "BeatSaver not found";
|
||||
subTitle.textContent = raw;
|
||||
artist.textContent = "";
|
||||
mapper.textContent = "";
|
||||
difficulty.textContent = "";
|
||||
characteristic.src = "images/characteristic/Standard.svg";
|
||||
difficultyLabel.textContent = "";
|
||||
type.textContent = "";
|
||||
bsrKey.textContent = "";
|
||||
return;
|
||||
}
|
||||
const fallbackHash = looksLikeBeatSaverHash(raw) ? raw.toLowerCase().trim() : "";
|
||||
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();
|
||||
cover.src = coverUrl || "images/unknown.svg";
|
||||
title.textContent = map.metadata?.songName ?? map.name ?? "";
|
||||
subTitle.textContent = map.metadata?.songSubName ?? "";
|
||||
artist.textContent = map.metadata?.songAuthorName ?? "";
|
||||
mapper.textContent = map.metadata?.levelAuthorName ?? "";
|
||||
const firstDiff = v0?.diffs?.[0];
|
||||
difficulty.textContent = firstDiff?.difficulty?.replace("Plus", " +") ?? "—";
|
||||
characteristic.src = firstDiff
|
||||
? `images/characteristic/${firstDiff.characteristic}.svg`
|
||||
: "images/characteristic/Standard.svg";
|
||||
difficultyLabel.textContent = firstDiff?.label ?? "";
|
||||
type.textContent = "Custom";
|
||||
bsrKey.textContent = map.id;
|
||||
timeMultiplier = 1;
|
||||
duration = 180;
|
||||
if (reqId === mapInfoRequestId) {
|
||||
setTime(0);
|
||||
}
|
||||
} catch {
|
||||
if (reqId !== mapInfoRequestId) return;
|
||||
rawLevelHash = "";
|
||||
currentMapHash = "";
|
||||
cover.src = "images/unknown.svg";
|
||||
title.textContent = "BeatSaver request failed";
|
||||
subTitle.textContent = raw;
|
||||
} finally {
|
||||
if (reqId === mapInfoRequestId) document.body.classList.remove("loading");
|
||||
if (reqId === mapInfoRequestId) void refreshMapFriendScores();
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
function onClose(_e: CloseEvent) {
|
||||
setTimeout(connect, retryMs);
|
||||
}
|
||||
|
||||
@@ -102,9 +241,18 @@ let timeMultiplier = 1;
|
||||
let duration = 0;
|
||||
|
||||
async function updateMapInfo(data: MapInfo) {
|
||||
if (settings.debugSongId.trim()) {
|
||||
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");
|
||||
currentMapHash = custom ? data.level_id.substring(13, 53).toLowerCase() : "";
|
||||
rawLevelHash = custom ? data.level_id.substring(13, 53).toLowerCase() : "";
|
||||
currentMapHash = rawLevelHash;
|
||||
|
||||
cover.src = data.coverRaw ? `data:image/jpeg;base64,${data.coverRaw}` : "images/unknown.svg";
|
||||
title.textContent = data.name || "";
|
||||
subTitle.textContent = data.sub_name || "";
|
||||
@@ -114,29 +262,44 @@ async function updateMapInfo(data: MapInfo) {
|
||||
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?
|
||||
bsrKey.textContent = custom && !wip ? "…" : custom ? rawLevelHash || "???" : "???";
|
||||
timeMultiplier = data.timeMultiplier || 1;
|
||||
duration = data.duration / 1000;
|
||||
void refreshMapFriendScores();
|
||||
|
||||
// Fetch extra info from BeatSaver
|
||||
beginFriendScoresForNewMapContext();
|
||||
|
||||
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;
|
||||
const map = await fetchBeatSaverMeta(rawLevelHash);
|
||||
if (reqId !== mapInfoRequestId) return;
|
||||
if (!map?.id) {
|
||||
currentMapHash = rawLevelHash;
|
||||
} else {
|
||||
const resolved = resolvedHashFromBeatSaverMap(map, rawLevelHash);
|
||||
currentMapHash = resolved;
|
||||
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 {
|
||||
if (reqId !== mapInfoRequestId) return;
|
||||
currentMapHash = rawLevelHash;
|
||||
} finally {
|
||||
document.body.classList.remove("loading");
|
||||
if (reqId === mapInfoRequestId) void refreshMapFriendScores();
|
||||
}
|
||||
} else {
|
||||
bsrKey.textContent = "???";
|
||||
if (custom && wip) {
|
||||
bsrKey.textContent = rawLevelHash || "???";
|
||||
} else {
|
||||
bsrKey.textContent = "???";
|
||||
}
|
||||
difficultyLabel.textContent = "";
|
||||
void refreshMapFriendScores();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,9 +339,33 @@ 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 friendScoresPlayerAvatar = must<HTMLImageElement>("friendScoresPlayerAvatar");
|
||||
const friendScoresHeaderImg = must<HTMLImageElement>("friendScoresHeaderImg");
|
||||
|
||||
let currentMapHash = "";
|
||||
let friendScoreRequestId = 0;
|
||||
let cachedConfiguredPlayerAvatarKey = "";
|
||||
let cachedConfiguredPlayerAvatarSrc = "images/unknown.svg";
|
||||
|
||||
async function refreshConfiguredPlayerAvatar() {
|
||||
const key = `${settings.beatLeaderId.trim()}|${currentPlayerPlatformId}`;
|
||||
const pid = getEffectivePlayerId();
|
||||
if (!pid) {
|
||||
cachedConfiguredPlayerAvatarKey = "";
|
||||
cachedConfiguredPlayerAvatarSrc = "images/unknown.svg";
|
||||
friendScoresPlayerAvatar.src = cachedConfiguredPlayerAvatarSrc;
|
||||
return;
|
||||
}
|
||||
if (key === cachedConfiguredPlayerAvatarKey) {
|
||||
friendScoresPlayerAvatar.src = cachedConfiguredPlayerAvatarSrc;
|
||||
return;
|
||||
}
|
||||
const profile = await fetchBeatLeaderPlayer(pid);
|
||||
const keyAfter = `${settings.beatLeaderId.trim()}|${currentPlayerPlatformId}`;
|
||||
if (keyAfter !== key) return;
|
||||
cachedConfiguredPlayerAvatarKey = key;
|
||||
cachedConfiguredPlayerAvatarSrc = profile?.avatar?.trim() || "images/unknown.svg";
|
||||
friendScoresPlayerAvatar.src = cachedConfiguredPlayerAvatarSrc;
|
||||
}
|
||||
|
||||
function updateScore(score: Score) {
|
||||
if (!settings.score) return;
|
||||
@@ -187,31 +374,75 @@ 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;
|
||||
friendScoresHeaderText.textContent = "frenz?";
|
||||
friendScoresHeaderImg.src = "assets/notlikesteve.webp";
|
||||
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");
|
||||
friendScoresEmpty.textContent = items.length ? "" : "No mutual scores on this map";
|
||||
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(name, acc);
|
||||
li.append(acc, avatar, name);
|
||||
friendScoresList.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -227,40 +458,68 @@ 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;
|
||||
try {
|
||||
const [leaderboards, mutualFriendIds] = await Promise.all([
|
||||
const relKey = friendsRelationListKey(playerId);
|
||||
const friendsPromise: Promise<BeatLeaderFollower[]> = (async () => {
|
||||
if (friendsRelationCache !== null && relKey === friendsRelationCacheKey) {
|
||||
return friendsRelationCache;
|
||||
}
|
||||
const fetched = await fetchFriends(playerId, settings.friendMode);
|
||||
friendsRelationCacheKey = relKey;
|
||||
friendsRelationCache = fetched;
|
||||
return fetched;
|
||||
})();
|
||||
const [leaderboards, friends] = await Promise.all([
|
||||
fetchBLLeaderboardsByHash(hash),
|
||||
fetchMutualFriendIds(playerId),
|
||||
friendsPromise,
|
||||
]);
|
||||
if (requestId !== friendScoreRequestId) return;
|
||||
if (leaderboards.length === 0) {
|
||||
clearFriendScores("No BeatLeader leaderboards found");
|
||||
return;
|
||||
}
|
||||
if (mutualFriendIds.size === 0) {
|
||||
clearFriendScores("No mutual BeatLeader followers");
|
||||
const forPlayMode = leaderboardsMatchingPlayMode(leaderboards, currentPlayCharacteristic, currentPlayDifficulty);
|
||||
if (forPlayMode.length === 0) {
|
||||
clearFriendScores("No BeatLeader leaderboard for this difficulty");
|
||||
return;
|
||||
}
|
||||
const scores = await fetchAllMapScoresByHash(hash, leaderboards);
|
||||
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, forPlayMode);
|
||||
if (requestId !== friendScoreRequestId) return;
|
||||
const bestByPlayer = new Map<string, { name: string; acc: number }>();
|
||||
const bestByPlayer = new Map<string, { name: string; acc: number; avatar: string | null }>();
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -272,181 +531,47 @@ async function refreshMapFriendScores() {
|
||||
}
|
||||
}
|
||||
|
||||
// Settings
|
||||
|
||||
interface Settings {
|
||||
cover: boolean;
|
||||
mapInfo: boolean;
|
||||
time: boolean;
|
||||
score: boolean;
|
||||
friends: boolean;
|
||||
bsr: boolean;
|
||||
debug: boolean;
|
||||
mockBsr: string;
|
||||
debugPlayerId: string;
|
||||
right: boolean;
|
||||
bottom: boolean;
|
||||
scale: number;
|
||||
fade: number;
|
||||
}
|
||||
|
||||
const settings: Settings = {
|
||||
cover: true,
|
||||
mapInfo: true,
|
||||
time: true,
|
||||
score: true,
|
||||
friends: true,
|
||||
bsr: false,
|
||||
debug: false,
|
||||
mockBsr: "4f4e4",
|
||||
debugPlayerId: "76561199407393962",
|
||||
right: false,
|
||||
bottom: true,
|
||||
scale: 1,
|
||||
fade: 300,
|
||||
};
|
||||
|
||||
const defaults = structuredClone(settings);
|
||||
const style = document.createElement("style");
|
||||
|
||||
function loadSettings() {
|
||||
const params = new URLSearchParams(location.hash.slice(1));
|
||||
let css = "";
|
||||
for (const [key, def] of Object.entries(defaults) as [keyof Settings, Settings[keyof Settings]][]) {
|
||||
const value = (parseJson<Settings[keyof Settings] | null>(params.get(key) || "null") ?? def);
|
||||
(settings as Record<keyof Settings, Settings[keyof Settings]>)[key] = value;
|
||||
if (typeof def === "boolean") document.body.classList.toggle(key, Boolean(value));
|
||||
else css += `--${key}: ${value}; `;
|
||||
window.onhashchange = () => {
|
||||
loadSettings();
|
||||
void refreshConfiguredPlayerAvatar();
|
||||
const debugEl = document.getElementById("debugSongIdInput") as HTMLInputElement | null;
|
||||
if (debugEl) debugEl.value = settings.debugSongId;
|
||||
if (settings.debugSongId.trim()) void applyDebugSong();
|
||||
else {
|
||||
mapInfoRequestId += 1;
|
||||
currentMapHash = "";
|
||||
rawLevelHash = "";
|
||||
void refreshMapFriendScores();
|
||||
}
|
||||
style.textContent = `:root { ${css}}`;
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(settings) as [keyof Settings, Settings[keyof Settings]][]) {
|
||||
if (value !== defaults[key]) params.set(key, JSON.stringify(value));
|
||||
}
|
||||
location.replace(`#${params.toString()}`);
|
||||
}
|
||||
|
||||
async function applyMockMapFromBsr() {
|
||||
const key = settings.mockBsr.trim();
|
||||
if (!settings.debug || !key) return;
|
||||
const map = await fetchBeatSaverMapById(key);
|
||||
if (!map) return;
|
||||
const hash = map.versions?.[0]?.hash?.toLowerCase?.() || "";
|
||||
currentMapHash = hash;
|
||||
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;
|
||||
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;
|
||||
loadSettings();
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Settings UI
|
||||
|
||||
for (const key of ["cover", "mapInfo", "time", "score", "friends", "bsr", "debug"] as const) {
|
||||
const input = must<HTMLInputElement>(`${key}Input`);
|
||||
input.checked = settings[key];
|
||||
input.oninput = () => {
|
||||
settings[key] = input.checked;
|
||||
saveSettings();
|
||||
if (key === "friends") void refreshMapFriendScores();
|
||||
if (key === "debug") {
|
||||
void loadRequestQueue();
|
||||
void applyMockMapFromBsr();
|
||||
void refreshMapFriendScores();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const mockBsrInput = must<HTMLInputElement>("mockBsrInput");
|
||||
mockBsrInput.value = settings.mockBsr;
|
||||
mockBsrInput.oninput = () => {
|
||||
settings.mockBsr = mockBsrInput.value.trim();
|
||||
saveSettings();
|
||||
void applyMockMapFromBsr();
|
||||
};
|
||||
|
||||
const debugPlayerInput = must<HTMLInputElement>("debugPlayerInput");
|
||||
debugPlayerInput.value = settings.debugPlayerId;
|
||||
debugPlayerInput.oninput = () => {
|
||||
settings.debugPlayerId = debugPlayerInput.value.trim();
|
||||
saveSettings();
|
||||
if (settings.debug) void refreshMapFriendScores();
|
||||
};
|
||||
|
||||
void applyMockMapFromBsr();
|
||||
|
||||
const scale = must<HTMLInputElement>("scaleInput");
|
||||
scale.valueAsNumber = settings.scale * 100;
|
||||
scale.oninput = () => {
|
||||
settings.scale = scale.valueAsNumber / 100;
|
||||
saveSettings();
|
||||
};
|
||||
|
||||
const position = must<HTMLSelectElement>("positionInput");
|
||||
position.value = JSON.stringify([settings.right, settings.bottom]);
|
||||
position.onchange = () => {
|
||||
[settings.right, settings.bottom] = parseJson<[boolean, boolean]>(position.value);
|
||||
saveSettings();
|
||||
};
|
||||
|
||||
const fade = must<HTMLInputElement>("fadeInput");
|
||||
fade.valueAsNumber = settings.fade;
|
||||
fade.oninput = () => {
|
||||
settings.fade = fade.valueAsNumber;
|
||||
saveSettings();
|
||||
};
|
||||
|
||||
document.documentElement.onclick = () => document.body.classList.toggle("preview");
|
||||
must<HTMLElement>("settings").onclick = (e: MouseEvent) => e.stopPropagation();
|
||||
|
||||
// Song request queue (JSON from same origin as page; poll). See docs/testing.md for ?requests=
|
||||
// Song request queue (JSON from same origin as page; poll)
|
||||
|
||||
const MAX_REQUESTS = 10;
|
||||
const REQUEST_POLL_MS = 5000;
|
||||
|
||||
/** BeatSaver map keys for the debug “example” control (cycles on each click). */
|
||||
const DEBUG_BSR_EXAMPLE_IDS = [
|
||||
"43239",
|
||||
"4b55e",
|
||||
"49201",
|
||||
"35a5e",
|
||||
"2c25a",
|
||||
"3864b",
|
||||
"2d205",
|
||||
"41d08",
|
||||
"e298",
|
||||
] as const;
|
||||
let debugBsrExampleIndex = 0;
|
||||
|
||||
const requestListEl = must<HTMLOListElement>("requestList");
|
||||
const requestOverlayEl = must<HTMLElement>("requestOverlay");
|
||||
const requestEmptyEl = must<HTMLElement>("requestEmpty");
|
||||
const requestTitleCache = new Map<string, string>();
|
||||
const requestTitleMisses = new Set<string>();
|
||||
|
||||
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: string) {
|
||||
const base = new URL(fileName, location.href);
|
||||
if (base.protocol !== "http:" && base.protocol !== "https:") {
|
||||
throw new Error("not-http");
|
||||
}
|
||||
function loadChatRequestJson() {
|
||||
const base = new URL("ChatRequest.json", location.href);
|
||||
const busted = new URL(base.href);
|
||||
busted.searchParams.set("t", String(Date.now()));
|
||||
return fetch(busted.href, { cache: "no-store" }).then((res) => {
|
||||
@@ -455,38 +580,32 @@ function loadJsonNextToPage(fileName: string) {
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRequestPayload() {
|
||||
let lastErr: unknown;
|
||||
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: ChatRequestEntry) {
|
||||
const parts = [item.npr, item.rqn].filter(Boolean);
|
||||
return parts.length ? parts.join(" ") : item.rqn || "";
|
||||
}
|
||||
|
||||
async function enrichRequestTitle(key: string, titleEl: HTMLElement) {
|
||||
if (requestTitleMisses.has(key)) return;
|
||||
if (requestTitleCache.has(key)) {
|
||||
titleEl.textContent = requestTitleCache.get(key) ?? "";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const map = await fetchBeatSaverMapById(key);
|
||||
if (!map) return;
|
||||
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 {
|
||||
// keep !bsr placeholder
|
||||
requestTitleMisses.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,18 +633,122 @@ function renderRequestList(items: ChatRequestEntry[]) {
|
||||
|
||||
async function loadRequestQueue() {
|
||||
try {
|
||||
const data = await loadRequestPayload();
|
||||
const data = await loadChatRequestJson();
|
||||
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);
|
||||
const items = (data.queue ?? []).slice(0, MAX_REQUESTS);
|
||||
renderRequestList(items);
|
||||
} catch {
|
||||
requestEmptyEl.textContent = "whupsy, database file missing";
|
||||
requestEmptyEl.textContent = "Request queue unavailable";
|
||||
requestOverlayEl.classList.add("request-load-failed");
|
||||
renderRequestList([]);
|
||||
}
|
||||
}
|
||||
|
||||
void loadRequestQueue();
|
||||
window.setInterval(() => void loadRequestQueue(), REQUEST_POLL_MS);
|
||||
async function bootstrap() {
|
||||
Object.assign(defaults, OVERLAY_SETTINGS_INITIAL);
|
||||
try {
|
||||
const url = new URL("/api/overlay-config", location.href);
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (res.ok) {
|
||||
const data = await res.json() as OverlayConfigApiBody;
|
||||
mergeOverlayConfigResponse(defaults, data);
|
||||
}
|
||||
} catch {
|
||||
// keep OVERLAY_SETTINGS_INITIAL (e.g. file:// or static hosting)
|
||||
}
|
||||
loadSettings();
|
||||
document.head.appendChild(style);
|
||||
void refreshConfiguredPlayerAvatar();
|
||||
if (settings.debugSongId.trim()) void applyDebugSong();
|
||||
else void refreshMapFriendScores();
|
||||
|
||||
// Settings UI
|
||||
|
||||
for (const key of ["cover", "mapInfo", "time", "score", "friends", "bsr"] as const) {
|
||||
const input = must<HTMLInputElement>(`${key}Input`);
|
||||
input.checked = settings[key];
|
||||
input.oninput = () => {
|
||||
settings[key] = input.checked;
|
||||
saveSettings();
|
||||
if (key === "friends") void refreshMapFriendScores();
|
||||
};
|
||||
}
|
||||
|
||||
const friendModeInput = must<HTMLSelectElement>("friendModeInput");
|
||||
friendModeInput.value = settings.friendMode;
|
||||
friendModeInput.onchange = () => {
|
||||
settings.friendMode = friendModeInput.value as FriendMode;
|
||||
saveSettings();
|
||||
void refreshMapFriendScores();
|
||||
};
|
||||
|
||||
const beatLeaderPlayerInput = must<HTMLInputElement>("beatLeaderPlayerInput");
|
||||
beatLeaderPlayerInput.value = settings.beatLeaderId;
|
||||
beatLeaderPlayerInput.oninput = () => {
|
||||
settings.beatLeaderId = beatLeaderPlayerInput.value.trim();
|
||||
saveSettings();
|
||||
void refreshConfiguredPlayerAvatar();
|
||||
void refreshMapFriendScores();
|
||||
};
|
||||
must<HTMLButtonElement>("beatLeaderPlayerExample").onclick = () => {
|
||||
beatLeaderPlayerInput.value = "76561199407393962";
|
||||
beatLeaderPlayerInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
};
|
||||
|
||||
const scale = must<HTMLInputElement>("scaleInput");
|
||||
scale.valueAsNumber = settings.scale * 100;
|
||||
scale.oninput = () => {
|
||||
settings.scale = scale.valueAsNumber / 100;
|
||||
saveSettings();
|
||||
};
|
||||
|
||||
const position = must<HTMLSelectElement>("positionInput");
|
||||
position.value = JSON.stringify([settings.right, settings.bottom]);
|
||||
position.onchange = () => {
|
||||
[settings.right, settings.bottom] = parseJson<[boolean, boolean]>(position.value);
|
||||
saveSettings();
|
||||
};
|
||||
|
||||
const fade = must<HTMLInputElement>("fadeInput");
|
||||
fade.valueAsNumber = settings.fade;
|
||||
fade.oninput = () => {
|
||||
settings.fade = fade.valueAsNumber;
|
||||
saveSettings();
|
||||
};
|
||||
|
||||
const debugSongIdInput = must<HTMLInputElement>("debugSongIdInput");
|
||||
debugSongIdInput.value = settings.debugSongId;
|
||||
debugSongIdInput.oninput = () => {
|
||||
settings.debugSongId = debugSongIdInput.value;
|
||||
saveSettings();
|
||||
if (!settings.debugSongId.trim()) {
|
||||
mapInfoRequestId += 1;
|
||||
currentMapHash = "";
|
||||
rawLevelHash = "";
|
||||
void refreshMapFriendScores();
|
||||
} else {
|
||||
void applyDebugSong();
|
||||
}
|
||||
};
|
||||
const debugSongIdExampleBtn = must<HTMLButtonElement>("debugSongIdExample");
|
||||
const syncDebugBsrExampleButton = () => {
|
||||
debugSongIdExampleBtn.textContent = DEBUG_BSR_EXAMPLE_IDS[debugBsrExampleIndex];
|
||||
};
|
||||
syncDebugBsrExampleButton();
|
||||
debugSongIdExampleBtn.onclick = () => {
|
||||
const id = DEBUG_BSR_EXAMPLE_IDS[debugBsrExampleIndex];
|
||||
debugBsrExampleIndex = (debugBsrExampleIndex + 1) % DEBUG_BSR_EXAMPLE_IDS.length;
|
||||
debugSongIdInput.value = id;
|
||||
debugSongIdInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
syncDebugBsrExampleButton();
|
||||
};
|
||||
|
||||
document.documentElement.onclick = () => document.body.classList.toggle("preview");
|
||||
must<HTMLElement>("settings").onclick = (e: MouseEvent) => e.stopPropagation();
|
||||
|
||||
void loadRequestQueue();
|
||||
window.setInterval(() => void loadRequestQueue(), REQUEST_POLL_MS);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { assert, assertEquals } from "jsr:@std/assert";
|
||||
import {
|
||||
fetchAllMapScoresByHash,
|
||||
fetchAllLeaderboardScoresByHash,
|
||||
fetchBLLeaderboardsByHash,
|
||||
fetchMutualFriends,
|
||||
fetchMutualFriendIds,
|
||||
fetchFriends,
|
||||
normalizeAccuracy,
|
||||
} from "./beatleader.ts";
|
||||
import { fetchBeatSaverMapById } from "./beatsaver.ts";
|
||||
@@ -52,19 +50,16 @@ Deno.test({
|
||||
"ExpertPlus should map to a different leaderboard id than another difficulty",
|
||||
);
|
||||
|
||||
const [expertPlusScores, allMapScores, mutualIds, mutualFriends] = await Promise.all([
|
||||
fetchAllLeaderboardScoresByHash(hash, "ExpertPlus", MODE, 8),
|
||||
const mutualFriends = await fetchFriends(PLAYER_ID, "mutual", 100);
|
||||
const mutualIds = new Set(mutualFriends.map((f) => f.id));
|
||||
assert(mutualIds.size > 0, "Expected at least one mutual friend");
|
||||
|
||||
const [expertPlusScores, allMapScores] = await Promise.all([
|
||||
fetchAllMapScoresByHash(hash, [expertPlus], 120),
|
||||
fetchAllMapScoresByHash(hash, leaderboards, 120),
|
||||
fetchMutualFriendIds(PLAYER_ID, 100),
|
||||
fetchMutualFriends(PLAYER_ID, 100),
|
||||
]);
|
||||
assert(expertPlusScores.length > 0, "Expected some ExpertPlus scores");
|
||||
assert(allMapScores.length > 0, "Expected map to have scores across leaderboards");
|
||||
assert(mutualIds.size > 0, "Expected at least one mutual friend");
|
||||
console.log(
|
||||
"Mutual friends:",
|
||||
mutualFriends.map((friend) => `${friend.name || friend.alias || "unknown"} (${friend.id})`).join(", "),
|
||||
);
|
||||
|
||||
const mutualScores = allMapScores.filter((score) => {
|
||||
const playerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { assert } from "jsr:@std/assert";
|
||||
import { fetchMutualFriends } from "./beatleader.ts";
|
||||
import { fetchFriends } from "./beatleader.ts";
|
||||
|
||||
const PLAYER_ID = "76561199407393962";
|
||||
|
||||
@@ -8,12 +8,7 @@ Deno.test({
|
||||
sanitizeOps: false,
|
||||
sanitizeResources: false,
|
||||
async fn() {
|
||||
const mutuals = await fetchMutualFriends(PLAYER_ID, 100);
|
||||
const mutuals = await fetchFriends(PLAYER_ID, "mutual", 100);
|
||||
assert(mutuals.length > 0, `Expected mutual friends for player ${PLAYER_ID}`);
|
||||
console.log("Mutual friends:");
|
||||
for (const friend of mutuals) {
|
||||
const label = friend.name || friend.alias || "(no name)";
|
||||
console.log(`- ${label} (${friend.id})`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { FriendMode, OverlaySettings } from "./types.ts";
|
||||
|
||||
/** Keys accepted in overlay.toml (snake_case). */
|
||||
export interface OverlayToml {
|
||||
chat_request_database?: string;
|
||||
cover?: boolean;
|
||||
map_info?: boolean;
|
||||
time?: boolean;
|
||||
score?: boolean;
|
||||
friends?: boolean;
|
||||
friend_mode?: string;
|
||||
bsr?: boolean;
|
||||
beatleader_player_id?: string;
|
||||
right?: boolean;
|
||||
bottom?: boolean;
|
||||
scale?: number;
|
||||
fade?: number;
|
||||
}
|
||||
|
||||
export interface OverlayConfigApiBody {
|
||||
defaults: Partial<OverlaySettings>;
|
||||
}
|
||||
|
||||
const FRIEND_MODES = new Set<FriendMode>(["mutual", "following", "followers"]);
|
||||
|
||||
/** Merge `/api/overlay-config` into the object used as `defaults` before applying the URL hash. */
|
||||
export function mergeOverlayConfigResponse(
|
||||
target: OverlaySettings,
|
||||
body: { defaults?: Partial<OverlaySettings> },
|
||||
): void {
|
||||
const d = body.defaults;
|
||||
if (!d) return;
|
||||
const out = target as unknown as Record<string, unknown>;
|
||||
for (const key of Object.keys(d) as (keyof OverlaySettings)[]) {
|
||||
const v = d[key];
|
||||
if (v !== undefined) out[key as string] = v;
|
||||
}
|
||||
}
|
||||
|
||||
function asBool(v: unknown): boolean | undefined {
|
||||
if (typeof v === "boolean") return v;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function asNum(v: unknown): number | undefined {
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps parsed TOML (snake_case) to overlay defaults. Collects warnings for invalid values.
|
||||
*/
|
||||
export function overlayTomlToDefaults(toml: OverlayToml): {
|
||||
chatRequestDatabase: string | undefined;
|
||||
defaults: Partial<OverlaySettings>;
|
||||
beatleaderPlayerIdConfigured: boolean;
|
||||
warnings: string[];
|
||||
} {
|
||||
const warnings: string[] = [];
|
||||
const defaults: Partial<OverlaySettings> = {};
|
||||
|
||||
const db = typeof toml.chat_request_database === "string" ? toml.chat_request_database.trim() : "";
|
||||
const chatRequestDatabase = db || undefined;
|
||||
|
||||
const bCover = asBool(toml.cover);
|
||||
if (bCover !== undefined) defaults.cover = bCover;
|
||||
const bMap = asBool(toml.map_info);
|
||||
if (bMap !== undefined) defaults.mapInfo = bMap;
|
||||
const bTime = asBool(toml.time);
|
||||
if (bTime !== undefined) defaults.time = bTime;
|
||||
const bScore = asBool(toml.score);
|
||||
if (bScore !== undefined) defaults.score = bScore;
|
||||
const bFriends = asBool(toml.friends);
|
||||
if (bFriends !== undefined) defaults.friends = bFriends;
|
||||
const bBsr = asBool(toml.bsr);
|
||||
if (bBsr !== undefined) defaults.bsr = bBsr;
|
||||
const bRight = asBool(toml.right);
|
||||
if (bRight !== undefined) defaults.right = bRight;
|
||||
const bBottom = asBool(toml.bottom);
|
||||
if (bBottom !== undefined) defaults.bottom = bBottom;
|
||||
|
||||
if (toml.friend_mode !== undefined) {
|
||||
const raw = String(toml.friend_mode).trim().toLowerCase();
|
||||
if (FRIEND_MODES.has(raw as FriendMode)) defaults.friendMode = raw as FriendMode;
|
||||
else {
|
||||
warnings.push(
|
||||
`overlay.toml: invalid friend_mode "${toml.friend_mode}" (expected mutual, following, or followers)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const scale = asNum(toml.scale);
|
||||
if (scale !== undefined) defaults.scale = scale;
|
||||
|
||||
const fade = asNum(toml.fade);
|
||||
if (fade !== undefined) defaults.fade = fade;
|
||||
|
||||
let beatleaderPlayerIdConfigured = false;
|
||||
if (typeof toml.beatleader_player_id === "string") {
|
||||
const id = toml.beatleader_player_id.trim();
|
||||
if (id) {
|
||||
defaults.beatLeaderId = id;
|
||||
beatleaderPlayerIdConfigured = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { chatRequestDatabase, defaults, beatleaderPlayerIdConfigured, warnings };
|
||||
}
|
||||
+37
-5
@@ -1,5 +1,41 @@
|
||||
// https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay
|
||||
|
||||
export type FriendMode = "mutual" | "following" | "followers";
|
||||
|
||||
/** Overlay UI + BeatLeader id (URL hash overrides defaults from overlay.toml / `/api/overlay-config`). */
|
||||
export interface OverlaySettings {
|
||||
cover: boolean;
|
||||
mapInfo: boolean;
|
||||
time: boolean;
|
||||
score: boolean;
|
||||
friends: boolean;
|
||||
friendMode: FriendMode;
|
||||
bsr: boolean;
|
||||
beatLeaderId: string;
|
||||
right: boolean;
|
||||
bottom: boolean;
|
||||
scale: number;
|
||||
fade: number;
|
||||
/** Frontend-only: BeatSaver map key or 40-char hash; when set, map UI + BeatLeader use this instead of BS+ WebSocket map info. */
|
||||
debugSongId: string;
|
||||
}
|
||||
|
||||
export const OVERLAY_SETTINGS_INITIAL: Readonly<OverlaySettings> = {
|
||||
cover: true,
|
||||
mapInfo: true,
|
||||
time: true,
|
||||
score: true,
|
||||
friends: true,
|
||||
friendMode: "mutual",
|
||||
bsr: false,
|
||||
beatLeaderId: "",
|
||||
right: false,
|
||||
bottom: true,
|
||||
scale: 1,
|
||||
fade: 300,
|
||||
debugSongId: "",
|
||||
};
|
||||
|
||||
export interface HandshakeEvent {
|
||||
_type: "handshake";
|
||||
protocolVersion: number;
|
||||
@@ -81,7 +117,6 @@ export interface ChatRequestEntry {
|
||||
|
||||
export interface ChatRequestPayload {
|
||||
queue: ChatRequestEntry[];
|
||||
history: ChatRequestEntry[];
|
||||
}
|
||||
|
||||
export interface BeatLeaderDifficulty {
|
||||
@@ -114,13 +149,10 @@ export interface BeatLeaderScore {
|
||||
modifiedScore?: number | null;
|
||||
playerId?: string | number | null;
|
||||
playerName?: string | null;
|
||||
playerAvatar?: string | null;
|
||||
player?: BeatLeaderPlayer | string | null;
|
||||
}
|
||||
|
||||
export interface BeatLeaderScoresResponse {
|
||||
data?: BeatLeaderScore[];
|
||||
}
|
||||
|
||||
export interface BeatLeaderFollower {
|
||||
id: string;
|
||||
alias?: string | null;
|
||||
|
||||
+51
-32
@@ -1,36 +1,54 @@
|
||||
import { join } from "jsr:@std/path";
|
||||
import { serveDir } from "jsr:@std/http/file-server";
|
||||
import { parse } from "jsr:@std/toml";
|
||||
import type { OverlaySettings } from "../client/types.ts";
|
||||
import type { OverlayConfigApiBody } from "../client/overlay-config.ts";
|
||||
import { overlayTomlToDefaults, type OverlayToml } from "../client/overlay-config.ts";
|
||||
|
||||
const scriptDir = import.meta.dirname ?? ".";
|
||||
const root = join(scriptDir, "..", "..");
|
||||
const port = Number(Deno.env.get("PORT") ?? "8080");
|
||||
|
||||
function trimPathLine(line: string): string {
|
||||
let s = line.trim();
|
||||
if (!s || s.startsWith("#")) return "";
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
s = s.slice(1, -1);
|
||||
}
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
/** Optional one-line file in repo root: absolute path to `ChatRequest/Database.json`. */
|
||||
function readOptionalPathFile(): string | undefined {
|
||||
function readOverlayConfig(): {
|
||||
chatRequestDatabase: string | undefined;
|
||||
apiDefaults: Partial<OverlaySettings>;
|
||||
} {
|
||||
const path = join(root, "overlay.toml");
|
||||
let raw: string;
|
||||
try {
|
||||
const pathFile = join(root, "chat-request-database.path");
|
||||
const raw = Deno.readTextFileSync(pathFile);
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const p = trimPathLine(line);
|
||||
if (p) return p;
|
||||
}
|
||||
raw = Deno.readTextFileSync(path);
|
||||
} catch {
|
||||
// missing or unreadable
|
||||
console.warn(
|
||||
"overlay.toml not found; using built-in defaults. Copy overlay.toml.example and set at least chat_request_database and beatleader_player_id.",
|
||||
);
|
||||
return { chatRequestDatabase: undefined, apiDefaults: {} };
|
||||
}
|
||||
return undefined;
|
||||
let parsed: OverlayToml;
|
||||
try {
|
||||
parsed = parse(raw) as OverlayToml;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`overlay.toml: parse error: ${msg}`);
|
||||
console.warn(
|
||||
"beatleader_player_id is not set; fix overlay.toml or set the BeatLeader id in the URL hash for friend scores.",
|
||||
);
|
||||
return { chatRequestDatabase: undefined, apiDefaults: {} };
|
||||
}
|
||||
|
||||
const { chatRequestDatabase, defaults, beatleaderPlayerIdConfigured, warnings } = overlayTomlToDefaults(
|
||||
parsed,
|
||||
);
|
||||
for (const w of warnings) console.warn(w);
|
||||
if (!beatleaderPlayerIdConfigured) {
|
||||
console.warn(
|
||||
"beatleader_player_id is not set; friend scores need a BeatLeader id in overlay.toml or in the URL hash.",
|
||||
);
|
||||
}
|
||||
|
||||
return { chatRequestDatabase, apiDefaults: defaults };
|
||||
}
|
||||
|
||||
const chatRequestDatabase =
|
||||
Deno.env.get("CHAT_REQUEST_DATABASE")?.trim() || readOptionalPathFile();
|
||||
const { chatRequestDatabase, apiDefaults } = readOverlayConfig();
|
||||
|
||||
function isSafeProxyPath(path: string): boolean {
|
||||
if (!path) return false;
|
||||
@@ -68,20 +86,24 @@ async function proxyApiRequest(req: Request, upstreamBase: string): Promise<Resp
|
||||
}
|
||||
}
|
||||
|
||||
function isChatRequestFilename(pathname: string): boolean {
|
||||
function isChatRequestPath(pathname: string): boolean {
|
||||
const base = pathname.split("/").pop() ?? "";
|
||||
return base === "ChatRequest.json" || base === "database.json";
|
||||
return base === "ChatRequest.json";
|
||||
}
|
||||
|
||||
Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => {
|
||||
const url = new URL(req.url);
|
||||
if (req.method === "GET" && url.pathname === "/api/overlay-config") {
|
||||
const body: OverlayConfigApiBody = { defaults: apiDefaults };
|
||||
return Response.json(body, { headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/api/beatleader") {
|
||||
return proxyApiRequest(req, "https://api.beatleader.com");
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/api/beatsaver") {
|
||||
return proxyApiRequest(req, "https://api.beatsaver.com");
|
||||
}
|
||||
if (req.method === "GET" && chatRequestDatabase && isChatRequestFilename(url.pathname)) {
|
||||
if (req.method === "GET" && chatRequestDatabase && isChatRequestPath(url.pathname)) {
|
||||
try {
|
||||
let text = await Deno.readTextFile(chatRequestDatabase);
|
||||
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
|
||||
@@ -93,23 +115,20 @@ Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => {
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Deno.errors.NotFound) {
|
||||
console.error(`CHAT_REQUEST_DATABASE not found: ${chatRequestDatabase}`);
|
||||
console.error(`chat_request_database not found: ${chatRequestDatabase}`);
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`CHAT_REQUEST_DATABASE read error (${chatRequestDatabase}): ${msg}`);
|
||||
return new Response(`Failed to read CHAT_REQUEST_DATABASE: ${msg}\n`, { status: 500 });
|
||||
console.error(`chat_request_database read error (${chatRequestDatabase}): ${msg}`);
|
||||
return new Response(`Failed to read chat_request_database: ${msg}\n`, { status: 500 });
|
||||
}
|
||||
}
|
||||
return serveDir(req, { fsRoot: root, showDirListing: false });
|
||||
});
|
||||
|
||||
console.log(`Overlay: http://127.0.0.1:${port}/index.html`);
|
||||
if (chatRequestDatabase) {
|
||||
console.log(`Chat request database file: ${chatRequestDatabase}`);
|
||||
} else {
|
||||
if (!chatRequestDatabase) {
|
||||
console.warn(
|
||||
"No database path: set CHAT_REQUEST_DATABASE or create chat-request-database.path (see README). " +
|
||||
"Otherwise /ChatRequest.json is only served from this folder if the file exists.",
|
||||
"No chat_request_database in overlay.toml — place ChatRequest.json in the repo folder or set chat_request_database (see overlay.toml.example).",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user