Minor reorganization of files
This commit is contained in:
@@ -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);
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { join } from "jsr:@std/path";
|
||||
import { serveDir } from "jsr:@std/http/file-server";
|
||||
|
||||
const scriptDir = import.meta.dirname ?? ".";
|
||||
const root = join(scriptDir, "..", "..");
|
||||
const port = Number(Deno.env.get("PORT") ?? "8080");
|
||||
|
||||
function trimPathLine(line: string): string {
|
||||
let s = line.trim();
|
||||
if (!s || s.startsWith("#")) return "";
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
s = s.slice(1, -1);
|
||||
}
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
/** Optional one-line file in repo root: absolute path to `ChatRequest/Database.json`. */
|
||||
function readOptionalPathFile(): string | undefined {
|
||||
try {
|
||||
const pathFile = join(root, "chat-request-database.path");
|
||||
const raw = Deno.readTextFileSync(pathFile);
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const p = trimPathLine(line);
|
||||
if (p) return p;
|
||||
}
|
||||
} catch {
|
||||
// missing or unreadable
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const chatRequestDatabase =
|
||||
Deno.env.get("CHAT_REQUEST_DATABASE")?.trim() || readOptionalPathFile();
|
||||
|
||||
function isChatRequestFilename(pathname: string): boolean {
|
||||
const base = pathname.split("/").pop() ?? "";
|
||||
return base === "ChatRequest.json" || base === "database.json";
|
||||
}
|
||||
|
||||
Deno.serve({ port, hostname: "127.0.0.1" }, async (req) => {
|
||||
const url = new URL(req.url);
|
||||
if (req.method === "GET" && chatRequestDatabase && isChatRequestFilename(url.pathname)) {
|
||||
try {
|
||||
let text = await Deno.readTextFile(chatRequestDatabase);
|
||||
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
|
||||
return new Response(text, {
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Deno.errors.NotFound) {
|
||||
console.error(`CHAT_REQUEST_DATABASE not found: ${chatRequestDatabase}`);
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`CHAT_REQUEST_DATABASE read error (${chatRequestDatabase}): ${msg}`);
|
||||
return new Response(`Failed to read CHAT_REQUEST_DATABASE: ${msg}\n`, { status: 500 });
|
||||
}
|
||||
}
|
||||
return serveDir(req, { fsRoot: root, showDirListing: false });
|
||||
});
|
||||
|
||||
console.log(`Overlay: http://127.0.0.1:${port}/index.html`);
|
||||
if (chatRequestDatabase) {
|
||||
console.log(`Chat request database file: ${chatRequestDatabase}`);
|
||||
} else {
|
||||
console.warn(
|
||||
"No database path: set CHAT_REQUEST_DATABASE or create chat-request-database.path (see README). " +
|
||||
"Otherwise /ChatRequest.json is only served from this folder if the file exists.",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user