Use a deno runtime to read the bs+ bsr queue file (Database.json)
This commit is contained in:
parent
4b4c74f31b
commit
4b0cf034bb
4
.gitignore
vendored
4
.gitignore
vendored
@ -1 +1,3 @@
|
|||||||
fonts/rajdhani-fontfacekit/
|
fonts/rajdhani-fontfacekit/
|
||||||
|
ChatRequest.json
|
||||||
|
chat-request-database.path
|
||||||
@ -1,16 +1,21 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
Static OBS/browser overlay that reads Beat Saber Plus Song Overlay events over WebSocket (`ws://localhost:2947/socket`).
|
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 [`serve.ts`](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).
|
||||||
|
|
||||||
## Files of interest
|
## Files of interest
|
||||||
|
|
||||||
- [`index.html`](index.html) — Page shell: markup for map info, time bar, score, settings dialog; pulls `index.css` and `index.js`.
|
- [`index.html`](index.html) — Page shell: markup for map info, time bar, score, settings dialog; pulls `index.css` and `index.js`.
|
||||||
- [`index.js`](index.js) — Connects to BS+ WebSocket, parses JSON events (`gameState`, `mapInfo`, `pause`, `resume`, `score`), updates DOM; optional BeatSaver API fetch for custom maps; reads/writes settings from URL hash.
|
- [`index.js`](index.js) — Connects to BS+ WebSocket, parses JSON events (`gameState`, `mapInfo`, `pause`, `resume`, `score`), updates DOM; optional BeatSaver API fetch for custom maps; reads/writes settings from URL hash.
|
||||||
- [`index.css`](index.css) — Layout, theming, visibility toggles driven by body classes and CSS variables from settings.
|
- [`index.css`](index.css) — Layout, theming, visibility toggles driven by body classes and CSS variables from settings.
|
||||||
|
- [`serve.ts`](serve.ts) — Deno static server + optional mapping of `ChatRequest.json` / `database.json` to the real BS+ `Database.json` path.
|
||||||
- [`types.d.ts`](types.d.ts) — JSDoc typedefs for BS+ payloads and events (editor/IDE hints only).
|
- [`types.d.ts`](types.d.ts) — JSDoc typedefs for BS+ payloads and events (editor/IDE hints only).
|
||||||
- [`jsconfig.json`](jsconfig.json) — JS project roots/path so editors resolve modules and `types.d.ts`.
|
- [`jsconfig.json`](jsconfig.json) — JS project roots/path so editors resolve modules and `types.d.ts`.
|
||||||
- [`images/`](images/) — Cover fallback (`unknown.svg`), characteristic icons under `images/characteristic/` (filenames match BS+ characteristic strings).
|
- [`images/`](images/) — Cover fallback (`unknown.svg`), characteristic icons under `images/characteristic/` (filenames match BS+ characteristic strings).
|
||||||
- [`README.md`](README.md) — User-facing usage (hosted URL, OBS, local `file://`, BS+ module).
|
- [`README.md`](README.md) — User-facing usage (Deno, OBS URL, BS+ module).
|
||||||
- [`dprint.json`](dprint.json) — Formatter config for the repo.
|
- [`dprint.json`](dprint.json) — Formatter config for the repo.
|
||||||
- [`.editorconfig`](.editorconfig) — Basic indent/charset rules for editors.
|
- [`.editorconfig`](.editorconfig) — Basic indent/charset rules for editors.
|
||||||
|
|
||||||
|
|||||||
23
README.md
23
README.md
@ -7,8 +7,25 @@ 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).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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).
|
||||||
|
|
||||||
|
**Or** set the environment variable (overrides the path file):
|
||||||
|
|
||||||
|
```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 to use the overlay locally.
|
Clone the repo and run `deno task serve` as above.
|
||||||
|
|
||||||
Note: in OBS browser source, use URL `file:///C:/path-to-overlay.../index.html` instead of "Local file" so that URL parameters work.
|
|
||||||
|
|||||||
1
chat-request-database.path.example
Normal file
1
chat-request-database.path.example
Normal file
@ -0,0 +1 @@
|
|||||||
|
C:\path\to\UserData\BeatSaberPlus\ChatRequest\Database.json
|
||||||
6
deno.json
Normal file
6
deno.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"serve": "deno run --allow-net --allow-read --allow-env serve.ts",
|
||||||
|
"dev": "deno run --watch --allow-net --allow-read --allow-env serve.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
66
deno.lock
generated
Normal file
66
deno.lock
generated
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"version": "5",
|
||||||
|
"specifiers": {
|
||||||
|
"jsr:@std/cli@^1.0.28": "1.0.28",
|
||||||
|
"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",
|
||||||
|
"jsr:@std/html@^1.0.5": "1.0.5",
|
||||||
|
"jsr:@std/http@*": "1.0.25",
|
||||||
|
"jsr:@std/internal@^1.0.12": "1.0.12",
|
||||||
|
"jsr:@std/media-types@^1.1.0": "1.1.0",
|
||||||
|
"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/cli@1.0.28": {
|
||||||
|
"integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a"
|
||||||
|
},
|
||||||
|
"@std/encoding@1.0.10": {
|
||||||
|
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
|
||||||
|
},
|
||||||
|
"@std/fmt@1.0.9": {
|
||||||
|
"integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0"
|
||||||
|
},
|
||||||
|
"@std/fs@1.0.23": {
|
||||||
|
"integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37"
|
||||||
|
},
|
||||||
|
"@std/html@1.0.5": {
|
||||||
|
"integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e"
|
||||||
|
},
|
||||||
|
"@std/http@1.0.25": {
|
||||||
|
"integrity": "577b4252290af1097132812b339fffdd55fb0f4aeb98ff11bdbf67998aa17193",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/cli",
|
||||||
|
"jsr:@std/encoding",
|
||||||
|
"jsr:@std/fmt",
|
||||||
|
"jsr:@std/fs",
|
||||||
|
"jsr:@std/html",
|
||||||
|
"jsr:@std/media-types",
|
||||||
|
"jsr:@std/net",
|
||||||
|
"jsr:@std/path@^1.1.4",
|
||||||
|
"jsr:@std/streams"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/internal@1.0.12": {
|
||||||
|
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
|
||||||
|
},
|
||||||
|
"@std/media-types@1.1.0": {
|
||||||
|
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
|
||||||
|
},
|
||||||
|
"@std/net@1.0.6": {
|
||||||
|
"integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c"
|
||||||
|
},
|
||||||
|
"@std/path@1.1.4": {
|
||||||
|
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/internal"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/streams@1.0.17": {
|
||||||
|
"integrity": "7859f3d9deed83cf4b41f19223d4a67661b3d3819e9fc117698f493bf5992140"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,15 +2,14 @@
|
|||||||
|
|
||||||
## Static web app with typed JavaScript
|
## Static web app with typed JavaScript
|
||||||
|
|
||||||
The overlay loads inside OBS from a URL or `file://` path, with no install step for streamers.
|
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
|
Consequences: Ease of use
|
||||||
|
|
||||||
- Adding or updating the overlay is **copy or point OBS at `index.html`** (hosted or local); nothing to build before 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.
|
||||||
- We avoid a toolchain that would complicate “drop this folder in” or quick Netlify-style hosting.
|
|
||||||
- Type safety is **optional IDE assistance**, not enforced at publish time (there is no `tsc` in CI by default).
|
- Type safety is **optional IDE assistance**, not enforced at publish time (there is no `tsc` in CI by default).
|
||||||
|
|
||||||
This static, dependency-free shape exists **because** it stays easy to wire up as an OBS Browser Source while keeping the codebase maintainable through JSDoc and `types.d.ts`.
|
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
|
## BeatSaberPlus
|
||||||
|
|
||||||
|
|||||||
@ -1,39 +1,37 @@
|
|||||||
# Testing in a browser
|
# Testing in a browser
|
||||||
|
|
||||||
The overlay is a static page. You can exercise the UI and wiring without OBS by opening it in any Chromium-based browser (Chrome, Edge) or Firefox.
|
Run **`deno task serve`** from the repo (see [README](../README.md)), then open the overlay in Chromium (Chrome, Edge) or Firefox.
|
||||||
|
|
||||||
## Open the page
|
## Open the page
|
||||||
|
|
||||||
Use a **`file://` URL** (same idea as OBS): absolute path to `index.html`, forward slashes. This clone:
|
Use the URL the server prints, for example:
|
||||||
|
|
||||||
`file:///C:/Users/example/ops/BeatSaber-Overlay/index.html?scale=1.5`
|
`http://127.0.0.1:8080/index.html?scale=1.5`
|
||||||
|
|
||||||
Paste that into the address bar, or drag `index.html` into a browser window.
|
Settings live in the **URL fragment** (after `#`). Put query parameters **before** the hash if you use both: `index.html?debug=1#…`
|
||||||
|
|
||||||
Settings are stored in the **URL fragment** (after `#`). Using a full `file://` URL (not only picking “Open file” in a way that strips the hash) keeps hash-based settings working, same idea as in the [README](../README.md).
|
## Preview the song overlay (no Beat Saber)
|
||||||
|
|
||||||
## Debug mode (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.
|
||||||
|
|
||||||
Without the game, the overlay normally hides outside **Playing** (and during BeatSaver loading). Add a **query parameter** so it stays visible for layout or WebSocket checks:
|
## Request list simulation
|
||||||
|
|
||||||
- Enable: `?debug=1`
|
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.
|
||||||
- Example: `file:///C:/Users/example/ops/BeatSaber-Overlay/index.html?debug=1`
|
|
||||||
|
|
||||||
Put `?debug` **before** the `#` hash if you use both: `index.html?debug=1#…`
|
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
|
## What you should see
|
||||||
|
|
||||||
- The overlay layout with placeholder labels until live data arrives.
|
|
||||||
- **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.
|
- **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.
|
- **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
|
## Quick UI checks without the game
|
||||||
|
|
||||||
- Click the page (outside the settings dialog) to toggle the **preview** / settings visibility.
|
- **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.
|
- Change checkboxes and values in the dialog; the **URL hash** should update and layout should reflect toggles and scale.
|
||||||
|
|
||||||
## Live data path
|
## 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 server.
|
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).
|
For custom maps, the overlay may request **BeatSaver** over HTTPS; use **Network** in devtools if those requests fail (offline, blocked, or API errors).
|
||||||
|
|||||||
99
index.css
99
index.css
@ -36,15 +36,106 @@ body {
|
|||||||
background: rgba(0, 0, 0, 0);
|
background: rgba(0, 0, 0, 0);
|
||||||
color: white;
|
color: white;
|
||||||
filter: drop-shadow(0 0 0.1rem black) drop-shadow(0 0 0.1rem black);
|
filter: drop-shadow(0 0 0.1rem black) drop-shadow(0 0 0.1rem black);
|
||||||
|
}
|
||||||
|
|
||||||
|
#overlayStack {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#songOverlay {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 0;
|
||||||
|
opacity: 1;
|
||||||
transition-property: opacity;
|
transition-property: opacity;
|
||||||
transition-timing-function: ease-out;
|
transition-timing-function: ease-out;
|
||||||
transition-duration: calc(var(--fade, 300) * 1ms);
|
transition-duration: calc(var(--fade, 300) * 1ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Production: hide when not Playing (or loading map meta). Debug: ?debug=1 (see index.html). */
|
#requestOverlay {
|
||||||
html:not(.debug) body.loading,
|
position: absolute;
|
||||||
html:not(.debug) body:not([data-game-state="Playing"], .preview) {
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
white-space: normal;
|
||||||
|
opacity: 1;
|
||||||
|
transition-property: opacity;
|
||||||
|
transition-timing-function: ease-out;
|
||||||
|
transition-duration: calc(var(--fade, 300) * 1ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Song panel: hide when not Playing (or loading map meta). Use body.preview to show placeholders (click). */
|
||||||
|
body.loading #songOverlay,
|
||||||
|
body:not([data-game-state="Playing"], .preview) #songOverlay {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Request queue: show when idle (Menu); hide while Playing, preview, or loading map meta */
|
||||||
|
body[data-game-state="Playing"] #requestOverlay,
|
||||||
|
body.preview #requestOverlay,
|
||||||
|
body.loading #requestOverlay {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#requestHeader {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#requestList {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 2.2rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
#requestList:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#requestList .request-item + .request-item {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#requestEmpty {
|
||||||
|
font-size: 1.45rem;
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
#requestOverlay.has-items #requestEmpty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-item {
|
||||||
|
display: list-item;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-title {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-meta {
|
||||||
|
opacity: 0.92;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-meta::before {
|
||||||
|
content: "· ";
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
@ -212,7 +303,7 @@ body:not(.bsr) #bsrKey {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.right > .row {
|
body.right #songOverlay > .row {
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
index.html
18
index.html
@ -4,12 +4,10 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>BS Overlay</title>
|
<title>BS Overlay</title>
|
||||||
<link rel="stylesheet" href="index.css">
|
<link rel="stylesheet" href="index.css">
|
||||||
<script>
|
|
||||||
if (new URLSearchParams(location.search).get("debug") === "1")
|
|
||||||
document.documentElement.classList.add("debug");
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-game-state="Menu">
|
||||||
|
<div id="overlayStack">
|
||||||
|
<div id="songOverlay">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<img id="coverImg" src="images/unknown.svg">
|
<img id="coverImg" src="images/unknown.svg">
|
||||||
<div id="mapInfo">
|
<div id="mapInfo">
|
||||||
@ -39,6 +37,13 @@
|
|||||||
<span id="accuracy">96.9</span>
|
<span id="accuracy">96.9</span>
|
||||||
<span id="mistakes">7</span>
|
<span id="mistakes">7</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="requestOverlay" aria-live="polite">
|
||||||
|
<div id="requestHeader">Song requests</div>
|
||||||
|
<ol id="requestList"></ol>
|
||||||
|
<div id="requestEmpty">No pending requests</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<svg width="0" height="0" style="position: absolute">
|
<svg width="0" height="0" style="position: absolute">
|
||||||
<filter id="gamma" color-interpolation-filters="sRGB">
|
<filter id="gamma" color-interpolation-filters="sRGB">
|
||||||
@ -64,9 +69,10 @@
|
|||||||
</select></label>
|
</select></label>
|
||||||
<label>Scale (%): <input id="scaleInput" type="number" min="10" max="1000" step="5"></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>Fade (ms): <input id="fadeInput" type="number" min="0" max="5000" step="10"></label>
|
||||||
|
<label>Debug: <input id="debugInput" type="checkbox"></label>
|
||||||
<br>
|
<br>
|
||||||
<strong>About</strong>
|
<strong>About</strong>
|
||||||
<a href="https://github.com/ibillingsley/BeatSaber-Overlay" target="_blank">Source code</a>
|
<a href="https://github.com/ibillingsley/BeatSaber-Overlay" target="_blank">This was forked from Iza's overlay</a>
|
||||||
</dialog>
|
</dialog>
|
||||||
<script src="index.js"></script>
|
<script src="index.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
123
index.js
123
index.js
@ -172,6 +172,7 @@ const settings = {
|
|||||||
time: true,
|
time: true,
|
||||||
score: true,
|
score: true,
|
||||||
bsr: false,
|
bsr: false,
|
||||||
|
debug: false,
|
||||||
right: false,
|
right: false,
|
||||||
bottom: true,
|
bottom: true,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
@ -206,12 +207,13 @@ document.head.appendChild(style);
|
|||||||
|
|
||||||
// Settings UI
|
// Settings UI
|
||||||
|
|
||||||
for (const key of ["cover", "mapInfo", "time", "score", "bsr"]) {
|
for (const key of ["cover", "mapInfo", "time", "score", "bsr", "debug"]) {
|
||||||
const input = document.getElementById(`${key}Input`);
|
const input = document.getElementById(`${key}Input`);
|
||||||
input.checked = settings[key];
|
input.checked = settings[key];
|
||||||
input.oninput = () => {
|
input.oninput = () => {
|
||||||
settings[key] = input.checked;
|
settings[key] = input.checked;
|
||||||
saveSettings();
|
saveSettings();
|
||||||
|
if (key === "debug") loadRequestQueue();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,3 +240,122 @@ fade.oninput = () => {
|
|||||||
|
|
||||||
document.documentElement.onclick = e => document.body.classList.toggle("preview");
|
document.documentElement.onclick = e => document.body.classList.toggle("preview");
|
||||||
document.getElementById("settings").onclick = e => e.stopPropagation();
|
document.getElementById("settings").onclick = e => e.stopPropagation();
|
||||||
|
|
||||||
|
// Song request queue (JSON from same origin as page; poll). See docs/testing.md for ?requests=
|
||||||
|
|
||||||
|
const MAX_REQUESTS = 10;
|
||||||
|
const REQUEST_POLL_MS = 5000;
|
||||||
|
const requestListEl = document.getElementById("requestList");
|
||||||
|
const requestOverlayEl = document.getElementById("requestOverlay");
|
||||||
|
const requestEmptyEl = document.getElementById("requestEmpty");
|
||||||
|
const requestTitleCache = new Map();
|
||||||
|
|
||||||
|
function useRequestHistorySim() {
|
||||||
|
return settings.debug || new URLSearchParams(location.search).get("debug") === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {string[]} */
|
||||||
|
function requestJsonFilenames() {
|
||||||
|
const explicit = new URLSearchParams(location.search).get("requests");
|
||||||
|
if (explicit) return [explicit];
|
||||||
|
if (useRequestHistorySim()) return ["ChatRequest.json", "database.json"];
|
||||||
|
return ["ChatRequest.json"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} fileName */
|
||||||
|
function loadJsonNextToPage(fileName) {
|
||||||
|
const base = new URL(fileName, location.href);
|
||||||
|
if (base.protocol !== "http:" && base.protocol !== "https:") {
|
||||||
|
throw new Error("not-http");
|
||||||
|
}
|
||||||
|
const busted = new URL(base.href);
|
||||||
|
busted.searchParams.set("t", String(Date.now()));
|
||||||
|
return fetch(busted.href, { cache: "no-store" }).then(res => {
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {ChatRequestEntry} item */
|
||||||
|
function requesterLine(item) {
|
||||||
|
const parts = [item.npr, item.rqn].filter(Boolean);
|
||||||
|
return parts.length ? parts.join(" ") : item.rqn || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} key @param {HTMLElement} titleEl */
|
||||||
|
async function enrichRequestTitle(key, titleEl) {
|
||||||
|
if (requestTitleCache.has(key)) {
|
||||||
|
titleEl.textContent = requestTitleCache.get(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://api.beatsaver.com/maps/id/${encodeURIComponent(key)}`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const map = await response.json();
|
||||||
|
const name = map.metadata?.songName ?? map.name;
|
||||||
|
if (name && typeof name === "string") {
|
||||||
|
requestTitleCache.set(key, name);
|
||||||
|
titleEl.textContent = name;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// keep !bsr placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {ChatRequestEntry[]} items */
|
||||||
|
function renderRequestList(items) {
|
||||||
|
requestListEl.replaceChildren();
|
||||||
|
requestOverlayEl.classList.toggle("has-items", items.length > 0);
|
||||||
|
for (const item of items) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "request-item";
|
||||||
|
const titleEl = document.createElement("span");
|
||||||
|
titleEl.className = "request-title";
|
||||||
|
titleEl.textContent = `!bsr ${item.key}`;
|
||||||
|
li.appendChild(titleEl);
|
||||||
|
const who = requesterLine(item);
|
||||||
|
if (who) {
|
||||||
|
const meta = document.createElement("span");
|
||||||
|
meta.className = "request-meta";
|
||||||
|
meta.textContent = who;
|
||||||
|
li.appendChild(meta);
|
||||||
|
}
|
||||||
|
requestListEl.appendChild(li);
|
||||||
|
enrichRequestTitle(item.key, titleEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRequestQueue() {
|
||||||
|
try {
|
||||||
|
/** @type {ChatRequestPayload} */
|
||||||
|
const data = await loadRequestPayload();
|
||||||
|
if (requestEmptyEl) {
|
||||||
|
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);
|
||||||
|
renderRequestList(items);
|
||||||
|
} catch {
|
||||||
|
if (requestEmptyEl) {
|
||||||
|
requestEmptyEl.textContent = "whupsy, database file missing";
|
||||||
|
requestOverlayEl.classList.add("request-load-failed");
|
||||||
|
}
|
||||||
|
renderRequestList([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRequestQueue();
|
||||||
|
window.setInterval(loadRequestQueue, REQUEST_POLL_MS);
|
||||||
|
|||||||
72
serve.ts
Normal file
72
serve.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { join } from "jsr:@std/path";
|
||||||
|
import { serveDir } from "jsr:@std/http/file-server";
|
||||||
|
|
||||||
|
const root = import.meta.dirname ?? ".";
|
||||||
|
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 next to this script: absolute path to `ChatRequest/Database.json`. */
|
||||||
|
function readOptionalPathFile(): string | undefined {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// missing or unreadable
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatRequestDatabase =
|
||||||
|
Deno.env.get("CHAT_REQUEST_DATABASE")?.trim() || readOptionalPathFile();
|
||||||
|
|
||||||
|
function isChatRequestFilename(pathname: string): boolean {
|
||||||
|
const base = pathname.split("/").pop() ?? "";
|
||||||
|
return base === "ChatRequest.json" || base === "database.json";
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (req.method === "GET" && chatRequestDatabase && isChatRequestFilename(url.pathname)) {
|
||||||
|
try {
|
||||||
|
let text = await Deno.readTextFile(chatRequestDatabase);
|
||||||
|
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
|
||||||
|
return new Response(text, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Deno.errors.NotFound) {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
14
types.d.ts
vendored
14
types.d.ts
vendored
@ -64,6 +64,20 @@ type BeatSaberPlusEvent = HandshakeEvent | GameStateEvent | ResumeEvent | PauseE
|
|||||||
type MapInfo = MapInfoChangedEvent["mapInfoChanged"];
|
type MapInfo = MapInfoChangedEvent["mapInfoChanged"];
|
||||||
type Score = ScoreEvent["scoreEvent"];
|
type Score = ScoreEvent["scoreEvent"];
|
||||||
|
|
||||||
|
/** Chat request / queue JSON (e.g. ChatRequest.json beside the overlay) */
|
||||||
|
interface ChatRequestEntry {
|
||||||
|
key: string;
|
||||||
|
rqt: number;
|
||||||
|
rqn: string;
|
||||||
|
npr: string;
|
||||||
|
msg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatRequestPayload {
|
||||||
|
queue: ChatRequestEntry[];
|
||||||
|
history: ChatRequestEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
interface Document {
|
interface Document {
|
||||||
// Assume non-null
|
// Assume non-null
|
||||||
getElementById(elementId: `${string}Img`): HTMLImageElement;
|
getElementById(elementId: `${string}Img`): HTMLImageElement;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user