Add settings UI, song time, accuracy, mistakes
This commit is contained in:
parent
0d22d036f7
commit
e3b376b9a8
39
README.md
39
README.md
@ -2,28 +2,29 @@
|
||||
|
||||
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
|
||||
<img width="606" height="96" alt="Screenshot" src="https://github.com/user-attachments/assets/96b8e7ce-66ed-4327-a915-ee309d2296c3" />
|
||||
|
||||
## Usage - OBS Studio
|
||||
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
|
||||

|
||||
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
|
||||
:root { font-size: 15px; }
|
||||
```
|
||||
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; }
|
||||
body { font-family: "Comic Sans MS"; }
|
||||
```
|
||||
|
||||
BIN
fonts/montserrat-600.woff2
Normal file
BIN
fonts/montserrat-600.woff2
Normal file
Binary file not shown.
BIN
fonts/montserrat-700.woff2
Normal file
BIN
fonts/montserrat-700.woff2
Normal file
Binary file not shown.
Binary file not shown.
BIN
images/screenshots/preview.png
Normal file
BIN
images/screenshots/preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
images/screenshots/settings.png
Normal file
BIN
images/screenshots/settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
187
index.css
187
index.css
@ -1,27 +1,44 @@
|
||||
@font-face {
|
||||
font-family: "Montserrat";
|
||||
font-display: swap;
|
||||
font-weight: 600 700;
|
||||
src: url("fonts/montserrat.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
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 {
|
||||
font-size: 10px;
|
||||
font-size: calc(var(--scale, 1) * 10px);
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 1.4rem 1.6rem;
|
||||
overflow: hidden;
|
||||
font-family: "Montserrat", sans-serif;
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
background: rgba(0, 0, 0, 0);
|
||||
color: white;
|
||||
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,
|
||||
@ -29,12 +46,27 @@ body:not([data-game-state="Playing"], .preview) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
span:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
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;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
@ -43,25 +75,13 @@ body:not([data-game-state="Playing"], .preview) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.column * {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.column .row {
|
||||
align-items: baseline;
|
||||
#mapInfo .row {
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.column span:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#coverImg {
|
||||
width: 6.8rem;
|
||||
height: 6.8rem;
|
||||
border-radius: 0.6rem;
|
||||
flex-shrink: 0;
|
||||
#mapInfo * {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#title {
|
||||
@ -69,19 +89,13 @@ body:not([data-game-state="Playing"], .preview) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#subTitle,
|
||||
#artist,
|
||||
#mapper {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
#mapper:before,
|
||||
#difficultyLabel:before {
|
||||
#mapper::before,
|
||||
#difficultyLabel::before {
|
||||
content: "‹";
|
||||
}
|
||||
|
||||
#mapper:after,
|
||||
#difficultyLabel:after {
|
||||
#mapper::after,
|
||||
#difficultyLabel::after {
|
||||
content: "›";
|
||||
}
|
||||
|
||||
@ -90,16 +104,20 @@ body:not([data-game-state="Playing"], .preview) {
|
||||
flex-shrink: 99999;
|
||||
}
|
||||
|
||||
#difficulty,
|
||||
#difficultyLabel {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
#characteristicImg {
|
||||
width: 1.1em;
|
||||
height: 1.1em;
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
#type,
|
||||
#bsrKey {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@ -108,7 +126,7 @@ body:not([data-game-state="Playing"], .preview) {
|
||||
letter-spacing: 0.2rem;
|
||||
}
|
||||
|
||||
#bsrKey:before {
|
||||
#bsrKey::before {
|
||||
content: "!bsr ";
|
||||
letter-spacing: normal;
|
||||
}
|
||||
@ -116,3 +134,100 @@ body:not([data-game-state="Playing"], .preview) {
|
||||
#type:not(:empty) + #bsrKey {
|
||||
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;
|
||||
}
|
||||
|
||||
27
index.html
27
index.html
@ -8,7 +8,7 @@
|
||||
<body>
|
||||
<div class="row">
|
||||
<img id="coverImg" src="images/unknown.svg">
|
||||
<div class="column">
|
||||
<div id="mapInfo">
|
||||
<div class="row">
|
||||
<span id="title">Title</span>
|
||||
<span id="subTitle">Subtitle</span>
|
||||
@ -26,6 +26,31 @@
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
139
index.js
139
index.js
@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
// Data providers
|
||||
// WebSocket connection
|
||||
|
||||
const beatSaberPlus = {
|
||||
// https://github.com/hardcpp/BeatSaberPlus/wiki/%5BEN%5D-Song-Overlay
|
||||
@ -20,6 +20,18 @@ const beatSaberPlus = {
|
||||
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;
|
||||
|
||||
@ -30,8 +42,6 @@ const beatSaberPlus = {
|
||||
},
|
||||
};
|
||||
|
||||
// WebSocket connection
|
||||
|
||||
const provider = beatSaberPlus;
|
||||
const retryMs = 10000;
|
||||
let retries = 0;
|
||||
@ -55,6 +65,8 @@ function onClose(e) {
|
||||
setTimeout(connect, retryMs);
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
// Map info
|
||||
|
||||
const cover = document.getElementById("coverImg");
|
||||
@ -67,12 +79,14 @@ 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 {MapInfoChanged} data */
|
||||
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";
|
||||
cover.src = data.coverRaw ? `data:image/png;base64,${data.coverRaw}` : "images/unknown.svg";
|
||||
title.textContent = data.name || "";
|
||||
subTitle.textContent = data.sub_name || "";
|
||||
artist.textContent = data.artist || "";
|
||||
@ -82,6 +96,8 @@ async function updateMapInfo(data) {
|
||||
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) {
|
||||
@ -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
1
types.d.ts
vendored
@ -62,6 +62,7 @@ interface ScoreEvent {
|
||||
|
||||
type BeatSaberPlusEvent = HandshakeEvent | GameStateEvent | ResumeEvent | PauseEvent | MapInfoChangedEvent | ScoreEvent;
|
||||
type MapInfoChanged = MapInfoChangedEvent["mapInfoChanged"];
|
||||
type Score = ScoreEvent["scoreEvent"];
|
||||
|
||||
interface Document {
|
||||
// Assume non-null
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user