From 18c629ac72e8afd4d06d93fe6c1edd0a04983a76 Mon Sep 17 00:00:00 2001 From: pleb Date: Sat, 11 Apr 2026 16:19:40 -0700 Subject: [PATCH] Full rewrite in TypeScript --- AGENTS.md | 11 +- README.md | 8 + deno.json | 13 +- index.html | 2 +- index.js | 573 +++++++++++++++++++++++--------------------------- index.ts | 367 ++++++++++++++++++++++++++++++++ jsconfig.json | 7 - types.d.ts | 86 -------- types.ts | 85 ++++++++ 9 files changed, 739 insertions(+), 413 deletions(-) create mode 100644 index.ts delete mode 100644 jsconfig.json delete mode 100644 types.d.ts create mode 100644 types.ts diff --git a/AGENTS.md b/AGENTS.md index 3e47283..83c3c65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,12 +8,13 @@ Do **not** add or maintain code paths for opening the overlay as **`file://`**. ## 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.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.html`](index.html) — Page shell: markup for map info, time bar, score, settings dialog; pulls `index.css` and bundled `index.js` module. +- [`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. - [`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). -- [`jsconfig.json`](jsconfig.json) — JS project roots/path so editors resolve modules and `types.d.ts`. +- [`deno.json`](deno.json) — Deno tasks and TypeScript compiler options (`build`, `serve`, `dev`). - [`images/`](images/) — Cover fallback (`unknown.svg`), characteristic icons under `images/characteristic/` (filenames match BS+ characteristic strings). - [`README.md`](README.md) — User-facing usage (Deno, OBS URL, BS+ module). - [`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 -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. diff --git a/README.md b/README.md index 9504ba8..a6070f2 100644 --- a/README.md +++ b/README.md @@ -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). +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. **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 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 diff --git a/deno.json b/deno.json index 0de2691..696221e 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,11 @@ { - "tasks": { - "serve": "deno run --allow-net --allow-read --allow-env serve.ts", - "dev": "deno run --watch --allow-net --allow-read --allow-env serve.ts" - } + "compilerOptions": { + "strict": true, + "lib": ["dom", "dom.iterable", "deno.ns", "esnext"] + }, + "tasks": { + "build": "deno bundle --platform=browser --check index.ts -o index.js", + "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" + } } diff --git a/index.html b/index.html index 88ce702..af01196 100644 --- a/index.html +++ b/index.html @@ -74,6 +74,6 @@ About This was forked from Iza's overlay - + diff --git a/index.js b/index.js index c5bba69..10ddb7c 100644 --- a/index.js +++ b/index.js @@ -1,361 +1,314 @@ -"use strict"; - -// WebSocket connection - -const beatSaberPlus = { - // https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay - url: "ws://localhost:2947/socket", - - /** @param {MessageEvent} e */ - onMessage: function(e) { - /** @type {BeatSaberPlusEvent} */ - const data = JSON.parse(e.data); - switch (data._type) { - case "event": - switch (data._event) { - case "gameState": - document.body.dataset.gameState = data.gameStateChanged; - break; - - case "mapInfo": - 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; - } - }, +// index.ts +function must(id) { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing element: ${id}`); + return element; +} +function parseJson(raw) { + return JSON.parse(raw); +} +var beatSaberPlus = { + // https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay + url: "ws://localhost:2947/socket", + onMessage: (e) => { + const data = parseJson(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; - +var provider = beatSaberPlus; +var retryMs = 1e4; +var 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; + 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; + console.log("Connection open."); + retries = 0; } - -/** @param {CloseEvent} e */ function onClose(e) { - console.log(`Connection closed. code: ${e.code}, reason: ${e.reason}, clean: ${e.wasClean}`); - setTimeout(connect, retryMs); + console.log(`Connection closed. code: ${e.code}, reason: ${e.reason}, clean: ${e.wasClean}`); + setTimeout(connect, retryMs); } - connect(); - -// Map info - -const cover = document.getElementById("coverImg"); -const title = document.getElementById("title"); -const subTitle = document.getElementById("subTitle"); -const artist = document.getElementById("artist"); -const mapper = document.getElementById("mapper"); -const difficulty = document.getElementById("difficulty"); -const characteristic = document.getElementById("characteristicImg"); -const difficultyLabel = document.getElementById("difficultyLabel"); -const type = document.getElementById("type"); -const bsrKey = document.getElementById("bsrKey"); -let timeMultiplier = 1; -let duration = 0; - -/** @param {MapInfo} data */ +var cover = must("coverImg"); +var title = must("title"); +var subTitle = must("subTitle"); +var artist = must("artist"); +var mapper = must("mapper"); +var difficulty = must("difficulty"); +var characteristic = must("characteristicImg"); +var difficultyLabel = must("difficultyLabel"); +var type = must("type"); +var bsrKey = must("bsrKey"); +var timeMultiplier = 1; +var duration = 0; async function updateMapInfo(data) { - 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 - // Find difficulty label - const diff = map.versions[0].diffs.find(d => - d.characteristic === data.characteristic && d.difficulty === data.difficulty - ); - if (diff.label) difficultyLabel.textContent = diff.label; - } finally { - document.body.classList.remove("loading"); - } - } + 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 = ""; + type.textContent = !custom ? "OST" : wip ? "WIP" : ""; + bsrKey.textContent = data.BSRKey || "???"; + timeMultiplier = data.timeMultiplier || 1; + duration = data.duration / 1e3; + 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; + const diff = map.versions[0].diffs.find((d) => 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 = document.getElementById("timeText"); -const timeBar = document.getElementById("timeBar"); -const intervalMs = 500; -let intervalId = 0; -let currentTime = 0; - -/** @param {number} time @param {boolean} paused */ +var timeText = must("timeText"); +var timeBar = must("timeBar"); +var intervalMs = 500; +var intervalId = 0; +var currentTime = 0; function updateTime(time, paused) { - if (!settings.time) return; - setTime(time); - clearInterval(intervalId); - if (paused) return; - intervalId = window.setInterval(() => setTime(currentTime + intervalMs * timeMultiplier / 1000), intervalMs); + if (!settings.time) return; + setTime(time); + clearInterval(intervalId); + if (paused) return; + intervalId = window.setInterval(() => setTime(currentTime + intervalMs * timeMultiplier / 1e3), intervalMs); } - -/** @param {number} time */ function setTime(time) { - currentTime = time; - timeText.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`; - timeBar.style.width = `${currentTime / (duration || Infinity) * 100}%`; + currentTime = time; + timeText.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`; + timeBar.style.width = `${currentTime / (duration || Infinity) * 100}%`; } - -/** @param {number} t */ function formatTime(t) { - t = Math.floor(t); - const minutes = Math.floor(t / 60); - const seconds = t - minutes * 60; - return `${minutes}:${String(seconds).padStart(2, "0")}`; + 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 = document.getElementById("accuracy"); -const mistakes = document.getElementById("mistakes"); - -/** @param {Score} score */ +var accuracy = must("accuracy"); +var mistakes = must("mistakes"); function updateScore(score) { - if (!settings.score) return; - accuracy.textContent = (score.accuracy * 100).toFixed(1); - mistakes.textContent = score.missCount ? String(score.missCount) : ""; - accuracy.classList.toggle("failed", score.currentHealth === 0); + 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 - -const settings = { - cover: true, - mapInfo: true, - time: true, - score: true, - bsr: false, - debug: false, - right: false, - bottom: true, - scale: 1, - fade: 300, +var 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"); - +var defaults = structuredClone(settings); +var style = document.createElement("style"); function loadSettings() { - const params = new URLSearchParams(location.hash.slice(1)); - let css = ""; - for (const [key, def] of Object.entries(defaults)) { - const value = JSON.parse(params.get(key) || "null") ?? def; - settings[key] = value; - if (typeof def === "boolean") document.body.classList.toggle(key, value); - else css += `--${key}: ${value}; `; - } - style.textContent = `:root { ${css}}`; + const params = new URLSearchParams(location.hash.slice(1)); + let css = ""; + for (const [key, def] of Object.entries(defaults)) { + const value = parseJson(params.get(key) || "null") ?? def; + settings[key] = value; + if (typeof def === "boolean") document.body.classList.toggle(key, Boolean(value)); + else css += `--${key}: ${value}; `; + } + style.textContent = `:root { ${css}}`; } - function saveSettings() { - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(settings)) - if (value !== defaults[key]) params.set(key, JSON.stringify(value)); - location.replace("#" + params.toString()); + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(settings)) { + if (value !== defaults[key]) params.set(key, JSON.stringify(value)); + } + location.replace(`#${params.toString()}`); } - window.onhashchange = loadSettings; loadSettings(); document.head.appendChild(style); - -// Settings UI - -for (const key of ["cover", "mapInfo", "time", "score", "bsr", "debug"]) { - const input = document.getElementById(`${key}Input`); - input.checked = settings[key]; - input.oninput = () => { - settings[key] = input.checked; - saveSettings(); - if (key === "debug") loadRequestQueue(); - }; +for (const key of [ + "cover", + "mapInfo", + "time", + "score", + "bsr", + "debug" +]) { + const input = must(`${key}Input`); + input.checked = settings[key]; + input.oninput = () => { + settings[key] = input.checked; + saveSettings(); + if (key === "debug") void loadRequestQueue(); + }; } - -const scale = document.getElementById("scaleInput"); +var scale = must("scaleInput"); scale.valueAsNumber = settings.scale * 100; scale.oninput = () => { - settings.scale = scale.valueAsNumber / 100; - saveSettings(); + settings.scale = scale.valueAsNumber / 100; + saveSettings(); }; - -const position = document.getElementById("positionInput"); -position.value = JSON.stringify([settings.right, settings.bottom]); +var position = must("positionInput"); +position.value = JSON.stringify([ + settings.right, + settings.bottom +]); position.onchange = () => { - [settings.right, settings.bottom] = JSON.parse(position.value); - saveSettings(); + [settings.right, settings.bottom] = parseJson(position.value); + saveSettings(); }; - -const fade = document.getElementById("fadeInput"); +var fade = must("fadeInput"); fade.valueAsNumber = settings.fade; fade.oninput = () => { - settings.fade = fade.valueAsNumber; - saveSettings(); + settings.fade = fade.valueAsNumber; + saveSettings(); }; - -document.documentElement.onclick = e => document.body.classList.toggle("preview"); -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(); - +document.documentElement.onclick = () => document.body.classList.toggle("preview"); +must("settings").onclick = (e) => e.stopPropagation(); +var MAX_REQUESTS = 10; +var REQUEST_POLL_MS = 5e3; +var requestListEl = must("requestList"); +var requestOverlayEl = must("requestOverlay"); +var requestEmptyEl = must("requestEmpty"); +var requestTitleCache = /* @__PURE__ */ new Map(); function useRequestHistorySim() { - return settings.debug || new URLSearchParams(location.search).get("debug") === "1"; + 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"]; + 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(); - }); + 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)); + 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 || ""; + 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 - } + 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 { + } } - -/** @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); - } + 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 { - /** @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([]); - } + 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([]); + } } - -loadRequestQueue(); -window.setInterval(loadRequestQueue, REQUEST_POLL_MS); +void loadRequestQueue(); +window.setInterval(() => void loadRequestQueue(), REQUEST_POLL_MS); diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..5bad73a --- /dev/null +++ b/index.ts @@ -0,0 +1,367 @@ +import type { + BeatSaberPlusEvent, + ChatRequestEntry, + ChatRequestPayload, + MapInfo, + Score, +} from "./types.ts"; + +function must(id: string): T { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing element: ${id}`); + return element as T; +} + +function parseJson(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) => { + const data = parseJson(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("coverImg"); +const title = must("title"); +const subTitle = must("subTitle"); +const artist = must("artist"); +const mapper = must("mapper"); +const difficulty = must("difficulty"); +const characteristic = must("characteristicImg"); +const difficultyLabel = must("difficultyLabel"); +const type = must("type"); +const bsrKey = must("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("timeText"); +const timeBar = must("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("accuracy"); +const mistakes = must("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(params.get(key) || "null") ?? def); + (settings as Record)[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(`${key}Input`); + input.checked = settings[key]; + input.oninput = () => { + settings[key] = input.checked; + saveSettings(); + if (key === "debug") void loadRequestQueue(); + }; +} + +const scale = must("scaleInput"); +scale.valueAsNumber = settings.scale * 100; +scale.oninput = () => { + settings.scale = scale.valueAsNumber / 100; + saveSettings(); +}; + +const position = must("positionInput"); +position.value = JSON.stringify([settings.right, settings.bottom]); +position.onchange = () => { + [settings.right, settings.bottom] = parseJson<[boolean, boolean]>(position.value); + saveSettings(); +}; + +const fade = must("fadeInput"); +fade.valueAsNumber = settings.fade; +fade.oninput = () => { + settings.fade = fade.valueAsNumber; + saveSettings(); +}; + +document.documentElement.onclick = () => document.body.classList.toggle("preview"); +must("settings").onclick = (e: 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("requestList"); +const requestOverlayEl = must("requestOverlay"); +const requestEmptyEl = must("requestEmpty"); +const requestTitleCache = new Map(); + +function useRequestHistorySim() { + return settings.debug || new URLSearchParams(location.search).get("debug") === "1"; +} + +function requestJsonFilenames() { + const explicit = new URLSearchParams(location.search).get("requests"); + if (explicit) return [explicit]; + if (useRequestHistorySim()) return ["ChatRequest.json", "database.json"]; + return ["ChatRequest.json"]; +} + +function loadJsonNextToPage(fileName: 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; + }); +} + +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); diff --git a/jsconfig.json b/jsconfig.json deleted file mode 100644 index 8ea6492..0000000 --- a/jsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "compilerOptions": { - "checkJs": true, - "target": "es2021", - "noImplicitAny": false - } -} diff --git a/types.d.ts b/types.d.ts deleted file mode 100644 index cd2099a..0000000 --- a/types.d.ts +++ /dev/null @@ -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; -} diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..3a90534 --- /dev/null +++ b/types.ts @@ -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[]; +}