Major cleanup and add toml configuration
This commit is contained in:
parent
9dbd17eb49
commit
e43d177afe
10
.gitattributes
vendored
Normal file
10
.gitattributes
vendored
Normal file
@ -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
.gitignore
vendored
2
.gitignore
vendored
@ -1,3 +1,3 @@
|
||||
fonts/rajdhani-fontfacekit/
|
||||
ChatRequest.json
|
||||
chat-request-database.path
|
||||
overlay.toml
|
||||
25
AGENTS.md
25
AGENTS.md
@ -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.
|
||||
|
||||
35
README.md
35
README.md
@ -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.
|
||||
|
||||
1
chat-request-database.path
Normal file
1
chat-request-database.path
Normal file
@ -0,0 +1 @@
|
||||
C:\Users\pleb\BSManager\BSInstances\1.40.8\UserData\BeatSaberPlus\ChatRequest\Database.json
|
||||
@ -1 +0,0 @@
|
||||
C:\path\to\UserData\BeatSaberPlus\ChatRequest\Database.json
|
||||
13
deno.lock
generated
13
deno.lock
generated
@ -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
docs/ADR.md
16
docs/ADR.md
@ -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).
|
||||
44
index.css
44
index.css
@ -47,46 +47,6 @@ body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Debug HUD (body.debug): below main overlay, still visible during body.loading */
|
||||
#debugHud {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 6;
|
||||
max-width: min(100%, 48rem);
|
||||
padding: 0.35rem 0.55rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
white-space: normal;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
border-radius: 0.35rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body.debug #debugHud {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#debugHud .debugHud-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.15rem;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
#debugHud .debugHud-k {
|
||||
opacity: 0.72;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
#debugHud code {
|
||||
font-family: ui-monospace, "Cascadia Code", monospace;
|
||||
font-size: 0.92em;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
#songOverlay {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@ -452,7 +412,3 @@ body.bottom #time {
|
||||
float: right;
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
body:not(.debug) #mockBsrSetting {
|
||||
display: none;
|
||||
}
|
||||
|
||||
19
index.html
19
index.html
@ -44,20 +44,6 @@
|
||||
<div id="friendScoresEmpty">No map loaded</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="debugHud">
|
||||
<div class="debugHud-title">Debug</div>
|
||||
<div><span class="debugHud-k">level_id</span> <code id="debugHudLevelId">—</code></div>
|
||||
<div><span class="debugHud-k">hash (level_id)</span> <code id="debugHudRawHash">—</code></div>
|
||||
<div><span class="debugHud-k">hash (BeatLeader)</span> <code id="debugHudHash">—</code></div>
|
||||
<div><span class="debugHud-k">BS+ BSRKey</span> <code id="debugHudBsPlusBsr">—</code></div>
|
||||
<div><span class="debugHud-k">BeatSaver id</span> <code id="debugHudBeatSaverId">—</code></div>
|
||||
<div><span class="debugHud-k">BeatSaver</span> <span id="debugHudBeatSaverNote">—</span></div>
|
||||
<div><span class="debugHud-k">char / diff</span> <span id="debugHudCharDiff">—</span></div>
|
||||
<div><span class="debugHud-k">BS+ playerPlatformId</span> <code id="debugHudHandshake">—</code></div>
|
||||
<div><span class="debugHud-k">BeatLeader id (effective)</span> <code id="debugHudBlId">—</code></div>
|
||||
<div><span class="debugHud-k">BeatLeader leaderboard ids</span> <code id="debugHudBlLeaderboards">—</code></div>
|
||||
<div><span class="debugHud-k">friend scores</span> <span id="debugHudFriends">—</span></div>
|
||||
</div>
|
||||
<div id="requestOverlay" aria-live="polite">
|
||||
<div id="requestHeader">Song requests</div>
|
||||
<ol id="requestList"></ol>
|
||||
@ -95,11 +81,10 @@
|
||||
</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>Debug Song ID (BSR): <input id="debugSongIdInput" type="text" placeholder="e.g. 4f4e4 or 40-char hash" spellcheck="false" autocomplete="off"></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>
|
||||
|
||||
20
overlay.toml.example
Normal file
20
overlay.toml.example
Normal file
@ -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
|
||||
@ -3,9 +3,12 @@ import type {
|
||||
BeatLeaderLeaderboard,
|
||||
BeatLeaderLeaderboardsByHashResponse,
|
||||
BeatLeaderScore,
|
||||
BeatLeaderScoresResponse,
|
||||
FriendMode,
|
||||
} from "./types.ts";
|
||||
import { mirrorOverlayLog } from "./overlay-server-log.ts";
|
||||
|
||||
interface BeatLeaderLeaderboardScoresResponse {
|
||||
scores?: BeatLeaderScore[];
|
||||
}
|
||||
|
||||
const BASE_URL = "https://api.beatleader.com";
|
||||
const PAGE_SIZE = 100;
|
||||
@ -16,14 +19,6 @@ const PAGE_SIZE = 100;
|
||||
*/
|
||||
const MAX_LEADERBOARD_SCORE_PAGES = 2000;
|
||||
const USE_RUNTIME_PROXY = typeof document !== "undefined";
|
||||
export type FriendMode = "mutual" | "following" | "followers";
|
||||
|
||||
/** Browser overlay only: BeatLeader request/result tracing (Deno tests use no `document`). */
|
||||
function blDiag(phase: string, detail: Record<string, unknown>) {
|
||||
if (!USE_RUNTIME_PROXY) return;
|
||||
console.log(`[BS+ overlay] beatleader:${phase}`, detail);
|
||||
mirrorOverlayLog("beatleader", phase, detail);
|
||||
}
|
||||
|
||||
function beatleaderUrl(path: string): string {
|
||||
if (USE_RUNTIME_PROXY) {
|
||||
@ -38,79 +33,33 @@ interface BeatLeaderPlayerLookup {
|
||||
|
||||
export async function fetchBLLeaderboardsByHash(hash: string): Promise<BeatLeaderLeaderboard[]> {
|
||||
const path = `/leaderboards/hash/${encodeURIComponent(hash)}`;
|
||||
blDiag("leaderboardsByHash", { path, hash });
|
||||
try {
|
||||
const res = await fetch(beatleaderUrl(path));
|
||||
if (!res.ok) {
|
||||
blDiag("leaderboardsByHash", { path, hash, reason: "http-not-ok", status: res.status, statusText: res.statusText });
|
||||
return [];
|
||||
}
|
||||
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
|
||||
: [];
|
||||
blDiag("leaderboardsByHash", { path, hash, count: leaderboards.length });
|
||||
return leaderboards;
|
||||
} catch (err) {
|
||||
blDiag("leaderboardsByHash", { path, hash, reason: "fetch-error", error: String(err) });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBeatLeaderPlayerId(playerId: string): Promise<string> {
|
||||
const path = `/player/${encodeURIComponent(playerId)}`;
|
||||
blDiag("resolvePlayer", { path, playerId });
|
||||
try {
|
||||
const res = await fetch(beatleaderUrl(path));
|
||||
if (!res.ok) {
|
||||
blDiag("resolvePlayer", { path, playerId, reason: "http-not-ok", status: res.status, usingId: playerId });
|
||||
return playerId;
|
||||
}
|
||||
if (!res.ok) return playerId;
|
||||
const data = await res.json() as BeatLeaderPlayerLookup;
|
||||
const canonicalId = data.id;
|
||||
const out = canonicalId == null ? playerId : String(canonicalId);
|
||||
blDiag("resolvePlayer", { path, playerId, canonicalId: out, changed: out !== playerId });
|
||||
return out;
|
||||
} catch (err) {
|
||||
blDiag("resolvePlayer", { path, playerId, reason: "fetch-error", error: String(err), usingId: playerId });
|
||||
return canonicalId == null ? playerId : String(canonicalId);
|
||||
} catch {
|
||||
return playerId;
|
||||
}
|
||||
}
|
||||
|
||||
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 fetchLeaderboardScoresById(
|
||||
leaderboardId: string,
|
||||
maxPages = MAX_LEADERBOARD_SCORE_PAGES,
|
||||
@ -118,12 +67,8 @@ async function fetchLeaderboardScoresById(
|
||||
const scores: BeatLeaderScore[] = [];
|
||||
const pageSize = PAGE_SIZE;
|
||||
let page = 1;
|
||||
let hitPageCap = false;
|
||||
for (;;) {
|
||||
if (page > maxPages) {
|
||||
hitPageCap = true;
|
||||
break;
|
||||
}
|
||||
if (page > maxPages) break;
|
||||
const qs = new URLSearchParams({
|
||||
leaderboardContext: "general",
|
||||
page: String(page),
|
||||
@ -136,36 +81,17 @@ async function fetchLeaderboardScoresById(
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url);
|
||||
} catch (err) {
|
||||
blDiag("leaderboardScores", { leaderboardId, page, path, reason: "fetch-error", error: String(err) });
|
||||
break;
|
||||
}
|
||||
if (!res.ok) {
|
||||
blDiag("leaderboardScores", {
|
||||
leaderboardId,
|
||||
page,
|
||||
path,
|
||||
reason: "http-not-ok",
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
});
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
if (!res.ok) break;
|
||||
const payload = await res.json() as BeatLeaderLeaderboardScoresResponse;
|
||||
const batch = Array.isArray(payload.scores) ? payload.scores : [];
|
||||
if (page === 1) {
|
||||
blDiag("leaderboardScores", { leaderboardId, page, path, firstPageBatchSize: batch.length, pageSize });
|
||||
}
|
||||
if (batch.length === 0) break;
|
||||
scores.push(...batch);
|
||||
if (batch.length < pageSize) break;
|
||||
page += 1;
|
||||
}
|
||||
blDiag("leaderboardScoresTotal", {
|
||||
leaderboardId,
|
||||
totalScores: scores.length,
|
||||
...(hitPageCap ? { warning: "hit-max-pages-cap", maxPages } : {}),
|
||||
});
|
||||
return scores;
|
||||
}
|
||||
|
||||
@ -174,17 +100,13 @@ export async function fetchAllMapScoresByHash(
|
||||
leaderboards: BeatLeaderLeaderboard[],
|
||||
maxPagesPerLeaderboard = MAX_LEADERBOARD_SCORE_PAGES,
|
||||
): Promise<BeatLeaderScore[]> {
|
||||
const ids = leaderboards.map((lb) => (lb.id == null ? "" : String(lb.id))).filter(Boolean);
|
||||
blDiag("fetchAllMapScoresByHash", { hash, leaderboardCount: leaderboards.length, leaderboardIds: ids });
|
||||
const requests = leaderboards.map((lb) => {
|
||||
const leaderboardId = lb.id == null ? null : String(lb.id);
|
||||
if (!leaderboardId) return Promise.resolve<BeatLeaderScore[]>([]);
|
||||
return fetchLeaderboardScoresById(leaderboardId, maxPagesPerLeaderboard);
|
||||
});
|
||||
const batches = await Promise.all(requests);
|
||||
const flat = batches.flat();
|
||||
blDiag("fetchAllMapScoresByHash", { hash, totalScores: flat.length });
|
||||
return flat;
|
||||
return batches.flat();
|
||||
}
|
||||
|
||||
async function fetchFollowersPage(
|
||||
@ -202,26 +124,10 @@ async function fetchFollowersPage(
|
||||
const url = beatleaderUrl(path);
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
blDiag("followersPage", {
|
||||
playerId,
|
||||
type,
|
||||
page,
|
||||
path,
|
||||
reason: "http-not-ok",
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json() as BeatLeaderFollower[];
|
||||
const rows = Array.isArray(data) ? data : [];
|
||||
if (page === 1) {
|
||||
blDiag("followersPage", { playerId, type, page, path, count: rows.length });
|
||||
}
|
||||
return rows;
|
||||
} catch (err) {
|
||||
blDiag("followersPage", { playerId, type, page, path, reason: "fetch-error", error: String(err) });
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -241,10 +147,6 @@ async function fetchAllFollowers(
|
||||
return all;
|
||||
}
|
||||
|
||||
export async function fetchMutualFriendIds(playerId: string, maxPages = 100): Promise<Set<string>> {
|
||||
return fetchFriendIds(playerId, "mutual", maxPages);
|
||||
}
|
||||
|
||||
function normalizeFollowerEntry(entry: BeatLeaderFollower): BeatLeaderFollower {
|
||||
return {
|
||||
...entry,
|
||||
@ -255,64 +157,20 @@ function normalizeFollowerEntry(entry: BeatLeaderFollower): 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);
|
||||
blDiag("fetchFriendsStart", { playerId, canonicalPlayerId, mode, maxPages });
|
||||
const [following, followers] = await Promise.all([
|
||||
fetchAllFollowers(canonicalPlayerId, "Following", maxPages),
|
||||
fetchAllFollowers(canonicalPlayerId, "Followers", maxPages),
|
||||
]);
|
||||
blDiag("fetchFriendsLists", {
|
||||
canonicalPlayerId,
|
||||
mode,
|
||||
followingCount: following.length,
|
||||
followersCount: followers.length,
|
||||
});
|
||||
const followingIds = new Set(following.map((entry) => String(entry.id)));
|
||||
if (mode === "following") {
|
||||
const out = following.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
|
||||
blDiag("fetchFriendsResult", { mode, count: out.length });
|
||||
return out;
|
||||
return following.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
|
||||
}
|
||||
if (mode === "followers") {
|
||||
const out = followers.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
|
||||
blDiag("fetchFriendsResult", { mode, count: out.length });
|
||||
return out;
|
||||
return followers.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
|
||||
}
|
||||
const out = followers
|
||||
return followers
|
||||
.filter((entry) => followingIds.has(String(entry.id)))
|
||||
.map((entry) => normalizeFollowerEntry(entry as BeatLeaderFollower));
|
||||
blDiag("fetchFriendsResult", {
|
||||
mode: "mutual",
|
||||
count: out.length,
|
||||
reasonIfEmpty: out.length === 0
|
||||
? (following.length === 0 || followers.length === 0
|
||||
? "missing-following-or-followers-list"
|
||||
: "no-intersection")
|
||||
: undefined,
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function fetchFriendIds(playerId: string, mode: FriendMode, 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 followerIds = new Set(followers.map((entry) => String(entry.id)));
|
||||
if (mode === "following") return followingIds;
|
||||
if (mode === "followers") return followerIds;
|
||||
const mutuals = new Set<string>();
|
||||
for (const id of followerIds) {
|
||||
if (followingIds.has(id)) {
|
||||
mutuals.add(id);
|
||||
}
|
||||
}
|
||||
return mutuals;
|
||||
}
|
||||
|
||||
export async function fetchMutualFriends(playerId: string, maxPages = 100): Promise<BeatLeaderFollower[]> {
|
||||
return fetchFriends(playerId, "mutual", maxPages);
|
||||
}
|
||||
|
||||
export function normalizeAccuracy(value: number | null | undefined): number | null {
|
||||
|
||||
@ -5,18 +5,21 @@ import type {
|
||||
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,
|
||||
fetchBLLeaderboardsByHash,
|
||||
fetchFriends,
|
||||
type FriendMode,
|
||||
normalizeAccuracy,
|
||||
} from "./beatleader.ts";
|
||||
import { mirrorOverlayLog } from "./overlay-server-log.ts";
|
||||
import { mergeOverlayConfigResponse, type OverlayConfigApiBody } from "./overlay-config.ts";
|
||||
|
||||
function must<T extends HTMLElement>(id: string): T {
|
||||
const element = document.getElementById(id);
|
||||
@ -28,41 +31,11 @@ function parseJson<T>(raw: string): T {
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
cover: boolean;
|
||||
mapInfo: boolean;
|
||||
time: boolean;
|
||||
score: boolean;
|
||||
friends: boolean;
|
||||
friendMode: FriendMode;
|
||||
bsr: boolean;
|
||||
debug: boolean;
|
||||
mockBsr: string;
|
||||
debugPlayerId: string;
|
||||
right: boolean;
|
||||
bottom: boolean;
|
||||
scale: number;
|
||||
fade: number;
|
||||
}
|
||||
type Settings = OverlaySettings;
|
||||
|
||||
const settings: Settings = {
|
||||
cover: true,
|
||||
mapInfo: true,
|
||||
time: true,
|
||||
score: true,
|
||||
friends: true,
|
||||
friendMode: "mutual",
|
||||
bsr: false,
|
||||
debug: false,
|
||||
mockBsr: "4f4e4",
|
||||
debugPlayerId: "76561199407393962",
|
||||
right: false,
|
||||
bottom: true,
|
||||
scale: 1,
|
||||
fade: 300,
|
||||
};
|
||||
const settings: Settings = structuredClone(OVERLAY_SETTINGS_INITIAL);
|
||||
|
||||
const defaults = structuredClone(settings);
|
||||
const defaults = structuredClone(OVERLAY_SETTINGS_INITIAL);
|
||||
const style = document.createElement("style");
|
||||
|
||||
function loadSettings() {
|
||||
@ -114,12 +87,9 @@ const beatSaberPlus = {
|
||||
break;
|
||||
case "handshake":
|
||||
currentPlayerPlatformId = data.playerPlatformId || "";
|
||||
console.log("[BS+ overlay] BS+ handshake", { playerPlatformId: currentPlayerPlatformId || "(empty)" });
|
||||
updateDebugHud();
|
||||
void refreshMapFriendScores();
|
||||
break;
|
||||
default:
|
||||
console.log("message", e.data);
|
||||
break;
|
||||
}
|
||||
},
|
||||
@ -127,11 +97,10 @@ const beatSaberPlus = {
|
||||
|
||||
const provider = beatSaberPlus;
|
||||
const retryMs = 10000;
|
||||
let retries = 0;
|
||||
let currentPlayerPlatformId = "";
|
||||
|
||||
function getEffectivePlayerId() {
|
||||
const configured = settings.debugPlayerId.trim();
|
||||
const configured = settings.beatLeaderId.trim();
|
||||
const raw = configured || currentPlayerPlatformId;
|
||||
if (!raw) return "";
|
||||
const steamIdCandidate = raw.match(/\d{17,20}/)?.[0];
|
||||
@ -147,75 +116,100 @@ let friendsRelationCacheKey = "";
|
||||
let friendsRelationCache: BeatLeaderFollower[] | null = null;
|
||||
let friendScoreRequestId = 0;
|
||||
let mapInfoRequestId = 0;
|
||||
let lastMapLevelId = "";
|
||||
let lastBsPlusBsrKey = "";
|
||||
let lastCharDiffStr = "";
|
||||
let lastBeatSaverIdDisplay = "—";
|
||||
let lastBeatSaverNote = "—";
|
||||
let lastFriendScoresDebug = "—";
|
||||
/** Hex hash from BS+ `level_id` (before BeatSaver version hash). */
|
||||
let rawLevelHash = "";
|
||||
let lastBeatLeaderLeaderboardIds = "—";
|
||||
|
||||
function formatErr(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
const debugHud = {
|
||||
levelId: must<HTMLElement>("debugHudLevelId"),
|
||||
rawHash: must<HTMLElement>("debugHudRawHash"),
|
||||
hash: must<HTMLElement>("debugHudHash"),
|
||||
bsPlusBsr: must<HTMLElement>("debugHudBsPlusBsr"),
|
||||
beatSaverId: must<HTMLElement>("debugHudBeatSaverId"),
|
||||
beatSaverNote: must<HTMLElement>("debugHudBeatSaverNote"),
|
||||
charDiff: must<HTMLElement>("debugHudCharDiff"),
|
||||
handshake: must<HTMLElement>("debugHudHandshake"),
|
||||
blId: must<HTMLElement>("debugHudBlId"),
|
||||
blLeaderboards: must<HTMLElement>("debugHudBlLeaderboards"),
|
||||
friends: must<HTMLElement>("debugHudFriends"),
|
||||
} as const;
|
||||
|
||||
function updateDebugHud() {
|
||||
if (!settings.debug) return;
|
||||
debugHud.levelId.textContent = lastMapLevelId || "—";
|
||||
debugHud.rawHash.textContent = rawLevelHash || "—";
|
||||
debugHud.hash.textContent = currentMapHash || "—";
|
||||
debugHud.bsPlusBsr.textContent = lastBsPlusBsrKey || "—";
|
||||
debugHud.beatSaverId.textContent = lastBeatSaverIdDisplay;
|
||||
debugHud.beatSaverNote.textContent = lastBeatSaverNote;
|
||||
debugHud.charDiff.textContent = lastCharDiffStr || "—";
|
||||
debugHud.handshake.textContent = currentPlayerPlatformId || "—";
|
||||
debugHud.blId.textContent = getEffectivePlayerId() || "—";
|
||||
debugHud.blLeaderboards.textContent = lastBeatLeaderLeaderboardIds;
|
||||
debugHud.friends.textContent = lastFriendScoresDebug;
|
||||
}
|
||||
|
||||
function beatLeaderboardId(lb: BeatLeaderLeaderboard): string {
|
||||
const id = lb.id ?? lb.leaderboardId;
|
||||
return id == null ? "" : String(id);
|
||||
}
|
||||
|
||||
function resolvedHashFromBeatSaverMap(map: NonNullable<Awaited<ReturnType<typeof fetchBeatSaverMeta>>>, fallback: string): string {
|
||||
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;
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@ -237,28 +231,15 @@ let timeMultiplier = 1;
|
||||
let duration = 0;
|
||||
|
||||
async function updateMapInfo(data: MapInfo) {
|
||||
if (settings.debugSongId.trim()) {
|
||||
void applyDebugSong();
|
||||
return;
|
||||
}
|
||||
const reqId = ++mapInfoRequestId;
|
||||
const custom = data.level_id.startsWith("custom_level_");
|
||||
const wip = custom && data.level_id.endsWith("WIP");
|
||||
rawLevelHash = custom ? data.level_id.substring(13, 53).toLowerCase() : "";
|
||||
currentMapHash = rawLevelHash;
|
||||
lastMapLevelId = data.level_id;
|
||||
lastBsPlusBsrKey = data.BSRKey || "";
|
||||
lastCharDiffStr = `${data.characteristic} / ${data.difficulty}`;
|
||||
lastBeatLeaderLeaderboardIds = "—";
|
||||
|
||||
console.log("[BS+ overlay] map: new song", {
|
||||
level_id: data.level_id,
|
||||
hashLevelId: rawLevelHash || "(none)",
|
||||
custom,
|
||||
wip,
|
||||
bsPlusBsrKey: data.BSRKey || "(empty, not used for APIs)",
|
||||
characteristic: data.characteristic,
|
||||
difficulty: data.difficulty,
|
||||
});
|
||||
if (settings.debug) {
|
||||
console.log("[BS+ overlay] map: detail", { requestId: reqId, name: data.name, artist: data.artist });
|
||||
}
|
||||
|
||||
cover.src = data.coverRaw ? `data:image/jpeg;base64,${data.coverRaw}` : "images/unknown.svg";
|
||||
title.textContent = data.name || "";
|
||||
@ -269,45 +250,20 @@ 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" : "";
|
||||
// Display: BeatSaver map id when resolved; never rely on BS+ BSRKey for lookups.
|
||||
bsrKey.textContent = custom && !wip ? "…" : custom ? rawLevelHash || "???" : "???";
|
||||
timeMultiplier = data.timeMultiplier || 1;
|
||||
duration = data.duration / 1000;
|
||||
|
||||
lastBeatSaverIdDisplay = "—";
|
||||
lastBeatSaverNote = custom && !wip ? "loading…" : wip ? "WIP (no BeatSaver)" : !custom ? "OST (no hash)" : "—";
|
||||
updateDebugHud();
|
||||
|
||||
// Fetch BeatSaver first for custom maps; BeatLeader uses version hash (or level_id hash fallback), not BS+ BSRKey.
|
||||
if (custom && !wip) {
|
||||
document.body.classList.add("loading");
|
||||
try {
|
||||
console.log("[BS+ overlay] map: BeatSaver lookup by hash (from level_id)", rawLevelHash);
|
||||
const map = await fetchBeatSaverMeta(rawLevelHash);
|
||||
if (reqId !== mapInfoRequestId) return;
|
||||
if (!map?.id) {
|
||||
lastBeatSaverIdDisplay = "—";
|
||||
lastBeatSaverNote = "BeatSaver: no map (check hash / proxy)";
|
||||
currentMapHash = rawLevelHash;
|
||||
console.warn("[BS+ overlay] map: BeatSaver miss — BeatLeader will use level_id hash", {
|
||||
hashLevelId: rawLevelHash,
|
||||
});
|
||||
} else {
|
||||
lastBeatSaverIdDisplay = map.id;
|
||||
lastBeatSaverNote = "ok";
|
||||
const resolved = resolvedHashFromBeatSaverMap(map, rawLevelHash);
|
||||
if (resolved !== rawLevelHash) {
|
||||
console.log("[BS+ overlay] map: using BeatSaver version hash for BeatLeader", {
|
||||
hashLevelId: rawLevelHash,
|
||||
hashBeatLeader: resolved,
|
||||
beatSaverId: map.id,
|
||||
});
|
||||
}
|
||||
currentMapHash = resolved;
|
||||
console.log("[BS+ overlay] map: BeatSaver ok", {
|
||||
beatSaverId: map.id,
|
||||
hashBeatLeader: currentMapHash,
|
||||
});
|
||||
bsrKey.textContent = map.id;
|
||||
mapper.textContent = map.metadata?.levelAuthorName || "";
|
||||
const diff = map.versions?.[0]?.diffs?.find(
|
||||
@ -315,18 +271,12 @@ async function updateMapInfo(data: MapInfo) {
|
||||
);
|
||||
if (diff?.label) difficultyLabel.textContent = diff.label;
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
if (reqId !== mapInfoRequestId) return;
|
||||
lastBeatSaverIdDisplay = "—";
|
||||
lastBeatSaverNote = `error: ${formatErr(err)}`;
|
||||
currentMapHash = rawLevelHash;
|
||||
console.error("[BS+ overlay] map: BeatSaver fetch failed — BeatLeader will use level_id hash", err);
|
||||
} finally {
|
||||
document.body.classList.remove("loading");
|
||||
if (reqId === mapInfoRequestId) {
|
||||
updateDebugHud();
|
||||
void refreshMapFriendScores();
|
||||
}
|
||||
if (reqId === mapInfoRequestId) void refreshMapFriendScores();
|
||||
}
|
||||
} else {
|
||||
if (custom && wip) {
|
||||
@ -335,7 +285,6 @@ async function updateMapInfo(data: MapInfo) {
|
||||
bsrKey.textContent = "???";
|
||||
}
|
||||
difficultyLabel.textContent = "";
|
||||
updateDebugHud();
|
||||
void refreshMapFriendScores();
|
||||
}
|
||||
}
|
||||
@ -429,13 +378,6 @@ function renderFriendScores(items: Array<{ name: string; acc: number; avatar: st
|
||||
}
|
||||
}
|
||||
|
||||
/** Friend-score flow: always log to the browser/OBS console when the friends panel is enabled (not gated on debug). */
|
||||
function friendsDiag(message: string, detail: Record<string, unknown> = {}) {
|
||||
if (!settings.friends) return;
|
||||
console.log(`[BS+ overlay] friends:${message}`, detail);
|
||||
mirrorOverlayLog("friends", message, detail);
|
||||
}
|
||||
|
||||
function friendsRelationListKey(playerId: string): string {
|
||||
return `${playerId}\0${settings.friendMode}`;
|
||||
}
|
||||
@ -443,55 +385,30 @@ function friendsRelationListKey(playerId: string): string {
|
||||
async function refreshMapFriendScores() {
|
||||
const hash = currentMapHash;
|
||||
if (!settings.friends) {
|
||||
lastFriendScoresDebug = "off";
|
||||
lastBeatLeaderLeaderboardIds = "—";
|
||||
updateDebugHud();
|
||||
clearFriendScores("Disabled in settings");
|
||||
return;
|
||||
}
|
||||
if (!hash) {
|
||||
friendsDiag("skip", { reason: "no-map-hash", hint: "Need custom map level_id hash or resolved BeatSaver hash" });
|
||||
lastFriendScoresDebug = "no hash";
|
||||
lastBeatLeaderLeaderboardIds = "—";
|
||||
updateDebugHud();
|
||||
clearFriendScores("No map loaded");
|
||||
return;
|
||||
}
|
||||
const playerId = getEffectivePlayerId();
|
||||
if (!playerId) {
|
||||
friendsDiag("skip", {
|
||||
reason: "no-player-id",
|
||||
hint: "Wait for BS+ handshake (playerPlatformId) or set debug BeatLeader id in settings",
|
||||
});
|
||||
lastFriendScoresDebug = "no BeatLeader player id";
|
||||
lastBeatLeaderLeaderboardIds = "—";
|
||||
updateDebugHud();
|
||||
clearFriendScores("Waiting for BeatLeader player id");
|
||||
return;
|
||||
}
|
||||
friendScoresPanel.classList.add("is-loading");
|
||||
friendScoresEmpty.textContent = "Loading mutual friend scores...";
|
||||
lastFriendScoresDebug = "loading…";
|
||||
updateDebugHud();
|
||||
const requestId = ++friendScoreRequestId;
|
||||
friendsDiag("start", {
|
||||
requestId,
|
||||
hash,
|
||||
playerId,
|
||||
friendMode: settings.friendMode,
|
||||
debugPlayerOverride: Boolean(settings.debug && settings.debugPlayerId.trim()),
|
||||
});
|
||||
try {
|
||||
const relKey = friendsRelationListKey(playerId);
|
||||
const friendsPromise: Promise<BeatLeaderFollower[]> = (async () => {
|
||||
if (friendsRelationCache !== null && relKey === friendsRelationCacheKey) {
|
||||
friendsDiag("friends-list-cache", { hit: true, friendCount: friendsRelationCache.length });
|
||||
return friendsRelationCache;
|
||||
}
|
||||
const fetched = await fetchFriends(playerId, settings.friendMode);
|
||||
friendsRelationCacheKey = relKey;
|
||||
friendsRelationCache = fetched;
|
||||
friendsDiag("friends-list-cache", { hit: false, friendCount: fetched.length });
|
||||
return fetched;
|
||||
})();
|
||||
const [leaderboards, friends] = await Promise.all([
|
||||
@ -499,37 +416,13 @@ async function refreshMapFriendScores() {
|
||||
friendsPromise,
|
||||
]);
|
||||
if (requestId !== friendScoreRequestId) return;
|
||||
const leaderboardIds = leaderboards.map(beatLeaderboardId).filter(Boolean);
|
||||
lastBeatLeaderLeaderboardIds = leaderboardIds.length ? leaderboardIds.join(", ") : "none";
|
||||
updateDebugHud();
|
||||
friendsDiag("parallel-fetch-done", {
|
||||
hash,
|
||||
leaderboardCount: leaderboards.length,
|
||||
leaderboardIds,
|
||||
friendCount: friends.length,
|
||||
});
|
||||
if (leaderboards.length === 0) {
|
||||
friendsDiag("empty-ui", {
|
||||
reason: "no-beatleader-leaderboards-for-hash",
|
||||
hash,
|
||||
hint: "BeatLeader has no leaderboards for this map hash (wrong hash, unranked, or API/proxy error — see beatleader:leaderboardsByHash logs)",
|
||||
});
|
||||
lastFriendScoresDebug = "0 leaderboards for hash";
|
||||
updateDebugHud();
|
||||
clearFriendScores("No BeatLeader leaderboards found");
|
||||
return;
|
||||
}
|
||||
const friendById = new Map(friends.map((f) => [f.id, f]));
|
||||
const mutualFriendIds = new Set(friends.map((f) => f.id));
|
||||
if (mutualFriendIds.size === 0) {
|
||||
friendsDiag("empty-ui", {
|
||||
reason: "no-friends-for-mode",
|
||||
friendMode: settings.friendMode,
|
||||
hash,
|
||||
hint: "Following/followers lists empty or mutual mode has no intersection — see beatleader:fetchFriendsResult",
|
||||
});
|
||||
lastFriendScoresDebug = `0 friends (${settings.friendMode})`;
|
||||
updateDebugHud();
|
||||
const relationLabel = settings.friendMode === "following"
|
||||
? "No followed BeatLeader players"
|
||||
: settings.friendMode === "followers"
|
||||
@ -540,7 +433,6 @@ async function refreshMapFriendScores() {
|
||||
}
|
||||
const scores = await fetchAllMapScoresByHash(hash, leaderboards);
|
||||
if (requestId !== friendScoreRequestId) return;
|
||||
friendsDiag("scores-aggregated", { hash, rawScoreRows: scores.length, friendIdsInRelation: mutualFriendIds.size });
|
||||
const bestByPlayer = new Map<string, { name: string; acc: number; avatar: string | null }>();
|
||||
for (const score of scores) {
|
||||
const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null);
|
||||
@ -564,141 +456,27 @@ async function refreshMapFriendScores() {
|
||||
}
|
||||
}
|
||||
const sorted = Array.from(bestByPlayer.values()).sort((a, b) => b.acc - a.acc);
|
||||
if (sorted.length === 0 && scores.length > 0) {
|
||||
friendsDiag("empty-ui", {
|
||||
reason: "no-friend-scores-on-leaderboards",
|
||||
hash,
|
||||
rawScoreRows: scores.length,
|
||||
friendIdsInRelation: mutualFriendIds.size,
|
||||
hint: "Leaderboard scores exist but none match friend ids (playerId on scores vs BeatLeader friend ids)",
|
||||
});
|
||||
} else if (sorted.length === 0 && scores.length === 0) {
|
||||
friendsDiag("empty-ui", {
|
||||
reason: "no-scores-on-map-leaderboards",
|
||||
hash,
|
||||
leaderboardIds,
|
||||
hint: "No ranked rows returned for these leaderboards — see beatleader:leaderboardScores logs",
|
||||
});
|
||||
} else {
|
||||
friendsDiag("done", { rows: sorted.length, hash });
|
||||
}
|
||||
lastFriendScoresDebug = `${leaderboards.length} LB, ${friends.length} friends, ${scores.length} scores → ${sorted.length} rows`;
|
||||
updateDebugHud();
|
||||
renderFriendScores(sorted);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
if (requestId !== friendScoreRequestId) return;
|
||||
friendsDiag("error", { message: formatErr(err), hash, playerId });
|
||||
lastFriendScoresDebug = `error: ${formatErr(err)}`;
|
||||
lastBeatLeaderLeaderboardIds = "—";
|
||||
updateDebugHud();
|
||||
clearFriendScores("Failed loading BeatLeader scores");
|
||||
}
|
||||
}
|
||||
|
||||
async function applyMockMapFromBsr() {
|
||||
const key = settings.mockBsr.trim();
|
||||
if (!settings.debug || !key) return;
|
||||
const map = await fetchBeatSaverMapById(key);
|
||||
if (!map) {
|
||||
console.warn("[BS+ overlay] map: mock BSR lookup returned nothing", { key });
|
||||
return;
|
||||
window.onhashchange = () => {
|
||||
loadSettings();
|
||||
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();
|
||||
}
|
||||
const hash = map.versions?.[0]?.hash?.toLowerCase?.() || "";
|
||||
rawLevelHash = hash;
|
||||
currentMapHash = hash;
|
||||
lastBeatLeaderLeaderboardIds = "—";
|
||||
title.textContent = map.metadata?.songName || map.name || title.textContent || "";
|
||||
subTitle.textContent = map.metadata?.songSubName || "";
|
||||
artist.textContent = map.metadata?.songAuthorName || "";
|
||||
mapper.textContent = map.metadata?.levelAuthorName || "";
|
||||
bsrKey.textContent = map.id || key;
|
||||
type.textContent = "MOCK";
|
||||
const coverUrl = map.versions?.[0]?.coverURL;
|
||||
if (coverUrl) cover.src = coverUrl;
|
||||
lastMapLevelId = `mock:${key}`;
|
||||
lastBsPlusBsrKey = "";
|
||||
lastCharDiffStr = "";
|
||||
lastBeatSaverIdDisplay = map.id || "—";
|
||||
lastBeatSaverNote = "mock BSR";
|
||||
updateDebugHud();
|
||||
console.log("[BS+ overlay] map: mock from BSR key", { key, hash: currentMapHash, mapId: map.id });
|
||||
void refreshMapFriendScores();
|
||||
}
|
||||
|
||||
window.onhashchange = loadSettings;
|
||||
loadSettings();
|
||||
document.head.appendChild(style);
|
||||
updateDebugHud();
|
||||
|
||||
// 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") {
|
||||
updateDebugHud();
|
||||
void loadRequestQueue();
|
||||
void applyMockMapFromBsr();
|
||||
void refreshMapFriendScores();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const friendModeInput = must<HTMLSelectElement>("friendModeInput");
|
||||
friendModeInput.value = settings.friendMode;
|
||||
friendModeInput.onchange = () => {
|
||||
settings.friendMode = friendModeInput.value as FriendMode;
|
||||
saveSettings();
|
||||
void refreshMapFriendScores();
|
||||
};
|
||||
|
||||
const mockBsrInput = must<HTMLInputElement>("mockBsrInput");
|
||||
mockBsrInput.value = settings.mockBsr;
|
||||
mockBsrInput.oninput = () => {
|
||||
settings.mockBsr = mockBsrInput.value.trim();
|
||||
saveSettings();
|
||||
void applyMockMapFromBsr();
|
||||
};
|
||||
|
||||
const beatLeaderPlayerInput = must<HTMLInputElement>("beatLeaderPlayerInput");
|
||||
beatLeaderPlayerInput.value = settings.debugPlayerId;
|
||||
beatLeaderPlayerInput.oninput = () => {
|
||||
settings.debugPlayerId = beatLeaderPlayerInput.value.trim();
|
||||
saveSettings();
|
||||
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;
|
||||
@ -708,22 +486,8 @@ 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) => {
|
||||
@ -732,18 +496,6 @@ 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 || "";
|
||||
@ -797,18 +549,104 @@ 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);
|
||||
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 refreshMapFriendScores();
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
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})`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
108
src/client/overlay-config.ts
Normal file
108
src/client/overlay-config.ts
Normal file
@ -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 };
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Mirrors overlay diagnostics to the Deno static server terminal (`POST /api/overlay-log`).
|
||||
* Browser console still receives the same messages via `console.log`.
|
||||
*/
|
||||
export function mirrorOverlayLog(scope: string, phase: string, detail: Record<string, unknown>): void {
|
||||
if (typeof location === "undefined") return;
|
||||
if (location.protocol !== "http:" && location.protocol !== "https:") return;
|
||||
const body = JSON.stringify({ scope, phase, detail });
|
||||
void fetch("/api/overlay-log", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body,
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
@ -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 {
|
||||
@ -118,10 +153,6 @@ export interface BeatLeaderScore {
|
||||
player?: BeatLeaderPlayer | string | null;
|
||||
}
|
||||
|
||||
export interface BeatLeaderScoresResponse {
|
||||
data?: BeatLeaderScore[];
|
||||
}
|
||||
|
||||
export interface BeatLeaderFollower {
|
||||
id: string;
|
||||
alias?: string | null;
|
||||
|
||||
@ -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,24 +86,16 @@ 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 === "POST" && url.pathname === "/api/overlay-log") {
|
||||
try {
|
||||
const raw = await req.text();
|
||||
const parsed = JSON.parse(raw) as { scope?: string; phase?: string; detail?: unknown };
|
||||
const scope = typeof parsed.scope === "string" ? parsed.scope : "?";
|
||||
const phase = typeof parsed.phase === "string" ? parsed.phase : "?";
|
||||
console.log(`[overlay] ${scope}:${phase}`, parsed.detail ?? "");
|
||||
} catch {
|
||||
console.log("[overlay] overlay-log: invalid JSON body");
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
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");
|
||||
@ -93,7 +103,7 @@ Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => {
|
||||
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);
|
||||
@ -105,24 +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`);
|
||||
console.log("Friend/BeatLeader diagnostics from the page also print here (browser console has the same lines).");
|
||||
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).",
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user