diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java b/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java index 4791c0e..00546e5 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java @@ -68,13 +68,13 @@ public class SystemEnvironmentValidator { private void checkString(String value, String name, StringBuilder missing) { if (value != null && StringUtils.hasText(value)) { - return + return; } missing.append(" - ").append(name).append("\n"); } private void checkUnsignedNumeric(T value, String name, StringBuilder missing) { - if (value !== null && value.doubleValue() >= 0) { + if (value != null && value.doubleValue() >= 0) { return; } missing.append(" - ").append(name).append('\n'); diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index 4bb1906..7a17d13 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -289,8 +289,7 @@ public class ChannelDirectoryService { asset.setHidden(request.isHidden()); assetRepository.save(asset); AssetPatch patch = AssetPatch.fromVisibility(asset); - messagingTemplate.convertAndSend(topicFor(broadcaster), - AssetEvent.visibility(broadcaster, patch)); + messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, patch)); return AssetView.from(normalized, asset); }); } diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index 6f35fd6..32e31b6 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -140,6 +140,151 @@ body { margin-top: 6px; } +.settings-hero { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; + align-items: center; +} + +.settings-layout { + display: grid; + grid-template-columns: 2fr minmax(260px, 1fr); + gap: 18px; + align-items: start; +} + +@media (max-width: 900px) { + .settings-layout { + grid-template-columns: 1fr; + } +} + +.settings-panel { + display: flex; + flex-direction: column; + gap: 18px; +} + +.settings-sidebar { + display: flex; + flex-direction: column; + gap: 14px; +} + +.hero-copy h1 { + margin: 8px 0 6px; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} + +.stat-grid.compact { + gap: 10px; +} + +.stat { + border: 1px solid #1f2937; + border-radius: 12px; + padding: 14px; + background: rgba(255, 255, 255, 0.02); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); +} + +.stat-label { + color: #cbd5e1; + font-size: 13px; + margin: 0 0 4px; +} + +.stat-value { + font-size: 22px; + font-weight: 700; + margin: 0; +} + +.stat-subtitle { + margin: 6px 0 0; + color: #94a3b8; + font-size: 12px; +} + +.field-hint { + color: #94a3b8; + font-size: 13px; + margin: 6px 0 0; +} + +.form-section { + border: 1px solid #1f2937; + border-radius: 12px; + padding: 14px; + background: rgba(255, 255, 255, 0.01); + display: flex; + flex-direction: column; + gap: 12px; +} + +.form-heading h3 { + margin: 4px 0 0; +} + +.form-heading .muted { + margin-top: 2px; +} + +.form-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 4px; +} + +.status-chip { + padding: 8px 12px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.1); + border: 1px solid rgba(148, 163, 184, 0.3); + color: #e2e8f0; + font-size: 14px; + margin: 0; +} + +.status-chip.status-success { + background: rgba(34, 197, 94, 0.12); + border-color: rgba(34, 197, 94, 0.35); + color: #bbf7d0; +} + +.status-chip.status-warning { + background: rgba(251, 191, 36, 0.12); + border-color: rgba(251, 191, 36, 0.4); + color: #fef3c7; +} + +.info-card { + display: flex; + flex-direction: column; + gap: 8px; +} + +.info-card.subtle { + background: rgba(15, 23, 42, 0.75); +} + +.hint-list { + margin: 0; + padding-left: 16px; + color: #cbd5e1; + display: flex; + flex-direction: column; + gap: 6px; +} + .channels-footer { display: flex; align-items: center; diff --git a/src/main/resources/static/js/settings.js b/src/main/resources/static/js/settings.js index df36794..662f38b 100644 --- a/src/main/resources/static/js/settings.js +++ b/src/main/resources/static/js/settings.js @@ -8,6 +8,12 @@ const minPitchElement = document.getElementById("min-audio-pitch"); const maxPitchElement = document.getElementById("max-audio-pitch"); const minVolumeElement = document.getElementById("min-volume"); const maxVolumeElement = document.getElementById("max-volume"); +const statusElement = document.getElementById("settings-status"); +const statCanvasFpsElement = document.getElementById("stat-canvas-fps"); +const statCanvasSizeElement = document.getElementById("stat-canvas-size"); +const statPlaybackRangeElement = document.getElementById("stat-playback-range"); +const statAudioRangeElement = document.getElementById("stat-audio-range"); +const statVolumeRangeElement = document.getElementById("stat-volume-range"); const currentSettings = JSON.parse(serverRenderedSettings); let userSettings = { ...currentSettings }; @@ -44,6 +50,15 @@ function setFormSettings(s) { maxVolumeElement.value = s.maxAssetVolumeFraction; } +function updateStatCards(settings) { + if (!settings) return; + statCanvasFpsElement.textContent = `${settings.canvasFramesPerSecond ?? "--"} fps`; + statCanvasSizeElement.textContent = `${settings.maxCanvasSideLengthPixels ?? "--"} px`; + statPlaybackRangeElement.textContent = `${settings.minAssetPlaybackSpeedFraction ?? "--"} – ${settings.maxAssetPlaybackSpeedFraction ?? "--"}x`; + statAudioRangeElement.textContent = `${settings.minAssetAudioPitchFraction ?? "--"} – ${settings.maxAssetAudioPitchFraction ?? "--"}x`; + statVolumeRangeElement.textContent = `${settings.minAssetVolumeFraction ?? "--"} – ${settings.maxAssetVolumeFraction ?? "--"}x`; +} + function readInt(input) { return input.checkValidity() ? Number(input.value) : null; } @@ -66,13 +81,20 @@ function loadUserSettingsFromDom() { function updateSubmitButtonDisabledState() { if (jsonEquals(currentSettings, userSettings)) { submitButtonElement.disabled = "disabled"; + statusElement.textContent = "No changes yet."; + statusElement.classList.remove("status-success", "status-warning"); return; } if (!formElement.checkValidity()) { submitButtonElement.disabled = "disabled"; + statusElement.textContent = "Fix highlighted fields."; + statusElement.classList.add("status-warning"); + statusElement.classList.remove("status-success"); return; } submitButtonElement.disabled = null; + statusElement.textContent = "Ready to save."; + statusElement.classList.remove("status-warning"); } function submitSettingsForm() { @@ -81,7 +103,9 @@ function submitSettingsForm() { showToast("Settings not valid", "warning"); return; } - fetch("/api/settings/set", { method: "PUT", headers: { 'Content-Type': 'application/json' }, body: userSettings }).then((r) => { + statusElement.textContent = "Saving…"; + statusElement.classList.remove("status-success", "status-warning"); + fetch("/api/settings/set", { method: "PUT", headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(userSettings) }).then((r) => { if (!r.ok) { throw new Error('Failed to load canvas'); } @@ -91,10 +115,17 @@ function submitSettingsForm() { .then((newSettings) => { currentSettings = { ...newSettings }; userSettings = { ...newSettings }; + updateStatCards(newSettings); + showToast("Settings saved", "success"); + statusElement.textContent = "Saved."; + statusElement.classList.add("status-success"); + updateSubmitButtonDisabledState(); }) .catch((error) => { showToast('Unable to save settings', 'error') console.error(error); + statusElement.textContent = "Save failed. Try again."; + statusElement.classList.add("status-warning"); }); } @@ -105,4 +136,11 @@ formElement.querySelectorAll("input").forEach((input) => { }); }); +formElement.addEventListener("submit", (event) => { + event.preventDefault(); + submitSettingsForm(); +}); + setFormSettings(currentSettings); +updateStatCards(currentSettings); +updateSubmitButtonDisabledState(); diff --git a/src/main/resources/templates/settings.html b/src/main/resources/templates/settings.html index 032c33f..546ff1f 100644 --- a/src/main/resources/templates/settings.html +++ b/src/main/resources/templates/settings.html @@ -21,92 +21,208 @@
-
-

System administrator settings

-
- - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+

System administrator settings

+

Application defaults

+

+ Configure overlay performance and audio guardrails for every channel using Imgfloat. + These settings are applied globally. +

+
+ Performance tuned + Server-wide + Admin only +
+
+
+
+

Canvas FPS

+

--

+

Longest side --

+
+
+

Playback speed

+

--

+

Applies to all animations

+
+
+

Audio pitch

+

--

+

Fraction of original clip

+
+
+

Volume limits

+

--

+

Keeps alerts comfortable

+
+
+ +
+
+
+
+

Overlay defaults

+

Performance & audio budget

+

Tune the canvas and audio guardrails to keep overlays smooth and balanced.

+
+
+ +
+
+
+

Canvas

+

Rendering budget

+

Match FPS and max dimensions to your streaming canvas for consistent overlays.

+
+
+ + + +
+

Use the longest edge of your OBS browser source to prevent stretching.

+
+ +
+
+

Playback

+

Animation speed limits

+

Bound default speeds between 0 and 1 so clips run predictably.

+
+
+ + + +
+

Keep the maximum at 1.0 to avoid speeding overlays beyond their source frame rate.

+
+ +
+
+

Audio

+

Pitch & volume guardrails

+

Prevent harsh audio by bounding pitch and volume as fractions of the source.

+
+
+ + + +
+
+ + + +
+

Volume and pitch values are percentages of the original clip between 0 and 1.

+
+ + +
+
+ + +