Full rewrite in TypeScript

This commit is contained in:
pleb 2026-04-11 16:19:40 -07:00
parent 4b0cf034bb
commit 18c629ac72
9 changed files with 739 additions and 413 deletions

View File

@ -8,12 +8,13 @@ Do **not** add or maintain code paths for opening the overlay as **`file://`**.
## 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 bundled `index.js` module.
- [`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.ts`](index.ts) — Main overlay source in TypeScript; connects to BS+ WebSocket, parses events (`gameState`, `mapInfo`, `pause`, `resume`, `score`), updates DOM, and manages settings/hash state.
- [`types.ts`](types.ts) — Shared TypeScript types for BS+ payloads/events and chat request JSON.
- [`index.js`](index.js) — Bundled browser output generated from `index.ts` via `deno task build`.
- [`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. - [`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). - [`deno.json`](deno.json) — Deno tasks and TypeScript compiler options (`build`, `serve`, `dev`).
- [`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 (Deno, OBS URL, 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.
@ -21,4 +22,4 @@ Do **not** add or maintain code paths for opening the overlay as **`file://`**.
## Out of scope here ## Out of scope here
Beat Saber Plus itself (game mod) exposes the socket; this repo is only the HTML/CSS/JS client. Beat Saber Plus itself (game mod) exposes the socket; this repo is only the HTML/CSS/TS client.

View File

@ -11,6 +11,8 @@ Requires [BeatSaberPlus](https://github.com/hardcpp/BeatSaberPlus)
Install [Deno](https://docs.deno.com/runtime/getting_started/installation/) and serve the overlay over HTTP (see below). Install [Deno](https://docs.deno.com/runtime/getting_started/installation/) and serve the overlay over HTTP (see below).
The browser overlay source is now TypeScript (`index.ts`) and is bundled to `index.js` with `deno task build` (run automatically by `deno task serve`).
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. 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). **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).
@ -29,3 +31,9 @@ If neither the path file nor `CHAT_REQUEST_DATABASE` is set, the overlay only fi
### Usage ### Usage
Clone the repo and run `deno task serve` as above. Clone the repo and run `deno task serve` as above.
### Development tasks
- `deno task build` - bundle/check `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 `index.ts` changes

View File

@ -1,6 +1,11 @@
{ {
"compilerOptions": {
"strict": true,
"lib": ["dom", "dom.iterable", "deno.ns", "esnext"]
},
"tasks": { "tasks": {
"serve": "deno run --allow-net --allow-read --allow-env serve.ts", "build": "deno bundle --platform=browser --check index.ts -o index.js",
"dev": "deno run --watch --allow-net --allow-read --allow-env serve.ts" "serve": "deno task build && deno run --allow-net --allow-read --allow-env serve.ts",
"dev": "deno bundle --platform=browser --watch --check index.ts -o index.js"
} }
} }

View File

@ -74,6 +74,6 @@
<strong>About</strong> <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 was forked from Iza's overlay</a>
</dialog> </dialog>
<script src="index.js"></script> <script type="module" src="index.js"></script>
</body> </body>
</html> </html>

245
index.js
View File

@ -1,51 +1,46 @@
"use strict"; // index.ts
function must(id) {
// WebSocket connection const element = document.getElementById(id);
if (!element) throw new Error(`Missing element: ${id}`);
const beatSaberPlus = { return element;
}
function parseJson(raw) {
return JSON.parse(raw);
}
var beatSaberPlus = {
// https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay // https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay
url: "ws://localhost:2947/socket", url: "ws://localhost:2947/socket",
onMessage: (e) => {
/** @param {MessageEvent<string>} e */ const data = parseJson(e.data);
onMessage: function(e) {
/** @type {BeatSaberPlusEvent} */
const data = JSON.parse(e.data);
switch (data._type) { switch (data._type) {
case "event": case "event":
switch (data._event) { switch (data._event) {
case "gameState": case "gameState":
document.body.dataset.gameState = data.gameStateChanged; document.body.dataset.gameState = data.gameStateChanged;
break; break;
case "mapInfo": case "mapInfo":
updateMapInfo(data.mapInfoChanged); void updateMapInfo(data.mapInfoChanged);
break; break;
case "pause": case "pause":
updateTime(data.pauseTime, true); updateTime(data.pauseTime, true);
break; break;
case "resume": case "resume":
updateTime(data.resumeTime, false); updateTime(data.resumeTime, false);
break; break;
case "score": case "score":
updateScore(data.scoreEvent); updateScore(data.scoreEvent);
break; break;
} }
break; break;
default: default:
console.log("message", e.data); console.log("message", e.data);
break; break;
} }
}, }
}; };
var provider = beatSaberPlus;
const provider = beatSaberPlus; var retryMs = 1e4;
const retryMs = 10000; var retries = 0;
let retries = 0;
function connect() { function connect() {
console.log(`Connecting to ${provider.url} (attempt ${retries++})`); console.log(`Connecting to ${provider.url} (attempt ${retries++})`);
const ws = new WebSocket(provider.url); const ws = new WebSocket(provider.url);
@ -53,36 +48,27 @@ function connect() {
ws.onmessage = provider.onMessage; ws.onmessage = provider.onMessage;
ws.onclose = onClose; ws.onclose = onClose;
} }
function onOpen() { function onOpen() {
console.log("Connection open."); console.log("Connection open.");
retries = 0; retries = 0;
} }
/** @param {CloseEvent} e */
function onClose(e) { function onClose(e) {
console.log(`Connection closed. code: ${e.code}, reason: ${e.reason}, clean: ${e.wasClean}`); console.log(`Connection closed. code: ${e.code}, reason: ${e.reason}, clean: ${e.wasClean}`);
setTimeout(connect, retryMs); setTimeout(connect, retryMs);
} }
connect(); connect();
var cover = must("coverImg");
// Map info var title = must("title");
var subTitle = must("subTitle");
const cover = document.getElementById("coverImg"); var artist = must("artist");
const title = document.getElementById("title"); var mapper = must("mapper");
const subTitle = document.getElementById("subTitle"); var difficulty = must("difficulty");
const artist = document.getElementById("artist"); var characteristic = must("characteristicImg");
const mapper = document.getElementById("mapper"); var difficultyLabel = must("difficultyLabel");
const difficulty = document.getElementById("difficulty"); var type = must("type");
const characteristic = document.getElementById("characteristicImg"); var bsrKey = must("bsrKey");
const difficultyLabel = document.getElementById("difficultyLabel"); var timeMultiplier = 1;
const type = document.getElementById("type"); var duration = 0;
const bsrKey = document.getElementById("bsrKey");
let timeMultiplier = 1;
let duration = 0;
/** @param {MapInfo} data */
async function updateMapInfo(data) { async function updateMapInfo(data) {
const custom = data.level_id.startsWith("custom_level_"); const custom = data.level_id.startsWith("custom_level_");
const wip = custom && data.level_id.endsWith("WIP"); const wip = custom && data.level_id.endsWith("WIP");
@ -93,13 +79,11 @@ async function updateMapInfo(data) {
mapper.textContent = data.mapper || ""; mapper.textContent = data.mapper || "";
difficulty.textContent = data.difficulty?.replace("Plus", " +") || ""; difficulty.textContent = data.difficulty?.replace("Plus", " +") || "";
characteristic.src = `images/characteristic/${data.characteristic}.svg`; characteristic.src = `images/characteristic/${data.characteristic}.svg`;
difficultyLabel.textContent = ""; // BS+ does not provide label difficultyLabel.textContent = "";
type.textContent = !custom ? "OST" : wip ? "WIP" : ""; type.textContent = !custom ? "OST" : wip ? "WIP" : "";
bsrKey.textContent = data.BSRKey || "???"; // Always empty? bsrKey.textContent = data.BSRKey || "???";
timeMultiplier = data.timeMultiplier || 1; timeMultiplier = data.timeMultiplier || 1;
duration = data.duration / 1000; duration = data.duration / 1e3;
// Fetch extra info from BeatSaver
if (custom && !wip) { if (custom && !wip) {
document.body.classList.add("loading"); document.body.classList.add("loading");
try { try {
@ -107,66 +91,46 @@ async function updateMapInfo(data) {
const map = await response.json(); const map = await response.json();
if (!map.id) return; if (!map.id) return;
bsrKey.textContent = map.id; bsrKey.textContent = map.id;
mapper.textContent = map.metadata.levelAuthorName; // Replace mapper name with full authors list mapper.textContent = map.metadata.levelAuthorName;
// Find difficulty label const diff = map.versions[0].diffs.find((d) => d.characteristic === data.characteristic && d.difficulty === data.difficulty);
const diff = map.versions[0].diffs.find(d => if (diff?.label) difficultyLabel.textContent = diff.label;
d.characteristic === data.characteristic && d.difficulty === data.difficulty
);
if (diff.label) difficultyLabel.textContent = diff.label;
} finally { } finally {
document.body.classList.remove("loading"); document.body.classList.remove("loading");
} }
} }
} }
var timeText = must("timeText");
// Song time var timeBar = must("timeBar");
var intervalMs = 500;
const timeText = document.getElementById("timeText"); var intervalId = 0;
const timeBar = document.getElementById("timeBar"); var currentTime = 0;
const intervalMs = 500;
let intervalId = 0;
let currentTime = 0;
/** @param {number} time @param {boolean} paused */
function updateTime(time, paused) { function updateTime(time, paused) {
if (!settings.time) return; if (!settings.time) return;
setTime(time); setTime(time);
clearInterval(intervalId); clearInterval(intervalId);
if (paused) return; if (paused) return;
intervalId = window.setInterval(() => setTime(currentTime + intervalMs * timeMultiplier / 1000), intervalMs); intervalId = window.setInterval(() => setTime(currentTime + intervalMs * timeMultiplier / 1e3), intervalMs);
} }
/** @param {number} time */
function setTime(time) { function setTime(time) {
currentTime = time; currentTime = time;
timeText.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`; timeText.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`;
timeBar.style.width = `${currentTime / (duration || Infinity) * 100}%`; timeBar.style.width = `${currentTime / (duration || Infinity) * 100}%`;
} }
/** @param {number} t */
function formatTime(t) { function formatTime(t) {
t = Math.floor(t); t = Math.floor(t);
const minutes = Math.floor(t / 60); const minutes = Math.floor(t / 60);
const seconds = t - minutes * 60; const seconds = t - minutes * 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`; return `${minutes}:${String(seconds).padStart(2, "0")}`;
} }
var accuracy = must("accuracy");
// Score var mistakes = must("mistakes");
const accuracy = document.getElementById("accuracy");
const mistakes = document.getElementById("mistakes");
/** @param {Score} score */
function updateScore(score) { function updateScore(score) {
if (!settings.score) return; if (!settings.score) return;
accuracy.textContent = (score.accuracy * 100).toFixed(1); accuracy.textContent = (score.accuracy * 100).toFixed(1);
mistakes.textContent = score.missCount ? String(score.missCount) : ""; mistakes.textContent = score.missCount ? String(score.missCount) : "";
accuracy.classList.toggle("failed", score.currentHealth === 0); accuracy.classList.toggle("failed", score.currentHealth === 0);
} }
var settings = {
// Settings
const settings = {
cover: true, cover: true,
mapInfo: true, mapInfo: true,
time: true, time: true,
@ -176,93 +140,92 @@ const settings = {
right: false, right: false,
bottom: true, bottom: true,
scale: 1, scale: 1,
fade: 300, fade: 300
}; };
var defaults = structuredClone(settings);
const defaults = structuredClone(settings); var style = document.createElement("style");
const style = document.createElement("style");
function loadSettings() { function loadSettings() {
const params = new URLSearchParams(location.hash.slice(1)); const params = new URLSearchParams(location.hash.slice(1));
let css = ""; let css = "";
for (const [key, def] of Object.entries(defaults)) { for (const [key, def] of Object.entries(defaults)) {
const value = JSON.parse(params.get(key) || "null") ?? def; const value = parseJson(params.get(key) || "null") ?? def;
settings[key] = value; settings[key] = value;
if (typeof def === "boolean") document.body.classList.toggle(key, value); if (typeof def === "boolean") document.body.classList.toggle(key, Boolean(value));
else css += `--${key}: ${value}; `; else css += `--${key}: ${value}; `;
} }
style.textContent = `:root { ${css}}`; style.textContent = `:root { ${css}}`;
} }
function saveSettings() { function saveSettings() {
const params = new URLSearchParams(); const params = new URLSearchParams();
for (const [key, value] of Object.entries(settings)) for (const [key, value] of Object.entries(settings)) {
if (value !== defaults[key]) params.set(key, JSON.stringify(value)); if (value !== defaults[key]) params.set(key, JSON.stringify(value));
location.replace("#" + params.toString()); }
location.replace(`#${params.toString()}`);
} }
window.onhashchange = loadSettings; window.onhashchange = loadSettings;
loadSettings(); loadSettings();
document.head.appendChild(style); document.head.appendChild(style);
for (const key of [
// Settings UI "cover",
"mapInfo",
for (const key of ["cover", "mapInfo", "time", "score", "bsr", "debug"]) { "time",
const input = document.getElementById(`${key}Input`); "score",
"bsr",
"debug"
]) {
const input = must(`${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(); if (key === "debug") void loadRequestQueue();
}; };
} }
var scale = must("scaleInput");
const scale = document.getElementById("scaleInput");
scale.valueAsNumber = settings.scale * 100; scale.valueAsNumber = settings.scale * 100;
scale.oninput = () => { scale.oninput = () => {
settings.scale = scale.valueAsNumber / 100; settings.scale = scale.valueAsNumber / 100;
saveSettings(); saveSettings();
}; };
var position = must("positionInput");
const position = document.getElementById("positionInput"); position.value = JSON.stringify([
position.value = JSON.stringify([settings.right, settings.bottom]); settings.right,
settings.bottom
]);
position.onchange = () => { position.onchange = () => {
[settings.right, settings.bottom] = JSON.parse(position.value); [settings.right, settings.bottom] = parseJson(position.value);
saveSettings(); saveSettings();
}; };
var fade = must("fadeInput");
const fade = document.getElementById("fadeInput");
fade.valueAsNumber = settings.fade; fade.valueAsNumber = settings.fade;
fade.oninput = () => { fade.oninput = () => {
settings.fade = fade.valueAsNumber; settings.fade = fade.valueAsNumber;
saveSettings(); saveSettings();
}; };
document.documentElement.onclick = () => document.body.classList.toggle("preview");
document.documentElement.onclick = e => document.body.classList.toggle("preview"); must("settings").onclick = (e) => e.stopPropagation();
document.getElementById("settings").onclick = e => e.stopPropagation(); var MAX_REQUESTS = 10;
var REQUEST_POLL_MS = 5e3;
// Song request queue (JSON from same origin as page; poll). See docs/testing.md for ?requests= var requestListEl = must("requestList");
var requestOverlayEl = must("requestOverlay");
const MAX_REQUESTS = 10; var requestEmptyEl = must("requestEmpty");
const REQUEST_POLL_MS = 5000; var requestTitleCache = /* @__PURE__ */ new Map();
const requestListEl = document.getElementById("requestList");
const requestOverlayEl = document.getElementById("requestOverlay");
const requestEmptyEl = document.getElementById("requestEmpty");
const requestTitleCache = new Map();
function useRequestHistorySim() { function useRequestHistorySim() {
return settings.debug || new URLSearchParams(location.search).get("debug") === "1"; return settings.debug || new URLSearchParams(location.search).get("debug") === "1";
} }
/** @returns {string[]} */
function requestJsonFilenames() { function requestJsonFilenames() {
const explicit = new URLSearchParams(location.search).get("requests"); const explicit = new URLSearchParams(location.search).get("requests");
if (explicit) return [explicit]; if (explicit) return [
if (useRequestHistorySim()) return ["ChatRequest.json", "database.json"]; explicit
return ["ChatRequest.json"]; ];
if (useRequestHistorySim()) return [
"ChatRequest.json",
"database.json"
];
return [
"ChatRequest.json"
];
} }
/** @param {string} fileName */
function loadJsonNextToPage(fileName) { function loadJsonNextToPage(fileName) {
const base = new URL(fileName, location.href); const base = new URL(fileName, location.href);
if (base.protocol !== "http:" && base.protocol !== "https:") { if (base.protocol !== "http:" && base.protocol !== "https:") {
@ -270,12 +233,13 @@ function loadJsonNextToPage(fileName) {
} }
const busted = new URL(base.href); const busted = new URL(base.href);
busted.searchParams.set("t", String(Date.now())); busted.searchParams.set("t", String(Date.now()));
return fetch(busted.href, { cache: "no-store" }).then(res => { return fetch(busted.href, {
cache: "no-store"
}).then((res) => {
if (!res.ok) throw new Error(String(res.status)); if (!res.ok) throw new Error(String(res.status));
return res.json(); return res.json();
}); });
} }
async function loadRequestPayload() { async function loadRequestPayload() {
let lastErr; let lastErr;
for (const name of requestJsonFilenames()) { for (const name of requestJsonFilenames()) {
@ -287,17 +251,16 @@ async function loadRequestPayload() {
} }
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr)); throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
} }
/** @param {ChatRequestEntry} item */
function requesterLine(item) { function requesterLine(item) {
const parts = [item.npr, item.rqn].filter(Boolean); const parts = [
item.npr,
item.rqn
].filter(Boolean);
return parts.length ? parts.join(" ") : item.rqn || ""; return parts.length ? parts.join(" ") : item.rqn || "";
} }
/** @param {string} key @param {HTMLElement} titleEl */
async function enrichRequestTitle(key, titleEl) { async function enrichRequestTitle(key, titleEl) {
if (requestTitleCache.has(key)) { if (requestTitleCache.has(key)) {
titleEl.textContent = requestTitleCache.get(key); titleEl.textContent = requestTitleCache.get(key) ?? "";
return; return;
} }
try { try {
@ -310,11 +273,8 @@ async function enrichRequestTitle(key, titleEl) {
titleEl.textContent = name; titleEl.textContent = name;
} }
} catch { } catch {
// keep !bsr placeholder
} }
} }
/** @param {ChatRequestEntry[]} items */
function renderRequestList(items) { function renderRequestList(items) {
requestListEl.replaceChildren(); requestListEl.replaceChildren();
requestOverlayEl.classList.toggle("has-items", items.length > 0); requestOverlayEl.classList.toggle("has-items", items.length > 0);
@ -333,29 +293,22 @@ function renderRequestList(items) {
li.appendChild(meta); li.appendChild(meta);
} }
requestListEl.appendChild(li); requestListEl.appendChild(li);
enrichRequestTitle(item.key, titleEl); void enrichRequestTitle(item.key, titleEl);
} }
} }
async function loadRequestQueue() { async function loadRequestQueue() {
try { try {
/** @type {ChatRequestPayload} */
const data = await loadRequestPayload(); const data = await loadRequestPayload();
if (requestEmptyEl) {
requestEmptyEl.textContent = "No pending requests"; requestEmptyEl.textContent = "No pending requests";
requestOverlayEl.classList.remove("request-load-failed"); requestOverlayEl.classList.remove("request-load-failed");
}
const raw = useRequestHistorySim() ? data.history ?? [] : data.queue ?? []; const raw = useRequestHistorySim() ? data.history ?? [] : data.queue ?? [];
const items = raw.slice(0, MAX_REQUESTS); const items = raw.slice(0, MAX_REQUESTS);
renderRequestList(items); renderRequestList(items);
} catch { } catch {
if (requestEmptyEl) {
requestEmptyEl.textContent = "whupsy, database file missing"; requestEmptyEl.textContent = "whupsy, database file missing";
requestOverlayEl.classList.add("request-load-failed"); requestOverlayEl.classList.add("request-load-failed");
}
renderRequestList([]); renderRequestList([]);
} }
} }
void loadRequestQueue();
loadRequestQueue(); window.setInterval(() => void loadRequestQueue(), REQUEST_POLL_MS);
window.setInterval(loadRequestQueue, REQUEST_POLL_MS);

367
index.ts Normal file
View File

@ -0,0 +1,367 @@
import type {
BeatSaberPlusEvent,
ChatRequestEntry,
ChatRequestPayload,
MapInfo,
Score,
} from "./types.ts";
function must<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (!element) throw new Error(`Missing element: ${id}`);
return element as T;
}
function parseJson<T>(raw: string): T {
return JSON.parse(raw) as T;
}
// WebSocket connection
const beatSaberPlus = {
// https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay
url: "ws://localhost:2947/socket",
onMessage: (e: MessageEvent<string>) => {
const data = parseJson<BeatSaberPlusEvent>(e.data);
switch (data._type) {
case "event":
switch (data._event) {
case "gameState":
document.body.dataset.gameState = data.gameStateChanged;
break;
case "mapInfo":
void updateMapInfo(data.mapInfoChanged);
break;
case "pause":
updateTime(data.pauseTime, true);
break;
case "resume":
updateTime(data.resumeTime, false);
break;
case "score":
updateScore(data.scoreEvent);
break;
}
break;
default:
console.log("message", e.data);
break;
}
},
};
const provider = beatSaberPlus;
const retryMs = 10000;
let retries = 0;
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}`);
setTimeout(connect, retryMs);
}
connect();
// Map info
const cover = must<HTMLImageElement>("coverImg");
const title = must<HTMLElement>("title");
const subTitle = must<HTMLElement>("subTitle");
const artist = must<HTMLElement>("artist");
const mapper = must<HTMLElement>("mapper");
const difficulty = must<HTMLElement>("difficulty");
const characteristic = must<HTMLImageElement>("characteristicImg");
const difficultyLabel = must<HTMLElement>("difficultyLabel");
const type = must<HTMLElement>("type");
const bsrKey = must<HTMLElement>("bsrKey");
let timeMultiplier = 1;
let duration = 0;
async function updateMapInfo(data: MapInfo) {
const custom = data.level_id.startsWith("custom_level_");
const wip = custom && data.level_id.endsWith("WIP");
cover.src = data.coverRaw ? `data:image/jpeg;base64,${data.coverRaw}` : "images/unknown.svg";
title.textContent = data.name || "";
subTitle.textContent = data.sub_name || "";
artist.textContent = data.artist || "";
mapper.textContent = data.mapper || "";
difficulty.textContent = data.difficulty?.replace("Plus", " +") || "";
characteristic.src = `images/characteristic/${data.characteristic}.svg`;
difficultyLabel.textContent = ""; // BS+ does not provide label
type.textContent = !custom ? "OST" : wip ? "WIP" : "";
bsrKey.textContent = data.BSRKey || "???"; // Always empty?
timeMultiplier = data.timeMultiplier || 1;
duration = data.duration / 1000;
// Fetch extra info from BeatSaver
if (custom && !wip) {
document.body.classList.add("loading");
try {
const response = await fetch(`https://api.beatsaver.com/maps/hash/${data.level_id.substring(13, 53)}`);
const map = await response.json();
if (!map.id) return;
bsrKey.textContent = map.id;
mapper.textContent = map.metadata.levelAuthorName; // Replace mapper name with full authors list
const diff = map.versions[0].diffs.find(
(d: { characteristic: string; difficulty: string; label?: string }) =>
d.characteristic === data.characteristic && d.difficulty === data.difficulty,
);
if (diff?.label) difficultyLabel.textContent = diff.label;
} finally {
document.body.classList.remove("loading");
}
}
}
// Song time
const timeText = must<HTMLElement>("timeText");
const timeBar = must<HTMLElement>("timeBar");
const intervalMs = 500;
let intervalId = 0;
let currentTime = 0;
function updateTime(time: number, paused: boolean) {
if (!settings.time) return;
setTime(time);
clearInterval(intervalId);
if (paused) return;
intervalId = window.setInterval(() => setTime(currentTime + intervalMs * timeMultiplier / 1000), intervalMs);
}
function setTime(time: number) {
currentTime = time;
timeText.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`;
timeBar.style.width = `${currentTime / (duration || Infinity) * 100}%`;
}
function formatTime(t: number) {
t = Math.floor(t);
const minutes = Math.floor(t / 60);
const seconds = t - minutes * 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
// Score
const accuracy = must<HTMLElement>("accuracy");
const mistakes = must<HTMLElement>("mistakes");
function updateScore(score: Score) {
if (!settings.score) return;
accuracy.textContent = (score.accuracy * 100).toFixed(1);
mistakes.textContent = score.missCount ? String(score.missCount) : "";
accuracy.classList.toggle("failed", score.currentHealth === 0);
}
// Settings
interface Settings {
cover: boolean;
mapInfo: boolean;
time: boolean;
score: boolean;
bsr: boolean;
debug: boolean;
right: boolean;
bottom: boolean;
scale: number;
fade: number;
}
const settings: Settings = {
cover: true,
mapInfo: true,
time: true,
score: true,
bsr: false,
debug: false,
right: false,
bottom: true,
scale: 1,
fade: 300,
};
const defaults = structuredClone(settings);
const style = document.createElement("style");
function loadSettings() {
const params = new URLSearchParams(location.hash.slice(1));
let css = "";
for (const [key, def] of Object.entries(defaults) as [keyof Settings, Settings[keyof Settings]][]) {
const value = (parseJson<Settings[keyof Settings] | null>(params.get(key) || "null") ?? def);
(settings as Record<keyof Settings, Settings[keyof Settings]>)[key] = value;
if (typeof def === "boolean") document.body.classList.toggle(key, Boolean(value));
else css += `--${key}: ${value}; `;
}
style.textContent = `:root { ${css}}`;
}
function saveSettings() {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(settings) as [keyof Settings, Settings[keyof Settings]][]) {
if (value !== defaults[key]) params.set(key, JSON.stringify(value));
}
location.replace(`#${params.toString()}`);
}
window.onhashchange = loadSettings;
loadSettings();
document.head.appendChild(style);
// Settings UI
for (const key of ["cover", "mapInfo", "time", "score", "bsr", "debug"] as const) {
const input = must<HTMLInputElement>(`${key}Input`);
input.checked = settings[key];
input.oninput = () => {
settings[key] = input.checked;
saveSettings();
if (key === "debug") void loadRequestQueue();
};
}
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=
const MAX_REQUESTS = 10;
const REQUEST_POLL_MS = 5000;
const requestListEl = must<HTMLOListElement>("requestList");
const requestOverlayEl = must<HTMLElement>("requestOverlay");
const requestEmptyEl = must<HTMLElement>("requestEmpty");
const requestTitleCache = new Map<string, 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");
}
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() as Promise<ChatRequestPayload>;
});
}
async function loadRequestPayload() {
let lastErr: unknown;
for (const name of requestJsonFilenames()) {
try {
return await loadJsonNextToPage(name);
} catch (e) {
lastErr = e;
}
}
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
}
function requesterLine(item: ChatRequestEntry) {
const parts = [item.npr, item.rqn].filter(Boolean);
return parts.length ? parts.join(" ") : item.rqn || "";
}
async function enrichRequestTitle(key: string, titleEl: HTMLElement) {
if (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
}
}
function renderRequestList(items: ChatRequestEntry[]) {
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);
void enrichRequestTitle(item.key, titleEl);
}
}
async function loadRequestQueue() {
try {
const data = await loadRequestPayload();
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 {
requestEmptyEl.textContent = "whupsy, database file missing";
requestOverlayEl.classList.add("request-load-failed");
renderRequestList([]);
}
}
void loadRequestQueue();
window.setInterval(() => void loadRequestQueue(), REQUEST_POLL_MS);

View File

@ -1,7 +0,0 @@
{
"compilerOptions": {
"checkJs": true,
"target": "es2021",
"noImplicitAny": false
}
}

86
types.d.ts vendored
View File

@ -1,86 +0,0 @@
// https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay
interface HandshakeEvent {
"_type": "handshake";
"protocolVersion": number;
"gameVersion": string;
"playerName": string;
"playerPlatformId": string;
}
interface GameStateEvent {
"_type": "event";
"_event": "gameState";
"gameStateChanged": "Menu" | "Playing";
}
interface ResumeEvent {
"_type": "event";
"_event": "resume";
"resumeTime": number;
}
interface PauseEvent {
"_type": "event";
"_event": "pause";
"pauseTime": number;
}
interface MapInfoChangedEvent {
"_type": "event";
"_event": "mapInfo";
"mapInfoChanged": {
"level_id": string;
"name": string;
"sub_name": string;
"artist": string;
"mapper": string;
"characteristic": string;
"difficulty": string;
"duration": number;
"BPM": number;
"PP": number;
"BSRKey": string;
"coverRaw": string;
"time": number;
"timeMultiplier": number;
};
}
interface ScoreEvent {
"_type": "event";
"_event": "score";
"scoreEvent": {
"time": number;
"score": number;
"accuracy": number;
"combo": number;
"missCount": number;
"currentHealth": number;
};
}
type BeatSaberPlusEvent = HandshakeEvent | GameStateEvent | ResumeEvent | PauseEvent | MapInfoChangedEvent | ScoreEvent;
type MapInfo = MapInfoChangedEvent["mapInfoChanged"];
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 {
// Assume non-null
getElementById(elementId: `${string}Img`): HTMLImageElement;
getElementById(elementId: `${string}Input`): HTMLInputElement;
getElementById(elementId: string): HTMLElement;
}

85
types.ts Normal file
View File

@ -0,0 +1,85 @@
// https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay
export interface HandshakeEvent {
_type: "handshake";
protocolVersion: number;
gameVersion: string;
playerName: string;
playerPlatformId: string;
}
export interface GameStateEvent {
_type: "event";
_event: "gameState";
gameStateChanged: "Menu" | "Playing";
}
export interface ResumeEvent {
_type: "event";
_event: "resume";
resumeTime: number;
}
export interface PauseEvent {
_type: "event";
_event: "pause";
pauseTime: number;
}
export interface MapInfoChangedEvent {
_type: "event";
_event: "mapInfo";
mapInfoChanged: {
level_id: string;
name: string;
sub_name: string;
artist: string;
mapper: string;
characteristic: string;
difficulty: string;
duration: number;
BPM: number;
PP: number;
BSRKey: string;
coverRaw: string;
time: number;
timeMultiplier: number;
};
}
export interface ScoreEvent {
_type: "event";
_event: "score";
scoreEvent: {
time: number;
score: number;
accuracy: number;
combo: number;
missCount: number;
currentHealth: number;
};
}
export type BeatSaberPlusEvent =
| HandshakeEvent
| GameStateEvent
| ResumeEvent
| PauseEvent
| MapInfoChangedEvent
| ScoreEvent;
export type MapInfo = MapInfoChangedEvent["mapInfoChanged"];
export type Score = ScoreEvent["scoreEvent"];
/** Chat request / queue JSON (e.g. ChatRequest.json beside the overlay) */
export interface ChatRequestEntry {
key: string;
rqt: number;
rqn: string;
npr: string;
msg: string;
}
export interface ChatRequestPayload {
queue: ChatRequestEntry[];
history: ChatRequestEntry[];
}