653 lines
21 KiB
TypeScript
653 lines
21 KiB
TypeScript
import type {
|
|
BeatLeaderFollower,
|
|
BeatLeaderLeaderboard,
|
|
BeatLeaderScore,
|
|
BeatSaberPlusEvent,
|
|
ChatRequestEntry,
|
|
ChatRequestPayload,
|
|
FriendMode,
|
|
MapInfo,
|
|
OverlaySettings,
|
|
Score,
|
|
} from "./types.ts";
|
|
import { OVERLAY_SETTINGS_INITIAL } from "./types.ts";
|
|
import type { BeatSaverMap } from "./beatsaver.ts";
|
|
import { fetchBeatSaverMapById, fetchBeatSaverMeta } from "./beatsaver.ts";
|
|
import {
|
|
fetchAllMapScoresByHash,
|
|
fetchBLLeaderboardsByHash,
|
|
fetchFriends,
|
|
normalizeAccuracy,
|
|
} from "./beatleader.ts";
|
|
import { mergeOverlayConfigResponse, type OverlayConfigApiBody } from "./overlay-config.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;
|
|
}
|
|
|
|
type Settings = OverlaySettings;
|
|
|
|
const settings: Settings = structuredClone(OVERLAY_SETTINGS_INITIAL);
|
|
|
|
const defaults = structuredClone(OVERLAY_SETTINGS_INITIAL);
|
|
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()}`);
|
|
}
|
|
|
|
// 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;
|
|
case "handshake":
|
|
currentPlayerPlatformId = data.playerPlatformId || "";
|
|
void refreshMapFriendScores();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
},
|
|
};
|
|
|
|
const provider = beatSaberPlus;
|
|
const retryMs = 10000;
|
|
let currentPlayerPlatformId = "";
|
|
|
|
function getEffectivePlayerId() {
|
|
const configured = settings.beatLeaderId.trim();
|
|
const raw = configured || currentPlayerPlatformId;
|
|
if (!raw) return "";
|
|
const steamIdCandidate = raw.match(/\d{17,20}/)?.[0];
|
|
if (steamIdCandidate) return steamIdCandidate;
|
|
if (/^\d+$/.test(raw)) return raw;
|
|
if (configured) return raw;
|
|
return "";
|
|
}
|
|
|
|
let currentMapHash = "";
|
|
/** Cached BeatLeader following/followers/mutual list; refetch only when player id or friend mode changes. */
|
|
let friendsRelationCacheKey = "";
|
|
let friendsRelationCache: BeatLeaderFollower[] | null = null;
|
|
let friendScoreRequestId = 0;
|
|
let mapInfoRequestId = 0;
|
|
/** Hex hash from BS+ `level_id` (before BeatSaver version hash). */
|
|
let rawLevelHash = "";
|
|
|
|
function beatLeaderboardId(lb: BeatLeaderLeaderboard): string {
|
|
const id = lb.id ?? lb.leaderboardId;
|
|
return id == null ? "" : String(id);
|
|
}
|
|
|
|
function resolvedHashFromBeatSaverMap(map: BeatSaverMap, fallback: string): string {
|
|
const v = map.versions?.[0]?.hash;
|
|
if (typeof v === "string" && v.length > 0) return v.toLowerCase().trim();
|
|
return fallback;
|
|
}
|
|
|
|
/** BeatLeader indexes maps by 40-char hex hash; BeatSaver accepts the same hash or a short map key (`/maps/id/…`). */
|
|
function looksLikeBeatSaverHash(s: string): boolean {
|
|
return /^[0-9a-f]{40}$/i.test(s.trim());
|
|
}
|
|
|
|
async function fetchBeatSaverMapForDebug(id: string): Promise<BeatSaverMap | null> {
|
|
const t = id.trim();
|
|
if (!t) return null;
|
|
if (looksLikeBeatSaverHash(t)) return fetchBeatSaverMeta(t.toLowerCase());
|
|
return fetchBeatSaverMapById(t);
|
|
}
|
|
|
|
async function applyDebugSong() {
|
|
const raw = settings.debugSongId.trim();
|
|
if (!raw) return;
|
|
const reqId = ++mapInfoRequestId;
|
|
document.body.classList.add("loading");
|
|
try {
|
|
const map = await fetchBeatSaverMapForDebug(raw);
|
|
if (reqId !== mapInfoRequestId) return;
|
|
if (!map?.id) {
|
|
rawLevelHash = "";
|
|
currentMapHash = "";
|
|
cover.src = "images/unknown.svg";
|
|
title.textContent = "BeatSaver not found";
|
|
subTitle.textContent = raw;
|
|
artist.textContent = "";
|
|
mapper.textContent = "";
|
|
difficulty.textContent = "";
|
|
characteristic.src = "images/characteristic/Standard.svg";
|
|
difficultyLabel.textContent = "";
|
|
type.textContent = "";
|
|
bsrKey.textContent = "";
|
|
return;
|
|
}
|
|
const fallbackHash = looksLikeBeatSaverHash(raw) ? raw.toLowerCase().trim() : "";
|
|
const resolved = resolvedHashFromBeatSaverMap(map, fallbackHash);
|
|
rawLevelHash = resolved || fallbackHash;
|
|
currentMapHash = resolved || fallbackHash;
|
|
|
|
const v0 = map.versions?.[0];
|
|
const coverUrl = v0?.coverURL?.trim();
|
|
cover.src = coverUrl || "images/unknown.svg";
|
|
title.textContent = map.metadata?.songName ?? map.name ?? "";
|
|
subTitle.textContent = map.metadata?.songSubName ?? "";
|
|
artist.textContent = map.metadata?.songAuthorName ?? "";
|
|
mapper.textContent = map.metadata?.levelAuthorName ?? "";
|
|
const firstDiff = v0?.diffs?.[0];
|
|
difficulty.textContent = firstDiff?.difficulty?.replace("Plus", " +") ?? "—";
|
|
characteristic.src = firstDiff
|
|
? `images/characteristic/${firstDiff.characteristic}.svg`
|
|
: "images/characteristic/Standard.svg";
|
|
difficultyLabel.textContent = firstDiff?.label ?? "";
|
|
type.textContent = "Custom";
|
|
bsrKey.textContent = map.id;
|
|
timeMultiplier = 1;
|
|
duration = 180;
|
|
if (reqId === mapInfoRequestId) {
|
|
setTime(0);
|
|
}
|
|
} catch {
|
|
if (reqId !== mapInfoRequestId) return;
|
|
rawLevelHash = "";
|
|
currentMapHash = "";
|
|
cover.src = "images/unknown.svg";
|
|
title.textContent = "BeatSaver request failed";
|
|
subTitle.textContent = raw;
|
|
} finally {
|
|
if (reqId === mapInfoRequestId) document.body.classList.remove("loading");
|
|
if (reqId === mapInfoRequestId) void refreshMapFriendScores();
|
|
}
|
|
}
|
|
|
|
function connect() {
|
|
const ws = new WebSocket(provider.url);
|
|
ws.onmessage = provider.onMessage;
|
|
ws.onclose = onClose;
|
|
}
|
|
|
|
function onClose(_e: CloseEvent) {
|
|
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) {
|
|
if (settings.debugSongId.trim()) {
|
|
void applyDebugSong();
|
|
return;
|
|
}
|
|
const reqId = ++mapInfoRequestId;
|
|
const custom = data.level_id.startsWith("custom_level_");
|
|
const wip = custom && data.level_id.endsWith("WIP");
|
|
rawLevelHash = custom ? data.level_id.substring(13, 53).toLowerCase() : "";
|
|
currentMapHash = rawLevelHash;
|
|
|
|
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 = custom && !wip ? "…" : custom ? rawLevelHash || "???" : "???";
|
|
timeMultiplier = data.timeMultiplier || 1;
|
|
duration = data.duration / 1000;
|
|
|
|
if (custom && !wip) {
|
|
document.body.classList.add("loading");
|
|
try {
|
|
const map = await fetchBeatSaverMeta(rawLevelHash);
|
|
if (reqId !== mapInfoRequestId) return;
|
|
if (!map?.id) {
|
|
currentMapHash = rawLevelHash;
|
|
} else {
|
|
const resolved = resolvedHashFromBeatSaverMap(map, rawLevelHash);
|
|
currentMapHash = resolved;
|
|
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;
|
|
}
|
|
} catch {
|
|
if (reqId !== mapInfoRequestId) return;
|
|
currentMapHash = rawLevelHash;
|
|
} finally {
|
|
document.body.classList.remove("loading");
|
|
if (reqId === mapInfoRequestId) void refreshMapFriendScores();
|
|
}
|
|
} else {
|
|
if (custom && wip) {
|
|
bsrKey.textContent = rawLevelHash || "???";
|
|
} else {
|
|
bsrKey.textContent = "???";
|
|
}
|
|
difficultyLabel.textContent = "";
|
|
void refreshMapFriendScores();
|
|
}
|
|
}
|
|
|
|
// 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");
|
|
const friendScoresPanel = must<HTMLElement>("friendScores");
|
|
const friendScoresList = must<HTMLOListElement>("friendScoresList");
|
|
const friendScoresEmpty = must<HTMLElement>("friendScoresEmpty");
|
|
const friendScoresHeaderText = must<HTMLElement>("friendScoresHeaderText");
|
|
const friendScoresHeaderImg = must<HTMLImageElement>("friendScoresHeaderImg");
|
|
|
|
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);
|
|
}
|
|
|
|
function avatarFromScore(score: BeatLeaderScore): string | null {
|
|
if (typeof score.player === "object" && score.player?.avatar) {
|
|
return score.player.avatar;
|
|
}
|
|
const url = score.playerAvatar?.trim();
|
|
return url || null;
|
|
}
|
|
|
|
function clearFriendScores(message: string) {
|
|
friendScoresList.replaceChildren();
|
|
friendScoresEmpty.textContent = message;
|
|
friendScoresHeaderText.textContent = "frenz?";
|
|
friendScoresHeaderImg.src = "assets/notlikesteve.webp";
|
|
friendScoresPanel.classList.remove("has-items", "is-loading");
|
|
}
|
|
|
|
function renderFriendScores(items: Array<{ name: string; acc: number; avatar: string | null }>) {
|
|
friendScoresList.replaceChildren();
|
|
friendScoresPanel.classList.toggle("has-items", items.length > 0);
|
|
friendScoresPanel.classList.remove("is-loading");
|
|
friendScoresEmpty.textContent = items.length ? "" : "No friend scores on this map";
|
|
friendScoresHeaderText.textContent = items.length ? "frenz!" : "frenz?";
|
|
friendScoresHeaderImg.src = items.length ? "assets/peepohigh.webp" : "assets/notlikesteve.webp";
|
|
for (const item of items) {
|
|
const li = document.createElement("li");
|
|
li.className = "friend-score-item";
|
|
const avatar = document.createElement("img");
|
|
avatar.className = "friend-avatar";
|
|
avatar.alt = "";
|
|
avatar.decoding = "async";
|
|
avatar.loading = "lazy";
|
|
avatar.src = item.avatar?.trim() || "images/unknown.svg";
|
|
const name = document.createElement("span");
|
|
name.className = "friend-name";
|
|
name.textContent = item.name;
|
|
const acc = document.createElement("span");
|
|
acc.className = "friend-acc";
|
|
acc.textContent = `${item.acc.toFixed(2)}%`;
|
|
li.append(acc, avatar, name);
|
|
friendScoresList.appendChild(li);
|
|
}
|
|
}
|
|
|
|
function friendsRelationListKey(playerId: string): string {
|
|
return `${playerId}\0${settings.friendMode}`;
|
|
}
|
|
|
|
async function refreshMapFriendScores() {
|
|
const hash = currentMapHash;
|
|
if (!settings.friends) {
|
|
clearFriendScores("Disabled in settings");
|
|
return;
|
|
}
|
|
if (!hash) {
|
|
clearFriendScores("No map loaded");
|
|
return;
|
|
}
|
|
const playerId = getEffectivePlayerId();
|
|
if (!playerId) {
|
|
clearFriendScores("Waiting for BeatLeader player id");
|
|
return;
|
|
}
|
|
friendScoresPanel.classList.add("is-loading");
|
|
friendScoresEmpty.textContent = "Loading mutual friend scores...";
|
|
const requestId = ++friendScoreRequestId;
|
|
try {
|
|
const relKey = friendsRelationListKey(playerId);
|
|
const friendsPromise: Promise<BeatLeaderFollower[]> = (async () => {
|
|
if (friendsRelationCache !== null && relKey === friendsRelationCacheKey) {
|
|
return friendsRelationCache;
|
|
}
|
|
const fetched = await fetchFriends(playerId, settings.friendMode);
|
|
friendsRelationCacheKey = relKey;
|
|
friendsRelationCache = fetched;
|
|
return fetched;
|
|
})();
|
|
const [leaderboards, friends] = await Promise.all([
|
|
fetchBLLeaderboardsByHash(hash),
|
|
friendsPromise,
|
|
]);
|
|
if (requestId !== friendScoreRequestId) return;
|
|
if (leaderboards.length === 0) {
|
|
clearFriendScores("No BeatLeader leaderboards found");
|
|
return;
|
|
}
|
|
const friendById = new Map(friends.map((f) => [f.id, f]));
|
|
const mutualFriendIds = new Set(friends.map((f) => f.id));
|
|
if (mutualFriendIds.size === 0) {
|
|
const relationLabel = settings.friendMode === "following"
|
|
? "No followed BeatLeader players"
|
|
: settings.friendMode === "followers"
|
|
? "No BeatLeader followers"
|
|
: "No mutual BeatLeader followers";
|
|
clearFriendScores(relationLabel);
|
|
return;
|
|
}
|
|
const scores = await fetchAllMapScoresByHash(hash, leaderboards);
|
|
if (requestId !== friendScoreRequestId) return;
|
|
const bestByPlayer = new Map<string, { name: string; acc: number; avatar: string | null }>();
|
|
for (const score of scores) {
|
|
const scorePlayerId = score.playerId ?? (typeof score.player === "object" ? score.player?.id : null);
|
|
const playerKey = scorePlayerId == null ? "" : String(scorePlayerId);
|
|
if (!playerKey || !mutualFriendIds.has(playerKey)) continue;
|
|
const acc = normalizeAccuracy(score.accuracy ?? score.acc);
|
|
if (acc === null) continue;
|
|
const existing = bestByPlayer.get(playerKey);
|
|
if (!existing || acc > existing.acc) {
|
|
const friendMeta = friendById.get(playerKey);
|
|
const playerName =
|
|
score.playerName ||
|
|
(typeof score.player === "object" ? score.player?.name : typeof score.player === "string" ? score.player : null);
|
|
const fromScore = avatarFromScore(score);
|
|
const fromFriend = friendMeta?.avatar?.trim() || null;
|
|
bestByPlayer.set(playerKey, {
|
|
name: playerName || friendMeta?.name || playerKey,
|
|
acc,
|
|
avatar: fromScore ?? fromFriend,
|
|
});
|
|
}
|
|
}
|
|
const sorted = Array.from(bestByPlayer.values()).sort((a, b) => b.acc - a.acc);
|
|
renderFriendScores(sorted);
|
|
} catch {
|
|
if (requestId !== friendScoreRequestId) return;
|
|
clearFriendScores("Failed loading BeatLeader scores");
|
|
}
|
|
}
|
|
|
|
window.onhashchange = () => {
|
|
loadSettings();
|
|
const debugEl = document.getElementById("debugSongIdInput") as HTMLInputElement | null;
|
|
if (debugEl) debugEl.value = settings.debugSongId;
|
|
if (settings.debugSongId.trim()) void applyDebugSong();
|
|
else {
|
|
mapInfoRequestId += 1;
|
|
currentMapHash = "";
|
|
rawLevelHash = "";
|
|
void refreshMapFriendScores();
|
|
}
|
|
};
|
|
|
|
// Song request queue (JSON from same origin as page; poll)
|
|
|
|
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>();
|
|
const requestTitleMisses = new Set<string>();
|
|
|
|
function loadChatRequestJson() {
|
|
const base = new URL("ChatRequest.json", location.href);
|
|
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>;
|
|
});
|
|
}
|
|
|
|
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 (requestTitleMisses.has(key)) return;
|
|
if (requestTitleCache.has(key)) {
|
|
titleEl.textContent = requestTitleCache.get(key) ?? "";
|
|
return;
|
|
}
|
|
try {
|
|
const map = await fetchBeatSaverMapById(key);
|
|
if (!map) {
|
|
requestTitleMisses.add(key);
|
|
return;
|
|
}
|
|
const name = map.metadata?.songName ?? map.name;
|
|
if (name && typeof name === "string") {
|
|
requestTitleCache.set(key, name);
|
|
titleEl.textContent = name;
|
|
return;
|
|
}
|
|
requestTitleMisses.add(key);
|
|
} catch {
|
|
requestTitleMisses.add(key);
|
|
}
|
|
}
|
|
|
|
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 loadChatRequestJson();
|
|
requestEmptyEl.textContent = "No pending requests";
|
|
requestOverlayEl.classList.remove("request-load-failed");
|
|
const items = (data.queue ?? []).slice(0, MAX_REQUESTS);
|
|
renderRequestList(items);
|
|
} catch {
|
|
requestEmptyEl.textContent = "Request queue unavailable";
|
|
requestOverlayEl.classList.add("request-load-failed");
|
|
renderRequestList([]);
|
|
}
|
|
}
|
|
|
|
async function bootstrap() {
|
|
Object.assign(defaults, OVERLAY_SETTINGS_INITIAL);
|
|
try {
|
|
const url = new URL("/api/overlay-config", location.href);
|
|
const res = await fetch(url, { cache: "no-store" });
|
|
if (res.ok) {
|
|
const data = await res.json() as OverlayConfigApiBody;
|
|
mergeOverlayConfigResponse(defaults, data);
|
|
}
|
|
} catch {
|
|
// keep OVERLAY_SETTINGS_INITIAL (e.g. file:// or static hosting)
|
|
}
|
|
loadSettings();
|
|
document.head.appendChild(style);
|
|
if (settings.debugSongId.trim()) void applyDebugSong();
|
|
else void refreshMapFriendScores();
|
|
|
|
// Settings UI
|
|
|
|
for (const key of ["cover", "mapInfo", "time", "score", "friends", "bsr"] as const) {
|
|
const input = must<HTMLInputElement>(`${key}Input`);
|
|
input.checked = settings[key];
|
|
input.oninput = () => {
|
|
settings[key] = input.checked;
|
|
saveSettings();
|
|
if (key === "friends") void refreshMapFriendScores();
|
|
};
|
|
}
|
|
|
|
const friendModeInput = must<HTMLSelectElement>("friendModeInput");
|
|
friendModeInput.value = settings.friendMode;
|
|
friendModeInput.onchange = () => {
|
|
settings.friendMode = friendModeInput.value as FriendMode;
|
|
saveSettings();
|
|
void refreshMapFriendScores();
|
|
};
|
|
|
|
const beatLeaderPlayerInput = must<HTMLInputElement>("beatLeaderPlayerInput");
|
|
beatLeaderPlayerInput.value = settings.beatLeaderId;
|
|
beatLeaderPlayerInput.oninput = () => {
|
|
settings.beatLeaderId = beatLeaderPlayerInput.value.trim();
|
|
saveSettings();
|
|
void refreshMapFriendScores();
|
|
};
|
|
|
|
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();
|
|
};
|
|
|
|
const debugSongIdInput = must<HTMLInputElement>("debugSongIdInput");
|
|
debugSongIdInput.value = settings.debugSongId;
|
|
debugSongIdInput.oninput = () => {
|
|
settings.debugSongId = debugSongIdInput.value;
|
|
saveSettings();
|
|
if (!settings.debugSongId.trim()) {
|
|
mapInfoRequestId += 1;
|
|
currentMapHash = "";
|
|
rawLevelHash = "";
|
|
void refreshMapFriendScores();
|
|
} else {
|
|
void applyDebugSong();
|
|
}
|
|
};
|
|
|
|
document.documentElement.onclick = () => document.body.classList.toggle("preview");
|
|
must<HTMLElement>("settings").onclick = (e: MouseEvent) => e.stopPropagation();
|
|
|
|
void loadRequestQueue();
|
|
window.setInterval(() => void loadRequestQueue(), REQUEST_POLL_MS);
|
|
}
|
|
|
|
void bootstrap();
|