diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java b/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java index 1327d03..bc6baf9 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java @@ -68,6 +68,7 @@ public class SchemaMigration implements ApplicationRunner { addColumnIfMissing("channels", columns, "canvas_width", "REAL", "1920"); addColumnIfMissing("channels", columns, "canvas_height", "REAL", "1080"); + addColumnIfMissing("channels", columns, "max_volume_db", "REAL", "0"); } private void ensureAssetTables() { diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/api/request/CanvasSettingsRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/api/request/CanvasSettingsRequest.java index e14e814..84a1999 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/api/request/CanvasSettingsRequest.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/api/request/CanvasSettingsRequest.java @@ -1,5 +1,7 @@ package dev.kruhlmann.imgfloat.model.api.request; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Positive; public class CanvasSettingsRequest { @@ -10,9 +12,14 @@ public class CanvasSettingsRequest { @Positive private final double height; - public CanvasSettingsRequest(double width, double height) { + @DecimalMin(value = "-60.0") + @DecimalMax(value = "0.0") + private final Double maxVolumeDb; + + public CanvasSettingsRequest(double width, double height, Double maxVolumeDb) { this.width = width; this.height = height; + this.maxVolumeDb = maxVolumeDb; } public double getWidth() { @@ -23,4 +30,8 @@ public class CanvasSettingsRequest { return height; } + public Double getMaxVolumeDb() { + return maxVolumeDb; + } + } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/api/response/CanvasEvent.java b/src/main/java/dev/kruhlmann/imgfloat/model/api/response/CanvasEvent.java index 3af8118..5531021 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/api/response/CanvasEvent.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/api/response/CanvasEvent.java @@ -19,4 +19,16 @@ public class CanvasEvent { return event; } + public Type getType() { + return type; + } + + public String getChannel() { + return channel; + } + + public CanvasSettingsRequest getPayload() { + return payload; + } + } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/db/imgfloat/Channel.java b/src/main/java/dev/kruhlmann/imgfloat/model/db/imgfloat/Channel.java index b30852f..6e482c2 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/db/imgfloat/Channel.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/db/imgfloat/Channel.java @@ -32,6 +32,9 @@ public class Channel { private double canvasHeight = 1080; + @Column(name = "max_volume_db", nullable = false) + private Double maxVolumeDb = 0.0; + @Column(name = "allow_channel_emotes_for_assets", nullable = false) private boolean allowChannelEmotesForAssets = true; @@ -85,6 +88,14 @@ public class Channel { this.canvasHeight = canvasHeight; } + public Double getMaxVolumeDb() { + return maxVolumeDb; + } + + public void setMaxVolumeDb(Double maxVolumeDb) { + this.maxVolumeDb = maxVolumeDb; + } + public boolean isAllowChannelEmotesForAssets() { return allowChannelEmotesForAssets; } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index 633fa94..edc07d0 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -213,7 +213,11 @@ public class ChannelDirectoryService { public CanvasSettingsRequest getCanvasSettings(String broadcaster) { Channel channel = getOrCreateChannel(broadcaster); - return new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight()); + return new CanvasSettingsRequest( + channel.getCanvasWidth(), + channel.getCanvasHeight(), + channel.getMaxVolumeDb() + ); } public CanvasSettingsRequest updateCanvasSettings(String broadcaster, CanvasSettingsRequest req, String actor) { @@ -221,24 +225,52 @@ public class ChannelDirectoryService { Channel channel = getOrCreateChannel(broadcaster); double beforeWidth = channel.getCanvasWidth(); double beforeHeight = channel.getCanvasHeight(); + Double beforeMaxVolumeDb = channel.getMaxVolumeDb(); channel.setCanvasWidth(req.getWidth()); channel.setCanvasHeight(req.getHeight()); + if (req.getMaxVolumeDb() != null) { + channel.setMaxVolumeDb(req.getMaxVolumeDb()); + } channelRepository.save(channel); - CanvasSettingsRequest response = new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight()); + CanvasSettingsRequest response = new CanvasSettingsRequest( + channel.getCanvasWidth(), + channel.getCanvasHeight(), + channel.getMaxVolumeDb() + ); messagingTemplate.convertAndSend(topicFor(broadcaster), CanvasEvent.updated(broadcaster, response)); - if (beforeWidth != channel.getCanvasWidth() || beforeHeight != channel.getCanvasHeight()) { + if ( + beforeWidth != channel.getCanvasWidth() || + beforeHeight != channel.getCanvasHeight() || + !Objects.equals(beforeMaxVolumeDb, channel.getMaxVolumeDb()) + ) { + List changes = new ArrayList<>(); + if (beforeWidth != channel.getCanvasWidth() || beforeHeight != channel.getCanvasHeight()) { + changes.add( + String.format( + Locale.ROOT, + "canvas %.0fx%.0f -> %.0fx%.0f", + beforeWidth, + beforeHeight, + channel.getCanvasWidth(), + channel.getCanvasHeight() + ) + ); + } + if (!Objects.equals(beforeMaxVolumeDb, channel.getMaxVolumeDb())) { + changes.add( + String.format( + Locale.ROOT, + "max volume %.0f dB -> %.0f dB", + beforeMaxVolumeDb == null ? 0.0 : beforeMaxVolumeDb, + channel.getMaxVolumeDb() == null ? 0.0 : channel.getMaxVolumeDb() + ) + ); + } auditLogService.recordEntry( channel.getBroadcaster(), actor, "CANVAS_UPDATED", - String.format( - Locale.ROOT, - "Canvas updated to %.0fx%.0f (was %.0fx%.0f)", - channel.getCanvasWidth(), - channel.getCanvasHeight(), - beforeWidth, - beforeHeight - ) + "Canvas settings updated" + (changes.isEmpty() ? "" : " (" + String.join(", ", changes) + ")") ); } return response; @@ -266,6 +298,15 @@ public class ChannelDirectoryService { BAD_REQUEST, "Canvas height must be a whole number within [1 to " + canvasMaxSizePixels + "]" ); + if (req.getMaxVolumeDb() != null) { + double maxVolumeDb = req.getMaxVolumeDb(); + if (!Double.isFinite(maxVolumeDb) || maxVolumeDb < -60 || maxVolumeDb > 0) { + throw new ResponseStatusException( + BAD_REQUEST, + "Max volume must be within [-60 to 0] dB" + ); + } + } } public ChannelScriptSettingsRequest getChannelScriptSettings(String broadcaster) { diff --git a/src/main/resources/db/migration/V11__channel_max_volume.sql b/src/main/resources/db/migration/V11__channel_max_volume.sql new file mode 100644 index 0000000..6d86f7d --- /dev/null +++ b/src/main/resources/db/migration/V11__channel_max_volume.sql @@ -0,0 +1,5 @@ +ALTER TABLE channels ADD COLUMN max_volume_db REAL NOT NULL DEFAULT 0; + +UPDATE channels +SET max_volume_db = 0 +WHERE max_volume_db IS NULL; diff --git a/src/main/resources/db/migration/V12__channel_max_volume_default_zero.sql b/src/main/resources/db/migration/V12__channel_max_volume_default_zero.sql new file mode 100644 index 0000000..f35515d --- /dev/null +++ b/src/main/resources/db/migration/V12__channel_max_volume_default_zero.sql @@ -0,0 +1,2 @@ +UPDATE channels +SET max_volume_db = 0; diff --git a/src/main/resources/static/js/broadcast/audioManager.js b/src/main/resources/static/js/broadcast/audioManager.js index 213ce24..c58df67 100644 --- a/src/main/resources/static/js/broadcast/audioManager.js +++ b/src/main/resources/static/js/broadcast/audioManager.js @@ -1,13 +1,15 @@ const audioUnlockEvents = ["pointerdown", "keydown", "touchstart"]; -export function createAudioManager({ assets, globalScope = globalThis }) { +export function createAudioManager({ assets, globalScope = globalThis, maxVolumeDb = 0 }) { const audioControllers = new Map(); const pendingAudioUnlock = new Set(); + const limiter = createAudioLimiter({ globalScope, maxVolumeDb }); audioUnlockEvents.forEach((eventName) => { globalScope.addEventListener(eventName, () => { + limiter.resume(); if (!pendingAudioUnlock.size) return; - pendingAudioUnlock.forEach((controller) => safePlay(controller, pendingAudioUnlock)); + pendingAudioUnlock.forEach((controller) => safePlay(controller, pendingAudioUnlock, limiter.resume)); pendingAudioUnlock.clear(); }); }); @@ -41,6 +43,7 @@ export function createAudioManager({ assets, globalScope = globalThis }) { element.onended = () => handleAudioEnded(asset.id); audioControllers.set(asset.id, controller); applyAudioSettings(controller, asset, true); + limiter.connectElement(element); return controller; } @@ -62,6 +65,8 @@ export function createAudioManager({ assets, globalScope = globalThis }) { element.playbackRate = speed * pitch; const volume = Math.max(0, Math.min(2, asset.audioVolume ?? 1)); element.volume = Math.min(volume, 1); + limiter.connectElement(element); + limiter.resume(); } function getAssetVolume(asset) { @@ -72,6 +77,8 @@ export function createAudioManager({ assets, globalScope = globalThis }) { if (!element) return 1; const volume = getAssetVolume(asset); element.volume = Math.min(volume, 1); + limiter.connectElement(element); + limiter.resume(); return volume; } @@ -113,7 +120,7 @@ export function createAudioManager({ assets, globalScope = globalThis }) { controller.element.currentTime = 0; const originalDelay = controller.delayMs; controller.delayMs = 0; - safePlay(controller, pendingAudioUnlock); + safePlay(controller, pendingAudioUnlock, limiter.resume); controller.delayMs = controller.baseDelayMs ?? originalDelay ?? 0; } @@ -123,11 +130,13 @@ export function createAudioManager({ assets, globalScope = globalThis }) { temp.preload = "auto"; temp.controls = false; applyAudioElementSettings(temp, asset); + limiter.connectElement(temp); const controller = { element: temp }; temp.onended = () => { + limiter.disconnectElement(temp); temp.remove(); }; - safePlay(controller, pendingAudioUnlock); + safePlay(controller, pendingAudioUnlock, limiter.resume); } function handleAudioPlay(asset, shouldPlay) { @@ -139,7 +148,7 @@ export function createAudioManager({ assets, globalScope = globalThis }) { } if (asset.audioLoop) { controller.delayMs = controller.baseDelayMs; - safePlay(controller, pendingAudioUnlock); + safePlay(controller, pendingAudioUnlock, limiter.resume); } else { playOverlappingAudio(asset); } @@ -160,7 +169,7 @@ export function createAudioManager({ assets, globalScope = globalThis }) { return; } controller.delayTimeout = setTimeout(() => { - safePlay(controller, pendingAudioUnlock); + safePlay(controller, pendingAudioUnlock, limiter.resume); }, controller.delayMs); } @@ -187,6 +196,7 @@ export function createAudioManager({ assets, globalScope = globalThis }) { if (audio.delayTimeout) { clearTimeout(audio.delayTimeout); } + limiter.disconnectElement(audio.element); audio.element.pause(); audio.element.currentTime = 0; audio.element.src = ""; @@ -202,11 +212,16 @@ export function createAudioManager({ assets, globalScope = globalThis }) { playAudioImmediately, autoStartAudio, clearAudio, + releaseMediaElement: limiter.disconnectElement, + setMaxVolumeDb: limiter.setMaxVolumeDb, }; } -function safePlay(controller, pendingUnlock) { +function safePlay(controller, pendingUnlock, resumeAudio) { if (!controller?.element) return; + if (resumeAudio) { + resumeAudio(); + } const playPromise = controller.element.play(); if (playPromise?.catch) { playPromise.catch(() => { @@ -214,3 +229,105 @@ function safePlay(controller, pendingUnlock) { }); } } + +function createAudioLimiter({ globalScope, maxVolumeDb }) { + const AudioContextImpl = globalScope.AudioContext || globalScope.webkitAudioContext; + if (!AudioContextImpl) { + return { + connectElement: () => {}, + disconnectElement: () => {}, + setMaxVolumeDb: () => {}, + resume: () => {}, + }; + } + + let context = null; + let limiterNode = null; + let pendingMaxVolumeDb = maxVolumeDb; + + const sourceNodes = new WeakMap(); + const pendingElements = new Set(); + + function ensureContext() { + if (context) { + return context; + } + context = new AudioContextImpl(); + limiterNode = context.createDynamicsCompressor(); + limiterNode.knee.value = 0; + limiterNode.ratio.value = 20; + limiterNode.attack.value = 0.003; + limiterNode.release.value = 0.25; + limiterNode.connect(context.destination); + applyMaxVolumeDb(pendingMaxVolumeDb); + return context; + } + + function applyMaxVolumeDb(value) { + const next = Number.isFinite(value) ? value : 0; + pendingMaxVolumeDb = next; + if (limiterNode) { + limiterNode.threshold.value = next; + } + } + + function connectElement(element) { + if (!element) return; + if (sourceNodes.has(element)) { + return; + } + if (!context || context.state !== "running") { + pendingElements.add(element); + return; + } + try { + const source = context.createMediaElementSource(element); + source.connect(limiterNode); + sourceNodes.set(element, source); + } catch (error) { + // Ignore elements that cannot be connected to the audio graph. + } + } + + function flushPending() { + if (!pendingElements.size) { + return; + } + const elements = Array.from(pendingElements); + pendingElements.clear(); + elements.forEach(connectElement); + } + + function disconnectElement(element) { + pendingElements.delete(element); + const source = sourceNodes.get(element); + if (source) { + source.disconnect(); + sourceNodes.delete(element); + } + } + + function setMaxVolumeDb(value) { + applyMaxVolumeDb(value); + } + + function resume() { + const ctx = ensureContext(); + if (ctx.state === "running") { + flushPending(); + return; + } + ctx.resume() + .then(() => { + flushPending(); + }) + .catch(() => {}); + } + + return { + connectElement, + disconnectElement, + setMaxVolumeDb, + resume, + }; +} diff --git a/src/main/resources/static/js/broadcast/mediaManager.js b/src/main/resources/static/js/broadcast/mediaManager.js index 74c7297..272f3a7 100644 --- a/src/main/resources/static/js/broadcast/mediaManager.js +++ b/src/main/resources/static/js/broadcast/mediaManager.js @@ -7,6 +7,7 @@ export function createMediaManager({ state, audioManager, draw, obsBrowser, supp function clearMedia(assetId) { const element = mediaCache.get(assetId); if (isVideoElement(element)) { + audioManager.releaseMediaElement(element); element.src = ""; element.remove(); } diff --git a/src/main/resources/static/js/broadcast/renderer.js b/src/main/resources/static/js/broadcast/renderer.js index cee90d0..30e8080 100644 --- a/src/main/resources/static/js/broadcast/renderer.js +++ b/src/main/resources/static/js/broadcast/renderer.js @@ -43,7 +43,10 @@ export class BroadcastRenderer { typeof ImageDecoder !== "undefined" && typeof createImageBitmap === "function" && !this.obsBrowser; this.canPlayProbe = document.createElement("video"); - this.audioManager = createAudioManager({ assets: this.state.assets }); + this.audioManager = createAudioManager({ + assets: this.state.assets, + maxVolumeDb: this.state.audioSettings.maxVolumeDb, + }); this.mediaManager = createMediaManager({ state: this.state, audioManager: this.audioManager, @@ -152,7 +155,12 @@ export class BroadcastRenderer { } const width = Number.isFinite(settings.width) ? settings.width : this.state.canvasSettings.width; const height = Number.isFinite(settings.height) ? settings.height : this.state.canvasSettings.height; + const maxVolumeDb = Number.isFinite(settings.maxVolumeDb) + ? settings.maxVolumeDb + : this.state.audioSettings.maxVolumeDb; this.state.canvasSettings = { width, height }; + this.state.audioSettings = { maxVolumeDb }; + this.audioManager.setMaxVolumeDb(maxVolumeDb); this.resizeCanvas(); } diff --git a/src/main/resources/static/js/broadcast/state.js b/src/main/resources/static/js/broadcast/state.js index 8ece1e4..a15598f 100644 --- a/src/main/resources/static/js/broadcast/state.js +++ b/src/main/resources/static/js/broadcast/state.js @@ -1,6 +1,7 @@ export function createBroadcastState() { return { canvasSettings: { width: 1920, height: 1080 }, + audioSettings: { maxVolumeDb: 0 }, assets: new Map(), mediaCache: new Map(), renderStates: new Map(), diff --git a/src/main/resources/static/js/dashboard.js b/src/main/resources/static/js/dashboard.js index 02a0cc0..3934d84 100644 --- a/src/main/resources/static/js/dashboard.js +++ b/src/main/resources/static/js/dashboard.js @@ -6,6 +6,8 @@ const elements = { addAdminButton: document.getElementById("add-admin-btn"), canvasWidth: document.getElementById("canvas-width"), canvasHeight: document.getElementById("canvas-height"), + maxVolumeDb: document.getElementById("max-volume-db"), + maxVolumeLabel: document.getElementById("max-volume-label"), canvasStatus: document.getElementById("canvas-status"), canvasSaveButton: document.getElementById("save-canvas-btn"), allowChannelEmotes: document.getElementById("allow-channel-emotes"), @@ -204,6 +206,12 @@ async function addAdmin(usernameFromAction) { function renderCanvasSettings(settings) { if (elements.canvasWidth) elements.canvasWidth.value = Math.round(settings.width); if (elements.canvasHeight) elements.canvasHeight.value = Math.round(settings.height); + if (elements.maxVolumeDb) { + const volumeDb = Number.isFinite(settings.maxVolumeDb) ? settings.maxVolumeDb : DEFAULT_MAX_VOLUME_DB; + const sliderValue = dbToSlider(volumeDb); + elements.maxVolumeDb.value = sliderValue; + setMaxVolumeLabel(volumeDb); + } } async function fetchCanvasSettings() { @@ -211,7 +219,7 @@ async function fetchCanvasSettings() { const data = await fetchJson("/canvas", {}, "Failed to load canvas settings"); renderCanvasSettings(data); } catch (error) { - renderCanvasSettings({ width: 1920, height: 1080 }); + renderCanvasSettings({ width: 1920, height: 1080, maxVolumeDb: 0 }); showToast("Using default canvas size. Unable to load saved settings.", "warning"); } } @@ -219,6 +227,7 @@ async function fetchCanvasSettings() { async function saveCanvasSettings() { const width = Number(elements.canvasWidth?.value); const height = Number(elements.canvasHeight?.value); + const maxVolumeDb = sliderToDb(Number(elements.maxVolumeDb?.value)); if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { showToast("Please enter a valid width and height.", "info"); return; @@ -227,6 +236,10 @@ async function saveCanvasSettings() { showToast("Please enter whole-number dimensions for the canvas size.", "info"); return; } + if (!Number.isFinite(maxVolumeDb) || maxVolumeDb > MAX_VOLUME_DB || maxVolumeDb < MIN_VOLUME_DB) { + showToast(`Max volume must be between ${MIN_VOLUME_DB} and ${MAX_VOLUME_DB} dBFS.`, "info"); + return; + } if (elements.canvasStatus) elements.canvasStatus.textContent = "Saving..."; setButtonBusy(elements.canvasSaveButton, true, "Saving..."); try { @@ -235,7 +248,7 @@ async function saveCanvasSettings() { { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ width, height }), + body: JSON.stringify({ width, height, maxVolumeDb }), }, "Failed to save canvas", ); @@ -253,6 +266,114 @@ async function saveCanvasSettings() { } } +const MIN_VOLUME_DB = -60; +const MAX_VOLUME_DB = 0; +const DEFAULT_MAX_VOLUME_DB = 0; + +function clamp(value, min, max) { + if (!Number.isFinite(value)) return min; + return Math.min(max, Math.max(min, value)); +} + +function sliderToDb(value) { + const clamped = clamp(value, 0, 100); + return MIN_VOLUME_DB + (clamped / 100) * (MAX_VOLUME_DB - MIN_VOLUME_DB); +} + +function dbToSlider(value) { + const clamped = clamp(value, MIN_VOLUME_DB, MAX_VOLUME_DB); + return Math.round(((clamped - MIN_VOLUME_DB) / (MAX_VOLUME_DB - MIN_VOLUME_DB)) * 100); +} + +function setMaxVolumeLabel(dbValue) { + if (!elements.maxVolumeLabel) return; + const rounded = Math.round(dbValue * 10) / 10; + elements.maxVolumeLabel.textContent = `${rounded} dBFS`; +} + +const demoAudioState = { + context: null, + oscillator: null, + gain: null, + compressor: null, + timeoutId: null, + isPlaying: false, + previewUnavailable: false, +}; + +function stopVolumeDemo() { + if (demoAudioState.timeoutId) { + clearTimeout(demoAudioState.timeoutId); + demoAudioState.timeoutId = null; + } + if (demoAudioState.oscillator) { + try { + demoAudioState.oscillator.stop(); + } catch (_) {} + demoAudioState.oscillator.disconnect(); + demoAudioState.oscillator = null; + } + if (demoAudioState.gain) { + demoAudioState.gain.disconnect(); + demoAudioState.gain = null; + } + if (demoAudioState.compressor) { + demoAudioState.compressor.disconnect(); + demoAudioState.compressor = null; + } + demoAudioState.isPlaying = false; +} + +function startVolumeDemo(maxVolumeDb) { + const AudioContextImpl = window.AudioContext || window.webkitAudioContext; + if (!AudioContextImpl) { + if (!demoAudioState.previewUnavailable) { + showToast("Audio preview is not supported in this browser.", "info"); + demoAudioState.previewUnavailable = true; + } + return; + } + const context = demoAudioState.context || new AudioContextImpl(); + demoAudioState.context = context; + if (!demoAudioState.compressor) { + const compressor = context.createDynamicsCompressor(); + compressor.knee.value = 0; + compressor.ratio.value = 20; + compressor.attack.value = 0.003; + compressor.release.value = 0.25; + compressor.connect(context.destination); + demoAudioState.compressor = compressor; + } + demoAudioState.compressor.threshold.value = maxVolumeDb; + if (!demoAudioState.gain) { + const gain = context.createGain(); + gain.gain.value = 0.8; + gain.connect(demoAudioState.compressor); + demoAudioState.gain = gain; + } + if (!demoAudioState.oscillator) { + const oscillator = context.createOscillator(); + oscillator.type = "sine"; + oscillator.frequency.value = 440; + oscillator.connect(demoAudioState.gain); + oscillator.start(); + demoAudioState.oscillator = oscillator; + } + demoAudioState.isPlaying = true; + context.resume().catch(() => {}); + if (demoAudioState.timeoutId) { + clearTimeout(demoAudioState.timeoutId); + } + demoAudioState.timeoutId = setTimeout(stopVolumeDemo, 800); +} + +function handleVolumeSliderInput() { + if (!elements.maxVolumeDb) return; + const nextDb = sliderToDb(Number(elements.maxVolumeDb.value)); + setMaxVolumeLabel(nextDb); + startVolumeDemo(nextDb); +} + function renderScriptSettings(settings) { if (elements.allowChannelEmotes) { elements.allowChannelEmotes.checked = settings.allowChannelEmotesForAssets !== false; @@ -356,3 +477,6 @@ fetchScriptSettings(); if (elements.deleteAccountButton) { elements.deleteAccountButton.addEventListener("click", deleteAccount); } +if (elements.maxVolumeDb) { + elements.maxVolumeDb.addEventListener("input", handleVolumeSliderInput); +} diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index 174ff90..5545345 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -70,6 +70,12 @@ Height +

Integrations

diff --git a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java index b2a5062..2a0ac59 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java @@ -11,6 +11,7 @@ import static org.mockito.Mockito.when; import dev.kruhlmann.imgfloat.model.AssetType; import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest; +import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.TransformRequest; import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest; import dev.kruhlmann.imgfloat.model.api.response.AssetView; @@ -246,6 +247,17 @@ class ChannelDirectoryServiceTest { assertThat(saved.allowedDomains()).isEmpty(); } + @Test + void updatesCanvasMaxVolumeDb() { + CanvasSettingsRequest request = new CanvasSettingsRequest(1920, 1080, -12.0); + + CanvasSettingsRequest saved = service.updateCanvasSettings("caster", request, "caster"); + + assertThat(saved.getMaxVolumeDb()).isEqualTo(-12.0); + Channel channel = channelRepository.findById("caster").orElseThrow(); + assertThat(channel.getMaxVolumeDb()).isEqualTo(-12.0); + } + private byte[] samplePng() throws IOException { BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB); ByteArrayOutputStream out = new ByteArrayOutputStream();