Add settings UI, song time, accuracy, mistakes

This commit is contained in:
Isaiah Billingsley 2026-03-05 17:03:19 -05:00
parent 0d22d036f7
commit e3b376b9a8
10 changed files with 331 additions and 62 deletions

View File

@ -2,28 +2,29 @@
Simple Beat Saber stream overlay for [twitch.tv/iza_k](https://www.twitch.tv/iza_k) Simple Beat Saber stream overlay for [twitch.tv/iza_k](https://www.twitch.tv/iza_k)
Requires [BeatSaberPlus](https://github.com/hardcpp/BeatSaberPlus) SongOverlay Requires [BeatSaberPlus](https://github.com/hardcpp/BeatSaberPlus)
### Preview ### Preview
<img width="606" height="96" alt="Screenshot" src="https://github.com/user-attachments/assets/96b8e7ce-66ed-4327-a915-ee309d2296c3" />
## Usage - OBS Studio ![](images/screenshots/preview.png)
Add Source > Browser > URL: `https://bs-overlay.netlify.app/`
## Usage
1. Go to [bs-overlay.netlify.app](https://bs-overlay.netlify.app/)
2. Click anywhere on page to show settings
![](images/screenshots/settings.png)
3. Copy URL
4. OBS Studio: Add Source > Browser > paste URL
5. BeatSaber+ settings, enable the Song Overlay module
### Advanced
[Download](https://github.com/ibillingsley/BeatSaber-Overlay/archive/refs/heads/main.zip) the source code to use the overlay locally without hosting it online.
Note: in OBS browser source, use URL `file:///C:/path-to-overlay.../index.html` instead of "Local file" so that URL parameters work.
You can further customize the overlay with Custom CSS. Example:
### Configuration - Custom CSS
To change size, set root font size (default 10px)
```css ```css
:root { font-size: 15px; } body { font-family: "Comic Sans MS"; }
```
To right align
```css
body > .row { flex-direction: row-reverse; } .row { justify-content: flex-end; }
```
To change fade duration (default 300ms)
```css
body { transition-duration: 500ms; }
```
To change font
```css
body { font-family: "Comic Sans MS", cursive; }
``` ```

BIN
fonts/montserrat-600.woff2 Normal file

Binary file not shown.

BIN
fonts/montserrat-700.woff2 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

187
index.css
View File

@ -1,27 +1,44 @@
@font-face { @font-face {
font-family: "Montserrat"; font-family: "Montserrat";
font-display: swap; font-display: swap;
font-weight: 600 700; font-weight: 600;
src: url("fonts/montserrat.woff2") format("woff2"); src: url("fonts/montserrat-600.woff2") format("woff2");
}
@font-face {
font-family: "Montserrat";
font-display: swap;
font-weight: 700;
src: url("fonts/montserrat-700.woff2") format("woff2");
}
* {
box-sizing: border-box;
} }
html { html {
font-size: 10px; font-size: calc(var(--scale, 1) * 10px);
} }
body { body {
display: flex;
flex-direction: column;
gap: 0.5rem;
height: 100vh;
margin: 0; margin: 0;
padding: 1.4rem 1.6rem; padding: 1.4rem 1.6rem;
overflow: hidden; overflow: hidden;
font-family: "Montserrat", sans-serif; font-family: "Montserrat", sans-serif;
font-size: 1.5rem; font-size: 1.6rem;
font-weight: 600; font-weight: 600;
line-height: 1.2; line-height: 1.2;
white-space: nowrap; white-space: nowrap;
background: rgba(0, 0, 0, 0); background: rgba(0, 0, 0, 0);
color: white; color: white;
filter: drop-shadow(0 0 0.1rem black) drop-shadow(0 0 0.1rem black); filter: drop-shadow(0 0 0.1rem black) drop-shadow(0 0 0.1rem black);
transition: opacity 300ms ease-out; transition-property: opacity;
transition-timing-function: ease-out;
transition-duration: calc(var(--fade, 300) * 1ms);
} }
body.loading, body.loading,
@ -29,12 +46,27 @@ body:not([data-game-state="Playing"], .preview) {
opacity: 0; opacity: 0;
} }
span:empty {
display: none;
}
.row { .row {
display: flex; display: flex;
gap: 1.6rem; gap: 1.6rem;
align-items: baseline;
} }
.column { /* Map info */
#coverImg {
width: 6.8rem;
height: 6.8rem;
border-radius: 0.6rem;
flex-shrink: 0;
align-self: flex-start;
}
#mapInfo {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
@ -43,25 +75,13 @@ body:not([data-game-state="Playing"], .preview) {
overflow: hidden; overflow: hidden;
} }
.column * { #mapInfo .row {
overflow: hidden;
text-overflow: ellipsis;
}
.column .row {
align-items: baseline;
gap: 0.9rem; gap: 0.9rem;
} }
.column span:empty { #mapInfo * {
display: none; overflow: hidden;
} text-overflow: ellipsis;
#coverImg {
width: 6.8rem;
height: 6.8rem;
border-radius: 0.6rem;
flex-shrink: 0;
} }
#title { #title {
@ -69,19 +89,13 @@ body:not([data-game-state="Playing"], .preview) {
font-weight: 700; font-weight: 700;
} }
#subTitle, #mapper::before,
#artist, #difficultyLabel::before {
#mapper {
font-size: 1.6rem;
}
#mapper:before,
#difficultyLabel:before {
content: ""; content: "";
} }
#mapper:after, #mapper::after,
#difficultyLabel:after { #difficultyLabel::after {
content: ""; content: "";
} }
@ -90,16 +104,20 @@ body:not([data-game-state="Playing"], .preview) {
flex-shrink: 99999; flex-shrink: 99999;
} }
#difficulty,
#difficultyLabel {
font-size: 1.5rem;
}
#characteristicImg { #characteristicImg {
width: 1.1em; width: 1.7rem;
height: 1.1em; height: 1.7rem;
flex-shrink: 0; flex-shrink: 0;
align-self: center; align-self: center;
} }
#type, #type,
#bsrKey { #bsrKey {
font-size: 1.6rem;
font-weight: 700; font-weight: 700;
flex-shrink: 0; flex-shrink: 0;
} }
@ -108,7 +126,7 @@ body:not([data-game-state="Playing"], .preview) {
letter-spacing: 0.2rem; letter-spacing: 0.2rem;
} }
#bsrKey:before { #bsrKey::before {
content: "!bsr "; content: "!bsr ";
letter-spacing: normal; letter-spacing: normal;
} }
@ -116,3 +134,100 @@ body:not([data-game-state="Playing"], .preview) {
#type:not(:empty) + #bsrKey { #type:not(:empty) + #bsrKey {
display: none; display: none;
} }
/* Song time */
#time {
align-self: flex-start;
width: 6.8rem;
font-size: 1.35rem;
text-align: right;
line-height: 1.4;
letter-spacing: -0.05em;
}
#time,
#accuracy,
#mistakes {
font-feature-settings: "tnum";
font-variant-numeric: tabular-nums;
}
/* Score */
#score {
display: contents;
}
#accuracy {
font-size: 2.4rem;
font-weight: 700;
}
#accuracy::after {
display: inline-block;
content: "%";
font-size: 1.8rem;
}
#accuracy.failed {
text-decoration: line-through red;
}
#mistakes {
display: inline;
font-size: 1.8rem;
}
#mistakes::after {
content: "";
}
#mistakes:empty::after {
content: "FC";
}
/* Settings */
body:not(.cover) #coverImg,
body:not(.mapInfo) #mapInfo,
body:not(.time) #time,
body:not(.score) #score {
display: none;
}
body.right > .row {
flex-direction: row-reverse;
}
body.right #mapInfo .row {
justify-content: flex-end;
}
body.bottom {
flex-direction: column-reverse;
}
body.bottom #time {
align-self: flex-end;
}
#settings {
display: none;
position: absolute;
top: 50%;
transform: translateY(-50%);
flex-direction: column;
gap: 2px;
font-family: system-ui;
font-size: 16px;
}
.preview #settings {
display: flex;
}
#settings label * {
float: right;
margin-left: 1em;
}

View File

@ -8,7 +8,7 @@
<body> <body>
<div class="row"> <div class="row">
<img id="coverImg" src="images/unknown.svg"> <img id="coverImg" src="images/unknown.svg">
<div class="column"> <div id="mapInfo">
<div class="row"> <div class="row">
<span id="title">Title</span> <span id="title">Title</span>
<span id="subTitle">Subtitle</span> <span id="subTitle">Subtitle</span>
@ -26,6 +26,31 @@
</div> </div>
</div> </div>
</div> </div>
<div class="row">
<div id="time">1:23 / 4:56</div>
<div id="score">
<span id="accuracy">96.9</span>
<span id="mistakes">7</span>
</div>
</div>
<dialog id="settings">
<strong>Settings</strong>
<label>Show cover: <input id="coverInput" type="checkbox"></label>
<label>Show map info: <input id="mapInfoInput" type="checkbox"></label>
<label>Show time: <input id="timeInput" type="checkbox"></label>
<label>Show score: <input id="scoreInput" type="checkbox"></label>
<label>Position: <select id="positionInput">
<option value="[false,false]">Top left</option>
<option value="[true,false]">Top right</option>
<option value="[false,true]">Bottom left</option>
<option value="[true,true]">Bottom right</option>
</select></label>
<label>Scale (%): <input id="scaleInput" type="number" min="10" max="1000" step="5"></label>
<label>Fade (ms): <input id="fadeInput" type="number" min="0" max="5000" step="10"></label>
<br>
<strong>About</strong>
<a href="https://github.com/ibillingsley/BeatSaber-Overlay" target="_blank">Source code</a>
</dialog>
<script src="index.js"></script> <script src="index.js"></script>
</body> </body>
</html> </html>

139
index.js
View File

@ -1,6 +1,6 @@
"use strict"; "use strict";
// Data providers // WebSocket connection
const beatSaberPlus = { const beatSaberPlus = {
// https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay // https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay
@ -20,6 +20,18 @@ const beatSaberPlus = {
case "mapInfo": case "mapInfo":
updateMapInfo(data.mapInfoChanged); updateMapInfo(data.mapInfoChanged);
break; break;
case "pause":
updateTime(data.pauseTime, true);
break;
case "resume":
updateTime(data.resumeTime, false);
break;
case "score":
updateScore(data.scoreEvent);
break;
} }
break; break;
@ -30,8 +42,6 @@ const beatSaberPlus = {
}, },
}; };
// WebSocket connection
const provider = beatSaberPlus; const provider = beatSaberPlus;
const retryMs = 10000; const retryMs = 10000;
let retries = 0; let retries = 0;
@ -55,6 +65,8 @@ function onClose(e) {
setTimeout(connect, retryMs); setTimeout(connect, retryMs);
} }
connect();
// Map info // Map info
const cover = document.getElementById("coverImg"); const cover = document.getElementById("coverImg");
@ -67,12 +79,14 @@ const characteristic = document.getElementById("characteristicImg");
const difficultyLabel = document.getElementById("difficultyLabel"); const difficultyLabel = document.getElementById("difficultyLabel");
const type = document.getElementById("type"); const type = document.getElementById("type");
const bsrKey = document.getElementById("bsrKey"); const bsrKey = document.getElementById("bsrKey");
let timeMultiplier = 1;
let duration = 0;
/** @param {MapInfoChanged} data */ /** @param {MapInfoChanged} 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");
cover.src = data.coverRaw ? `data:image/jpeg;base64,${data.coverRaw}` : "images/unknown.svg"; cover.src = data.coverRaw ? `data:image/png;base64,${data.coverRaw}` : "images/unknown.svg";
title.textContent = data.name || ""; title.textContent = data.name || "";
subTitle.textContent = data.sub_name || ""; subTitle.textContent = data.sub_name || "";
artist.textContent = data.artist || ""; artist.textContent = data.artist || "";
@ -82,6 +96,8 @@ async function updateMapInfo(data) {
difficultyLabel.textContent = ""; // BS+ does not provide label difficultyLabel.textContent = ""; // BS+ does not provide label
type.textContent = !custom ? "OST" : wip ? "WIP" : ""; type.textContent = !custom ? "OST" : wip ? "WIP" : "";
bsrKey.textContent = data.BSRKey || "???"; // Always empty? bsrKey.textContent = data.BSRKey || "???"; // Always empty?
timeMultiplier = data.timeMultiplier || 1;
duration = data.duration / 1000;
// Fetch extra info from BeatSaver // Fetch extra info from BeatSaver
if (custom && !wip) { if (custom && !wip) {
@ -103,6 +119,117 @@ async function updateMapInfo(data) {
} }
} }
connect(); // Song time
document.documentElement.onclick = () => document.body.classList.toggle("preview"); const timeText = document.getElementById("time");
const intervalMs = 500;
let intervalId = 0;
let currentTime = 0;
/** @param {number} time @param {boolean} paused */
function updateTime(time, paused) {
if (!settings.time) return;
currentTime = time;
timeText.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`;
clearInterval(intervalId);
if (paused) return;
intervalId = window.setInterval(() => {
currentTime += intervalMs * timeMultiplier / 1000;
timeText.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`;
}, intervalMs);
}
/** @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")}`;
}
// Score
const accuracy = document.getElementById("accuracy");
const mistakes = document.getElementById("mistakes");
/** @param {Score} score */
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);
}
// Settings
const settings = {
cover: true,
mapInfo: true,
time: true,
score: true,
right: false,
bottom: false,
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 of Object.keys(settings)) {
const value = JSON.parse(params.get(key) || "null") ?? defaults[key];
settings[key] = value;
if (typeof value === "boolean") document.body.classList.toggle(key, 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());
}
window.onhashchange = loadSettings;
loadSettings();
document.head.appendChild(style);
// Settings UI
for (const key of ["cover", "mapInfo", "time", "score"]) {
const input = document.getElementById(`${key}Input`);
input.checked = settings[key];
input.oninput = () => {
settings[key] = input.checked;
saveSettings();
};
}
const scale = document.getElementById("scaleInput");
scale.valueAsNumber = settings.scale * 100;
scale.oninput = () => {
settings.scale = scale.valueAsNumber / 100;
saveSettings();
};
const position = document.getElementById("positionInput");
position.value = JSON.stringify([settings.right, settings.bottom]);
position.onchange = () => {
[settings.right, settings.bottom] = JSON.parse(position.value);
saveSettings();
};
const fade = document.getElementById("fadeInput");
fade.valueAsNumber = settings.fade;
fade.oninput = () => {
settings.fade = fade.valueAsNumber;
saveSettings();
};
document.documentElement.onclick = e => document.body.classList.toggle("preview");
document.getElementById("settings").onclick = e => e.stopPropagation();

1
types.d.ts vendored
View File

@ -62,6 +62,7 @@ interface ScoreEvent {
type BeatSaberPlusEvent = HandshakeEvent | GameStateEvent | ResumeEvent | PauseEvent | MapInfoChangedEvent | ScoreEvent; type BeatSaberPlusEvent = HandshakeEvent | GameStateEvent | ResumeEvent | PauseEvent | MapInfoChangedEvent | ScoreEvent;
type MapInfoChanged = MapInfoChangedEvent["mapInfoChanged"]; type MapInfoChanged = MapInfoChangedEvent["mapInfoChanged"];
type Score = ScoreEvent["scoreEvent"];
interface Document { interface Document {
// Assume non-null // Assume non-null