diff --git a/README.md b/README.md index 5c12354..f824964 100644 --- a/README.md +++ b/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 -Screenshot -## Usage - OBS Studio -Add Source > Browser > URL: `https://bs-overlay.netlify.app/` +![](images/screenshots/preview.png) + +## 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 -: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"; } ``` diff --git a/fonts/montserrat-600.woff2 b/fonts/montserrat-600.woff2 new file mode 100644 index 0000000..b8d0df8 Binary files /dev/null and b/fonts/montserrat-600.woff2 differ diff --git a/fonts/montserrat-700.woff2 b/fonts/montserrat-700.woff2 new file mode 100644 index 0000000..b180294 Binary files /dev/null and b/fonts/montserrat-700.woff2 differ diff --git a/fonts/montserrat.woff2 b/fonts/montserrat.woff2 deleted file mode 100644 index ab6fda2..0000000 Binary files a/fonts/montserrat.woff2 and /dev/null differ diff --git a/images/screenshots/preview.png b/images/screenshots/preview.png new file mode 100644 index 0000000..5ca240b Binary files /dev/null and b/images/screenshots/preview.png differ diff --git a/images/screenshots/settings.png b/images/screenshots/settings.png new file mode 100644 index 0000000..8fb857c Binary files /dev/null and b/images/screenshots/settings.png differ diff --git a/index.css b/index.css index eadc0c7..ec58b9b 100644 --- a/index.css +++ b/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; +} diff --git a/index.html b/index.html index 3f85796..17d1567 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@
-
+
Title Subtitle @@ -26,6 +26,31 @@
+
+
1:23 / 4:56
+
+ 96.9 + 7 +
+
+ + Settings + + + + + + + +
+ About + Source code +
diff --git a/index.js b/index.js index 64af5c8..23383f8 100644 --- a/index.js +++ b/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(); diff --git a/types.d.ts b/types.d.ts index ecf57d4..e22f0a4 100644 --- a/types.d.ts +++ b/types.d.ts @@ -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