4 Commits

Author SHA1 Message Date
3bcd6d6747 Add volume limiter 2026-03-13 16:36:21 +01:00
e7af8907b4 Add GitInfoService 2026-03-13 15:21:53 +01:00
0975c039a0 Cleanup chanenls 2026-02-13 14:16:52 +01:00
c4bed3f050 Add todos 2026-02-10 13:44:21 +01:00
30 changed files with 625 additions and 121 deletions

View File

@@ -80,7 +80,7 @@
## Frontend Notes ## Frontend Notes
- Broadcast client code lives in `src/main/resources/static/js/broadcast/` (renderer, workers, runtime helpers). When editing overlay scripts, keep worker changes in `script-worker.js` and keep main page logic in `renderer.js`. - Broadcast client code lives in `src/main/resources/static/js/broadcast/` (renderer, workers, runtime helpers). When editing overlay scripts, keep worker changes in `script-worker.js` and keep main page logic in `renderer.js`.
- Admin/dashboard JS modules (`customAssets.js`, `settings.js`, `channels.js`, etc.) are plain ES modules bundled through Thymeleaf templates, so keep related CSS/HTML under `static/css` and `templates`. - Admin/dashboard JS modules (`customAssets.js`, `settings.js`, etc.) are plain ES modules bundled through Thymeleaf templates, so keep related CSS/HTML under `static/css` and `templates`.
- Templates render dynamic data via controllers such as `ViewController`, which also injects `uploadLimitBytes`, version info (`VersionService`), and feature flags (staging banner, docs URL, commit chip wrapped in `GitInfoService`/`GithubReleaseService` values). - Templates render dynamic data via controllers such as `ViewController`, which also injects `uploadLimitBytes`, version info (`VersionService`), and feature flags (staging banner, docs URL, commit chip wrapped in `GitInfoService`/`GithubReleaseService` values).
## Testing & Validation ## Testing & Validation

View File

@@ -12,6 +12,8 @@ import org.springframework.stereotype.Component;
@Component @Component
public class SchemaMigration implements ApplicationRunner { public class SchemaMigration implements ApplicationRunner {
// TODO: Code smell Runtime schema migration logic duplicates Flyway responsibilities and is difficult to reason about/test.
private static final Logger logger = LoggerFactory.getLogger(SchemaMigration.class); private static final Logger logger = LoggerFactory.getLogger(SchemaMigration.class);
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
@@ -66,6 +68,7 @@ public class SchemaMigration implements ApplicationRunner {
addColumnIfMissing("channels", columns, "canvas_width", "REAL", "1920"); addColumnIfMissing("channels", columns, "canvas_width", "REAL", "1920");
addColumnIfMissing("channels", columns, "canvas_height", "REAL", "1080"); addColumnIfMissing("channels", columns, "canvas_height", "REAL", "1080");
addColumnIfMissing("channels", columns, "max_volume_db", "REAL", "0");
} }
private void ensureAssetTables() { private void ensureAssetTables() {

View File

@@ -56,6 +56,8 @@ import org.springframework.web.server.ResponseStatusException;
@SecurityRequirement(name = "twitchOAuth") @SecurityRequirement(name = "twitchOAuth")
public class ChannelApiController { public class ChannelApiController {
// TODO: Code smell Controller surface area is very large, suggesting too many endpoint responsibilities in one type.
private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class); private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class);
private final ChannelDirectoryService channelDirectoryService; private final ChannelDirectoryService channelDirectoryService;
private final OAuth2AuthorizedClientService authorizedClientService; private final OAuth2AuthorizedClientService authorizedClientService;

View File

@@ -1,5 +1,7 @@
package dev.kruhlmann.imgfloat.model.api.request; package dev.kruhlmann.imgfloat.model.api.request;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Positive;
public class CanvasSettingsRequest { public class CanvasSettingsRequest {
@@ -10,9 +12,14 @@ public class CanvasSettingsRequest {
@Positive @Positive
private final double height; 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.width = width;
this.height = height; this.height = height;
this.maxVolumeDb = maxVolumeDb;
} }
public double getWidth() { public double getWidth() {
@@ -23,4 +30,8 @@ public class CanvasSettingsRequest {
return height; return height;
} }
public Double getMaxVolumeDb() {
return maxVolumeDb;
}
} }

View File

@@ -19,4 +19,16 @@ public class CanvasEvent {
return event; return event;
} }
public Type getType() {
return type;
}
public String getChannel() {
return channel;
}
public CanvasSettingsRequest getPayload() {
return payload;
}
} }

View File

@@ -32,6 +32,9 @@ public class Channel {
private double canvasHeight = 1080; 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) @Column(name = "allow_channel_emotes_for_assets", nullable = false)
private boolean allowChannelEmotesForAssets = true; private boolean allowChannelEmotesForAssets = true;
@@ -85,6 +88,14 @@ public class Channel {
this.canvasHeight = canvasHeight; this.canvasHeight = canvasHeight;
} }
public Double getMaxVolumeDb() {
return maxVolumeDb;
}
public void setMaxVolumeDb(Double maxVolumeDb) {
this.maxVolumeDb = maxVolumeDb;
}
public boolean isAllowChannelEmotesForAssets() { public boolean isAllowChannelEmotesForAssets() {
return allowChannelEmotesForAssets; return allowChannelEmotesForAssets;
} }

View File

@@ -60,6 +60,7 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@Service @Service
// TODO: Code smell God class; this service mixes admin management, asset CRUD, media processing, websocket publishing, and marketplace concerns.
public class ChannelDirectoryService { public class ChannelDirectoryService {
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class); private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class);
@@ -74,6 +75,7 @@ public class ChannelDirectoryService {
AssetType.OTHER AssetType.OTHER
); );
// TODO: Code smell Constructor has too many dependencies, indicating high coupling and too many responsibilities.
private final ChannelRepository channelRepository; private final ChannelRepository channelRepository;
private final AssetRepository assetRepository; private final AssetRepository assetRepository;
private final VisualAssetRepository visualAssetRepository; private final VisualAssetRepository visualAssetRepository;
@@ -211,7 +213,11 @@ public class ChannelDirectoryService {
public CanvasSettingsRequest getCanvasSettings(String broadcaster) { public CanvasSettingsRequest getCanvasSettings(String broadcaster) {
Channel channel = getOrCreateChannel(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) { public CanvasSettingsRequest updateCanvasSettings(String broadcaster, CanvasSettingsRequest req, String actor) {
@@ -219,24 +225,52 @@ public class ChannelDirectoryService {
Channel channel = getOrCreateChannel(broadcaster); Channel channel = getOrCreateChannel(broadcaster);
double beforeWidth = channel.getCanvasWidth(); double beforeWidth = channel.getCanvasWidth();
double beforeHeight = channel.getCanvasHeight(); double beforeHeight = channel.getCanvasHeight();
Double beforeMaxVolumeDb = channel.getMaxVolumeDb();
channel.setCanvasWidth(req.getWidth()); channel.setCanvasWidth(req.getWidth());
channel.setCanvasHeight(req.getHeight()); channel.setCanvasHeight(req.getHeight());
if (req.getMaxVolumeDb() != null) {
channel.setMaxVolumeDb(req.getMaxVolumeDb());
}
channelRepository.save(channel); 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)); messagingTemplate.convertAndSend(topicFor(broadcaster), CanvasEvent.updated(broadcaster, response));
if (
beforeWidth != channel.getCanvasWidth() ||
beforeHeight != channel.getCanvasHeight() ||
!Objects.equals(beforeMaxVolumeDb, channel.getMaxVolumeDb())
) {
List<String> changes = new ArrayList<>();
if (beforeWidth != channel.getCanvasWidth() || beforeHeight != channel.getCanvasHeight()) { 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( auditLogService.recordEntry(
channel.getBroadcaster(), channel.getBroadcaster(),
actor, actor,
"CANVAS_UPDATED", "CANVAS_UPDATED",
String.format( "Canvas settings updated" + (changes.isEmpty() ? "" : " (" + String.join(", ", changes) + ")")
Locale.ROOT,
"Canvas updated to %.0fx%.0f (was %.0fx%.0f)",
channel.getCanvasWidth(),
channel.getCanvasHeight(),
beforeWidth,
beforeHeight
)
); );
} }
return response; return response;
@@ -264,6 +298,15 @@ public class ChannelDirectoryService {
BAD_REQUEST, BAD_REQUEST,
"Canvas height must be a whole number within [1 to " + canvasMaxSizePixels + "]" "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) { public ChannelScriptSettingsRequest getChannelScriptSettings(String broadcaster) {

View File

@@ -1,12 +1,10 @@
package dev.kruhlmann.imgfloat.service; package dev.kruhlmann.imgfloat.service;
import java.io.BufferedReader; import dev.kruhlmann.imgfloat.service.git.GitCommitInfo;
import java.io.IOException; import dev.kruhlmann.imgfloat.service.git.GitCommitInfoSource;
import java.io.InputStream; import java.util.List;
import java.io.InputStreamReader; import java.util.Objects;
import java.util.Properties; import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -15,18 +13,16 @@ import org.springframework.util.StringUtils;
public class GitInfoService { public class GitInfoService {
private static final String FALLBACK_GIT_SHA = "unknown"; private static final String FALLBACK_GIT_SHA = "unknown";
private static final Logger LOG = LoggerFactory.getLogger(GitInfoService.class);
private final String commitSha; private final String commitSha;
private final String shortCommitSha; private final String shortCommitSha;
private final String commitUrlPrefix; private final String commitUrlPrefix;
public GitInfoService(@Value("${IMGFLOAT_COMMIT_URL_PREFIX:}") String commitUrlPrefix) { public GitInfoService(
CommitInfo commitInfo = resolveFromGitProperties(); @Value("${IMGFLOAT_COMMIT_URL_PREFIX:}") String commitUrlPrefix,
if (commitInfo == null) { List<GitCommitInfoSource> commitInfoSources
commitInfo = resolveFromGitBinary(); ) {
} GitCommitInfo commitInfo = resolveCommitInfo(commitInfoSources);
String full = commitInfo != null ? commitInfo.fullSha() : null; String full = commitInfo != null ? commitInfo.fullSha() : null;
String abbreviated = commitInfo != null ? commitInfo.shortSha() : null; String abbreviated = commitInfo != null ? commitInfo.shortSha() : null;
@@ -59,54 +55,20 @@ public class GitInfoService {
return StringUtils.hasText(commitUrlPrefix); return StringUtils.hasText(commitUrlPrefix);
} }
private CommitInfo resolveFromGitProperties() { private GitCommitInfo resolveCommitInfo(List<GitCommitInfoSource> commitInfoSources) {
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream("git.properties")) { if (commitInfoSources == null || commitInfoSources.isEmpty()) {
if (inputStream == null) {
return null;
}
Properties properties = new Properties();
properties.load(inputStream);
String fullSha = normalize(properties.getProperty("git.commit.id"));
String shortSha = normalize(properties.getProperty("git.commit.id.abbrev"));
if (fullSha == null && shortSha == null) {
return null;
}
return new CommitInfo(fullSha, shortSha);
} catch (IOException e) {
LOG.warn("Unable to read git.properties from classpath", e);
return null;
}
}
private CommitInfo resolveFromGitBinary() {
String fullSha = runGitCommand("rev-parse", "HEAD");
String shortSha = runGitCommand("rev-parse", "--short", "HEAD");
if (fullSha == null && shortSha == null) {
return null;
}
return new CommitInfo(fullSha, shortSha);
}
private String runGitCommand(String... command) {
try {
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String output = reader.readLine();
int exitCode = process.waitFor();
if (exitCode == 0 && output != null && !output.isBlank()) {
return output.trim();
}
LOG.debug("Git command {} failed with exit code {}", String.join(" ", command), exitCode);
return null;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.debug("Thread interrupt during git command {}", String.join(" ", command), e);
return null;
} catch (IOException e) {
LOG.debug("Git command IO error command {}", String.join(" ", command), e);
return null; return null;
} }
Optional<GitCommitInfo> resolved = commitInfoSources
.stream()
.filter(Objects::nonNull)
.map(GitCommitInfoSource::loadCommitInfo)
.filter(Optional::isPresent)
.map(Optional::get)
.map(this::normalizeCommitInfo)
.filter(Objects::nonNull)
.findFirst();
return resolved.orElse(null);
} }
private String abbreviate(String value) { private String abbreviate(String value) {
@@ -130,5 +92,15 @@ public class GitInfoService {
return value; return value;
} }
private record CommitInfo(String fullSha, String shortSha) {} private GitCommitInfo normalizeCommitInfo(GitCommitInfo commitInfo) {
if (commitInfo == null) {
return null;
}
String fullSha = normalize(commitInfo.fullSha());
String shortSha = normalize(commitInfo.shortSha());
if (fullSha == null && shortSha == null) {
return null;
}
return new GitCommitInfo(fullSha, shortSha);
}
} }

View File

@@ -22,6 +22,8 @@ import org.springframework.stereotype.Component;
@Component @Component
public class MarketplaceScriptSeedLoader { public class MarketplaceScriptSeedLoader {
// TODO: Code smell Large parser/loader with many branching paths; consider decomposing into smaller collaborators.
private static final Logger logger = LoggerFactory.getLogger(MarketplaceScriptSeedLoader.class); private static final Logger logger = LoggerFactory.getLogger(MarketplaceScriptSeedLoader.class);
private static final String METADATA_FILENAME = "metadata.json"; private static final String METADATA_FILENAME = "metadata.json";
private static final String SOURCE_FILENAME = "source.js"; private static final String SOURCE_FILENAME = "source.js";

View File

@@ -30,6 +30,8 @@ import org.springframework.web.util.UriComponentsBuilder;
@Service @Service
public class SevenTvEmoteService { public class SevenTvEmoteService {
// TODO: Code smell Service handles transport, parsing, and storage concerns together instead of focused components.
private static final Logger LOG = LoggerFactory.getLogger(SevenTvEmoteService.class); private static final Logger LOG = LoggerFactory.getLogger(SevenTvEmoteService.class);
private static final String USERS_URL = "https://api.twitch.tv/helix/users"; private static final String USERS_URL = "https://api.twitch.tv/helix/users";
private static final String USER_EMOTE_URL = "https://7tv.io/v3/users/twitch/"; private static final String USER_EMOTE_URL = "https://7tv.io/v3/users/twitch/";

View File

@@ -34,6 +34,8 @@ import org.springframework.web.util.UriComponentsBuilder;
@Service @Service
public class TwitchEmoteService { public class TwitchEmoteService {
// TODO: Code smell Service bundles API client calls, caching, disk persistence, and async scheduling in one class.
private static final Logger LOG = LoggerFactory.getLogger(TwitchEmoteService.class); private static final Logger LOG = LoggerFactory.getLogger(TwitchEmoteService.class);
private static final String GLOBAL_EMOTE_URL = "https://api.twitch.tv/helix/chat/emotes/global"; private static final String GLOBAL_EMOTE_URL = "https://api.twitch.tv/helix/chat/emotes/global";
private static final String CHANNEL_EMOTE_URL = "https://api.twitch.tv/helix/chat/emotes"; private static final String CHANNEL_EMOTE_URL = "https://api.twitch.tv/helix/chat/emotes";

View File

@@ -0,0 +1,54 @@
package dev.kruhlmann.imgfloat.service.git;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
@Order(1)
public class GitBinaryCommitInfoSource implements GitCommitInfoSource {
private static final Logger LOG = LoggerFactory.getLogger(GitBinaryCommitInfoSource.class);
@Override
public Optional<GitCommitInfo> loadCommitInfo() {
String fullSha = runGitCommand("rev-parse", "HEAD");
String shortSha = runGitCommand("rev-parse", "--short", "HEAD");
if (fullSha == null && shortSha == null) {
return Optional.empty();
}
return Optional.of(new GitCommitInfo(fullSha, shortSha));
}
private String runGitCommand(String... args) {
List<String> command = new ArrayList<>(args.length + 1);
command.add("git");
command.addAll(List.of(args));
try {
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String output = reader.readLine();
int exitCode = process.waitFor();
if (exitCode == 0 && output != null && !output.isBlank()) {
return output.trim();
}
LOG.debug("Git command {} failed with exit code {}", String.join(" ", command), exitCode);
return null;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.debug("Thread interrupt during git command {}", String.join(" ", command), e);
return null;
} catch (IOException e) {
LOG.debug("Git command IO error command {}", String.join(" ", command), e);
return null;
}
}
}

View File

@@ -0,0 +1,3 @@
package dev.kruhlmann.imgfloat.service.git;
public record GitCommitInfo(String fullSha, String shortSha) {}

View File

@@ -0,0 +1,8 @@
package dev.kruhlmann.imgfloat.service.git;
import java.util.Optional;
@FunctionalInterface
public interface GitCommitInfoSource {
Optional<GitCommitInfo> loadCommitInfo();
}

View File

@@ -0,0 +1,38 @@
package dev.kruhlmann.imgfloat.service.git;
import java.io.IOException;
import java.io.InputStream;
import java.util.Optional;
import java.util.Properties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@Component
@Order(0)
public class GitPropertiesCommitInfoSource implements GitCommitInfoSource {
private static final Logger LOG = LoggerFactory.getLogger(GitPropertiesCommitInfoSource.class);
@Override
public Optional<GitCommitInfo> loadCommitInfo() {
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream("git.properties")) {
if (inputStream == null) {
return Optional.empty();
}
Properties properties = new Properties();
properties.load(inputStream);
String fullSha = properties.getProperty("git.commit.id");
String shortSha = properties.getProperty("git.commit.id.abbrev");
if (!StringUtils.hasText(fullSha) && !StringUtils.hasText(shortSha)) {
return Optional.empty();
}
return Optional.of(new GitCommitInfo(fullSha, shortSha));
} catch (IOException e) {
LOG.warn("Unable to read git.properties from classpath", e);
return Optional.empty();
}
}
}

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
UPDATE channels
SET max_volume_db = 0;

View File

@@ -1,3 +1,4 @@
// TODO: Code smell Monolithic admin console logic centralizes rendering, transport, and interaction handling in one file.
import { isAudioAsset } from "../media/audio.js"; import { isAudioAsset } from "../media/audio.js";
import { isApngAsset, isCodeAsset, isGifAsset, isModelAsset, isVideoAsset, isVideoElement } from "../broadcast/assetKinds.js"; import { isApngAsset, isCodeAsset, isGifAsset, isModelAsset, isVideoAsset, isVideoElement } from "../broadcast/assetKinds.js";
import { createModelManager } from "../media/modelManager.js"; import { createModelManager } from "../media/modelManager.js";

View File

@@ -1,13 +1,15 @@
const audioUnlockEvents = ["pointerdown", "keydown", "touchstart"]; const audioUnlockEvents = ["pointerdown", "keydown", "touchstart"];
export function createAudioManager({ assets, globalScope = globalThis }) { export function createAudioManager({ assets, globalScope = globalThis, maxVolumeDb = 0 }) {
const audioControllers = new Map(); const audioControllers = new Map();
const pendingAudioUnlock = new Set(); const pendingAudioUnlock = new Set();
const limiter = createAudioLimiter({ globalScope, maxVolumeDb });
audioUnlockEvents.forEach((eventName) => { audioUnlockEvents.forEach((eventName) => {
globalScope.addEventListener(eventName, () => { globalScope.addEventListener(eventName, () => {
limiter.resume();
if (!pendingAudioUnlock.size) return; if (!pendingAudioUnlock.size) return;
pendingAudioUnlock.forEach((controller) => safePlay(controller, pendingAudioUnlock)); pendingAudioUnlock.forEach((controller) => safePlay(controller, pendingAudioUnlock, limiter.resume));
pendingAudioUnlock.clear(); pendingAudioUnlock.clear();
}); });
}); });
@@ -41,6 +43,7 @@ export function createAudioManager({ assets, globalScope = globalThis }) {
element.onended = () => handleAudioEnded(asset.id); element.onended = () => handleAudioEnded(asset.id);
audioControllers.set(asset.id, controller); audioControllers.set(asset.id, controller);
applyAudioSettings(controller, asset, true); applyAudioSettings(controller, asset, true);
limiter.connectElement(element);
return controller; return controller;
} }
@@ -62,6 +65,8 @@ export function createAudioManager({ assets, globalScope = globalThis }) {
element.playbackRate = speed * pitch; element.playbackRate = speed * pitch;
const volume = Math.max(0, Math.min(2, asset.audioVolume ?? 1)); const volume = Math.max(0, Math.min(2, asset.audioVolume ?? 1));
element.volume = Math.min(volume, 1); element.volume = Math.min(volume, 1);
limiter.connectElement(element);
limiter.resume();
} }
function getAssetVolume(asset) { function getAssetVolume(asset) {
@@ -72,6 +77,8 @@ export function createAudioManager({ assets, globalScope = globalThis }) {
if (!element) return 1; if (!element) return 1;
const volume = getAssetVolume(asset); const volume = getAssetVolume(asset);
element.volume = Math.min(volume, 1); element.volume = Math.min(volume, 1);
limiter.connectElement(element);
limiter.resume();
return volume; return volume;
} }
@@ -113,7 +120,7 @@ export function createAudioManager({ assets, globalScope = globalThis }) {
controller.element.currentTime = 0; controller.element.currentTime = 0;
const originalDelay = controller.delayMs; const originalDelay = controller.delayMs;
controller.delayMs = 0; controller.delayMs = 0;
safePlay(controller, pendingAudioUnlock); safePlay(controller, pendingAudioUnlock, limiter.resume);
controller.delayMs = controller.baseDelayMs ?? originalDelay ?? 0; controller.delayMs = controller.baseDelayMs ?? originalDelay ?? 0;
} }
@@ -123,11 +130,13 @@ export function createAudioManager({ assets, globalScope = globalThis }) {
temp.preload = "auto"; temp.preload = "auto";
temp.controls = false; temp.controls = false;
applyAudioElementSettings(temp, asset); applyAudioElementSettings(temp, asset);
limiter.connectElement(temp);
const controller = { element: temp }; const controller = { element: temp };
temp.onended = () => { temp.onended = () => {
limiter.disconnectElement(temp);
temp.remove(); temp.remove();
}; };
safePlay(controller, pendingAudioUnlock); safePlay(controller, pendingAudioUnlock, limiter.resume);
} }
function handleAudioPlay(asset, shouldPlay) { function handleAudioPlay(asset, shouldPlay) {
@@ -139,7 +148,7 @@ export function createAudioManager({ assets, globalScope = globalThis }) {
} }
if (asset.audioLoop) { if (asset.audioLoop) {
controller.delayMs = controller.baseDelayMs; controller.delayMs = controller.baseDelayMs;
safePlay(controller, pendingAudioUnlock); safePlay(controller, pendingAudioUnlock, limiter.resume);
} else { } else {
playOverlappingAudio(asset); playOverlappingAudio(asset);
} }
@@ -160,7 +169,7 @@ export function createAudioManager({ assets, globalScope = globalThis }) {
return; return;
} }
controller.delayTimeout = setTimeout(() => { controller.delayTimeout = setTimeout(() => {
safePlay(controller, pendingAudioUnlock); safePlay(controller, pendingAudioUnlock, limiter.resume);
}, controller.delayMs); }, controller.delayMs);
} }
@@ -187,6 +196,7 @@ export function createAudioManager({ assets, globalScope = globalThis }) {
if (audio.delayTimeout) { if (audio.delayTimeout) {
clearTimeout(audio.delayTimeout); clearTimeout(audio.delayTimeout);
} }
limiter.disconnectElement(audio.element);
audio.element.pause(); audio.element.pause();
audio.element.currentTime = 0; audio.element.currentTime = 0;
audio.element.src = ""; audio.element.src = "";
@@ -202,11 +212,16 @@ export function createAudioManager({ assets, globalScope = globalThis }) {
playAudioImmediately, playAudioImmediately,
autoStartAudio, autoStartAudio,
clearAudio, clearAudio,
releaseMediaElement: limiter.disconnectElement,
setMaxVolumeDb: limiter.setMaxVolumeDb,
}; };
} }
function safePlay(controller, pendingUnlock) { function safePlay(controller, pendingUnlock, resumeAudio) {
if (!controller?.element) return; if (!controller?.element) return;
if (resumeAudio) {
resumeAudio();
}
const playPromise = controller.element.play(); const playPromise = controller.element.play();
if (playPromise?.catch) { if (playPromise?.catch) {
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,
};
}

View File

@@ -7,6 +7,7 @@ export function createMediaManager({ state, audioManager, draw, obsBrowser, supp
function clearMedia(assetId) { function clearMedia(assetId) {
const element = mediaCache.get(assetId); const element = mediaCache.get(assetId);
if (isVideoElement(element)) { if (isVideoElement(element)) {
audioManager.releaseMediaElement(element);
element.src = ""; element.src = "";
element.remove(); element.remove();
} }

View File

@@ -8,6 +8,7 @@ import { createMediaManager } from "./mediaManager.js";
import { createModelManager } from "../media/modelManager.js"; import { createModelManager } from "../media/modelManager.js";
export class BroadcastRenderer { export class BroadcastRenderer {
// TODO: Code smell Renderer class accumulates networking, state management, media orchestration, and rendering responsibilities.
constructor({ canvas, scriptLayer, broadcaster, showToast }) { constructor({ canvas, scriptLayer, broadcaster, showToast }) {
this.canvas = canvas; this.canvas = canvas;
this.ctx = canvas.getContext("2d"); this.ctx = canvas.getContext("2d");
@@ -42,7 +43,10 @@ export class BroadcastRenderer {
typeof ImageDecoder !== "undefined" && typeof createImageBitmap === "function" && !this.obsBrowser; typeof ImageDecoder !== "undefined" && typeof createImageBitmap === "function" && !this.obsBrowser;
this.canPlayProbe = document.createElement("video"); 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({ this.mediaManager = createMediaManager({
state: this.state, state: this.state,
audioManager: this.audioManager, audioManager: this.audioManager,
@@ -151,7 +155,12 @@ export class BroadcastRenderer {
} }
const width = Number.isFinite(settings.width) ? settings.width : this.state.canvasSettings.width; 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 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.canvasSettings = { width, height };
this.state.audioSettings = { maxVolumeDb };
this.audioManager.setMaxVolumeDb(maxVolumeDb);
this.resizeCanvas(); this.resizeCanvas();
} }

View File

@@ -330,6 +330,7 @@ function createScriptHandlers(source, context, state, sourceLabel = "") {
return factory(context, state, module, exports); return factory(context, state, module, exports);
} }
// TODO: Code smell Worker command handling relies on a long conditional dispatcher that is hard to maintain.
self.addEventListener("message", (event) => { self.addEventListener("message", (event) => {
const { type, payload } = event.data || {}; const { type, payload } = event.data || {};
if (type === "init") { if (type === "init") {

View File

@@ -1,6 +1,7 @@
export function createBroadcastState() { export function createBroadcastState() {
return { return {
canvasSettings: { width: 1920, height: 1080 }, canvasSettings: { width: 1920, height: 1080 },
audioSettings: { maxVolumeDb: 0 },
assets: new Map(), assets: new Map(),
mediaCache: new Map(), mediaCache: new Map(),
renderStates: new Map(), renderStates: new Map(),

View File

@@ -1,10 +0,0 @@
const channelNameInput = document.getElementById("channel-search");
function onOpenOverlayButtonClick(event) {
event.preventDefault();
const channelName = channelNameInput.value.trim().toLowerCase();
if (channelName) {
const overlayUrl = `/view/${channelName}/broadcast`;
window.location.href = overlayUrl;
}
}

View File

@@ -1,3 +1,4 @@
// TODO: Code smell Large modal module with extensive mutable state and mixed UI/network responsibilities.
export function createCustomAssetModal({ export function createCustomAssetModal({
broadcaster, broadcaster,
adminChannels = [], adminChannels = [],
@@ -21,7 +22,6 @@ export function createCustomAssetModal({
const logoPreview = document.getElementById("custom-asset-logo-preview"); const logoPreview = document.getElementById("custom-asset-logo-preview");
const logoClearButton = document.getElementById("custom-asset-logo-clear"); const logoClearButton = document.getElementById("custom-asset-logo-clear");
const userSourceTextArea = document.getElementById("custom-asset-code"); const userSourceTextArea = document.getElementById("custom-asset-code");
let codeEditor = null;
const formErrorWrapper = document.getElementById("custom-asset-error"); const formErrorWrapper = document.getElementById("custom-asset-error");
const jsErrorTitle = document.getElementById("js-error-title"); const jsErrorTitle = document.getElementById("js-error-title");
const jsErrorDetails = document.getElementById("js-error-details"); const jsErrorDetails = document.getElementById("js-error-details");
@@ -33,7 +33,7 @@ export function createCustomAssetModal({
const allowedDomainInput = document.getElementById("custom-asset-allowed-domain"); const allowedDomainInput = document.getElementById("custom-asset-allowed-domain");
const allowedDomainList = document.getElementById("custom-asset-allowed-domain-list"); const allowedDomainList = document.getElementById("custom-asset-allowed-domain-list");
const allowedDomainAddButton = document.getElementById("custom-asset-allowed-domain-add"); const allowedDomainAddButton = document.getElementById("custom-asset-allowed-domain-add");
const allowedDomainHint = document.getElementById("custom-asset-allowed-domain-hint"); let codeEditor = null;
let currentAssetId = null; let currentAssetId = null;
let pendingLogoFile = null; let pendingLogoFile = null;
let logoRemoved = false; let logoRemoved = false;
@@ -922,16 +922,6 @@ export function createCustomAssetModal({
content.appendChild(title); content.appendChild(title);
content.appendChild(description); content.appendChild(description);
content.appendChild(meta); content.appendChild(meta);
if (Array.isArray(entry.allowedDomains) && entry.allowedDomains.length) {
const domains = document.createElement("small");
domains.className = "marketplace-domains";
const summary =
entry.allowedDomains.length > 3
? `${entry.allowedDomains.slice(0, 3).join(", ")}, …`
: entry.allowedDomains.join(", ");
domains.textContent = `Allowed domains: ${summary}`;
content.appendChild(domains);
}
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "marketplace-actions"; actions.className = "marketplace-actions";
@@ -977,7 +967,7 @@ export function createCustomAssetModal({
} }
const target = marketplaceChannelSelect?.value || broadcaster; const target = marketplaceChannelSelect?.value || broadcaster;
const allowedDomains = Array.isArray(entry.allowedDomains) ? entry.allowedDomains.filter(Boolean) : []; const allowedDomains = Array.isArray(entry.allowedDomains) ? entry.allowedDomains.filter(Boolean) : [];
confirmDomainImport(allowedDomains, target) confirmDomainImport(allowedDomains)
.then((confirmed) => { .then((confirmed) => {
if (!confirmed) { if (!confirmed) {
return null; return null;
@@ -1175,7 +1165,7 @@ export function createCustomAssetModal({
return undefined; return undefined;
} }
function confirmDomainImport(domains, target) { function confirmDomainImport(domains) {
if (!Array.isArray(domains) || domains.length === 0) { if (!Array.isArray(domains) || domains.length === 0) {
return Promise.resolve(true); return Promise.resolve(true);
} }
@@ -1186,14 +1176,14 @@ export function createCustomAssetModal({
overlay.setAttribute("aria-modal", "true"); overlay.setAttribute("aria-modal", "true");
const dialog = document.createElement("div"); const dialog = document.createElement("div");
dialog.className = "modal-card"; dialog.className = "modal-inner small";
const title = document.createElement("h3"); const title = document.createElement("h3");
title.textContent = "Allow external domains?"; title.textContent = "Allow external domains?";
dialog.appendChild(title); dialog.appendChild(title);
const copy = document.createElement("p"); const copy = document.createElement("p");
copy.textContent = `This script requests network access to the following domains on ${target}:`; copy.textContent = `This script requests network access to the following domains:`;
dialog.appendChild(copy); dialog.appendChild(copy);
const list = document.createElement("ul"); const list = document.createElement("ul");
@@ -1206,7 +1196,7 @@ export function createCustomAssetModal({
dialog.appendChild(list); dialog.appendChild(list);
const buttons = document.createElement("div"); const buttons = document.createElement("div");
buttons.className = "modal-actions"; buttons.className = "form-actions";
const cancel = document.createElement("button"); const cancel = document.createElement("button");
cancel.type = "button"; cancel.type = "button";
cancel.className = "secondary"; cancel.className = "secondary";

View File

@@ -1,3 +1,4 @@
// TODO: Code smell Dashboard script uses broad shared state and imperative DOM updates instead of focused components.
const elements = { const elements = {
adminList: document.getElementById("admin-list"), adminList: document.getElementById("admin-list"),
suggestionList: document.getElementById("admin-suggestions"), suggestionList: document.getElementById("admin-suggestions"),
@@ -5,6 +6,8 @@ const elements = {
addAdminButton: document.getElementById("add-admin-btn"), addAdminButton: document.getElementById("add-admin-btn"),
canvasWidth: document.getElementById("canvas-width"), canvasWidth: document.getElementById("canvas-width"),
canvasHeight: document.getElementById("canvas-height"), canvasHeight: document.getElementById("canvas-height"),
maxVolumeDb: document.getElementById("max-volume-db"),
maxVolumeLabel: document.getElementById("max-volume-label"),
canvasStatus: document.getElementById("canvas-status"), canvasStatus: document.getElementById("canvas-status"),
canvasSaveButton: document.getElementById("save-canvas-btn"), canvasSaveButton: document.getElementById("save-canvas-btn"),
allowChannelEmotes: document.getElementById("allow-channel-emotes"), allowChannelEmotes: document.getElementById("allow-channel-emotes"),
@@ -203,6 +206,12 @@ async function addAdmin(usernameFromAction) {
function renderCanvasSettings(settings) { function renderCanvasSettings(settings) {
if (elements.canvasWidth) elements.canvasWidth.value = Math.round(settings.width); if (elements.canvasWidth) elements.canvasWidth.value = Math.round(settings.width);
if (elements.canvasHeight) elements.canvasHeight.value = Math.round(settings.height); 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() { async function fetchCanvasSettings() {
@@ -210,7 +219,7 @@ async function fetchCanvasSettings() {
const data = await fetchJson("/canvas", {}, "Failed to load canvas settings"); const data = await fetchJson("/canvas", {}, "Failed to load canvas settings");
renderCanvasSettings(data); renderCanvasSettings(data);
} catch (error) { } 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"); showToast("Using default canvas size. Unable to load saved settings.", "warning");
} }
} }
@@ -218,6 +227,7 @@ async function fetchCanvasSettings() {
async function saveCanvasSettings() { async function saveCanvasSettings() {
const width = Number(elements.canvasWidth?.value); const width = Number(elements.canvasWidth?.value);
const height = Number(elements.canvasHeight?.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) { if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
showToast("Please enter a valid width and height.", "info"); showToast("Please enter a valid width and height.", "info");
return; return;
@@ -226,6 +236,10 @@ async function saveCanvasSettings() {
showToast("Please enter whole-number dimensions for the canvas size.", "info"); showToast("Please enter whole-number dimensions for the canvas size.", "info");
return; 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..."; if (elements.canvasStatus) elements.canvasStatus.textContent = "Saving...";
setButtonBusy(elements.canvasSaveButton, true, "Saving..."); setButtonBusy(elements.canvasSaveButton, true, "Saving...");
try { try {
@@ -234,7 +248,7 @@ async function saveCanvasSettings() {
{ {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ width, height }), body: JSON.stringify({ width, height, maxVolumeDb }),
}, },
"Failed to save canvas", "Failed to save canvas",
); );
@@ -252,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) { function renderScriptSettings(settings) {
if (elements.allowChannelEmotes) { if (elements.allowChannelEmotes) {
elements.allowChannelEmotes.checked = settings.allowChannelEmotesForAssets !== false; elements.allowChannelEmotes.checked = settings.allowChannelEmotesForAssets !== false;
@@ -355,3 +477,6 @@ fetchScriptSettings();
if (elements.deleteAccountButton) { if (elements.deleteAccountButton) {
elements.deleteAccountButton.addEventListener("click", deleteAccount); elements.deleteAccountButton.addEventListener("click", deleteAccount);
} }
if (elements.maxVolumeDb) {
elements.maxVolumeDb.addEventListener("input", handleVolumeSliderInput);
}

View File

@@ -1,3 +1,4 @@
// TODO: Code smell Script performs global DOM lookups/state mutation at module load, increasing coupling and test difficulty.
const formElement = document.getElementById("settings-form"); const formElement = document.getElementById("settings-form");
const submitButtonElement = document.getElementById("settings-submit-button"); const submitButtonElement = document.getElementById("settings-submit-button");
const canvasFpsElement = document.getElementById("canvas-fps"); const canvasFpsElement = document.getElementById("canvas-fps");

View File

@@ -70,6 +70,12 @@
Height Height
<input id="canvas-height" type="number" min="100" step="10" /> <input id="canvas-height" type="number" min="100" step="10" />
</label> </label>
<label>
Max output volume
<input id="max-volume-db" type="range" min="0" max="100" step="1" value="100" />
<span id="max-volume-label" class="form-helper">0 dBFS</span>
<span class="form-helper">Drag to preview. Left = much quieter, right = louder.</span>
</label>
</div> </div>
<h3>Integrations</h3> <h3>Integrations</h3>

View File

@@ -11,6 +11,7 @@ import static org.mockito.Mockito.when;
import dev.kruhlmann.imgfloat.model.AssetType; import dev.kruhlmann.imgfloat.model.AssetType;
import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest; 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.TransformRequest;
import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest; import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest;
import dev.kruhlmann.imgfloat.model.api.response.AssetView; import dev.kruhlmann.imgfloat.model.api.response.AssetView;
@@ -246,6 +247,17 @@ class ChannelDirectoryServiceTest {
assertThat(saved.allowedDomains()).isEmpty(); 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 { private byte[] samplePng() throws IOException {
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB); BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB);
ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream out = new ByteArrayOutputStream();

View File

@@ -0,0 +1,80 @@
package dev.kruhlmann.imgfloat.service;
import static org.assertj.core.api.Assertions.assertThat;
import dev.kruhlmann.imgfloat.service.git.GitCommitInfo;
import dev.kruhlmann.imgfloat.service.git.GitCommitInfoSource;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
class GitInfoServiceTest {
@Test
void usesFirstCommitInfoSource() {
TrackingSource first = new TrackingSource(Optional.of(new GitCommitInfo("full-sha", "short")));
TrackingSource second = new TrackingSource(Optional.of(new GitCommitInfo("other-sha", "other")));
GitInfoService service = new GitInfoService("https://example/commit/", List.of(first, second));
assertThat(service.getShortCommitSha()).isEqualTo("short");
assertThat(service.getCommitUrl()).isEqualTo("https://example/commit/full-sha");
assertThat(first.calls()).isEqualTo(1);
assertThat(second.calls()).isEqualTo(0);
}
@Test
void fallsBackToNextSourceWhenFirstIsEmpty() {
TrackingSource first = new TrackingSource(Optional.empty());
TrackingSource second = new TrackingSource(Optional.of(new GitCommitInfo(null, "abc1234")));
GitInfoService service = new GitInfoService("https://example/commit/", List.of(first, second));
assertThat(service.getShortCommitSha()).isEqualTo("abc1234");
assertThat(service.getCommitUrl()).isEqualTo("https://example/commit/abc1234");
assertThat(first.calls()).isEqualTo(1);
assertThat(second.calls()).isEqualTo(1);
}
@Test
void abbreviatesShortShaWhenOnlyFullIsProvided() {
GitInfoService service = new GitInfoService(
"https://example/commit/",
List.of(() -> Optional.of(new GitCommitInfo("1234567890", null)))
);
assertThat(service.getShortCommitSha()).isEqualTo("1234567");
assertThat(service.getCommitUrl()).isEqualTo("https://example/commit/1234567890");
}
@Test
void hidesCommitUrlWhenPrefixOrShaIsMissing() {
GitInfoService missingPrefix = new GitInfoService(" ", List.of());
GitInfoService missingSha = new GitInfoService("https://example/commit/", List.of());
assertThat(missingPrefix.shouldShowCommitChip()).isFalse();
assertThat(missingPrefix.getCommitUrl()).isNull();
assertThat(missingSha.getShortCommitSha()).isEqualTo("unknown");
assertThat(missingSha.getCommitUrl()).isNull();
}
private static final class TrackingSource implements GitCommitInfoSource {
private final Optional<GitCommitInfo> payload;
private final AtomicInteger calls = new AtomicInteger();
private TrackingSource(Optional<GitCommitInfo> payload) {
this.payload = payload;
}
@Override
public Optional<GitCommitInfo> loadCommitInfo() {
calls.incrementAndGet();
return payload;
}
private int calls() {
return calls.get();
}
}
}