Full rewrite in TypeScript
This commit is contained in:
parent
4b0cf034bb
commit
18c629ac72
11
AGENTS.md
11
AGENTS.md
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
245
index.js
@ -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
367
index.ts
Normal 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);
|
||||||
@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"checkJs": true,
|
|
||||||
"target": "es2021",
|
|
||||||
"noImplicitAny": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
86
types.d.ts
vendored
86
types.d.ts
vendored
@ -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
85
types.ts
Normal 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[];
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user