mirror of
https://github.com/imgfloat/server.git
synced 2026-03-22 23:10:38 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bcd6d6747 | |||
| e7af8907b4 | |||
| 0975c039a0 | |||
| c4bed3f050 | |||
| 45fb1921da | |||
| ed5007538b | |||
| 0f088dc83b |
@@ -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
|
||||||
|
|||||||
2
pom.xml
2
pom.xml
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<groupId>dev.kruhlmann</groupId>
|
<groupId>dev.kruhlmann</groupId>
|
||||||
<artifactId>imgfloat</artifactId>
|
<artifactId>imgfloat</artifactId>
|
||||||
<version>0.0.1</version>
|
<version>0.0.3</version>
|
||||||
<name>Imgfloat</name>
|
<name>Imgfloat</name>
|
||||||
<description>Livestream overlay with Twitch-authenticated channel admins and broadcasters.</description>
|
<description>Livestream overlay with Twitch-authenticated channel admins and broadcasters.</description>
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.controller;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.security.Principal;
|
||||||
|
import java.util.Locale;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.messaging.handler.annotation.DestinationVariable;
|
||||||
|
import org.springframework.messaging.handler.annotation.MessageMapping;
|
||||||
|
import org.springframework.messaging.handler.annotation.Payload;
|
||||||
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
|
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
|
||||||
|
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||||
|
import dev.kruhlmann.imgfloat.model.api.request.TransformRequest;
|
||||||
|
import dev.kruhlmann.imgfloat.model.api.response.AssetEvent;
|
||||||
|
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||||
|
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class ChannelPreviewWsController {
|
||||||
|
|
||||||
|
private final ChannelDirectoryService channelDirectoryService;
|
||||||
|
private final AuthorizationService authorizationService;
|
||||||
|
private final SimpMessagingTemplate messagingTemplate;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public ChannelPreviewWsController(
|
||||||
|
ChannelDirectoryService channelDirectoryService,
|
||||||
|
AuthorizationService authorizationService,
|
||||||
|
SimpMessagingTemplate messagingTemplate
|
||||||
|
) {
|
||||||
|
this.channelDirectoryService = channelDirectoryService;
|
||||||
|
this.authorizationService = authorizationService;
|
||||||
|
this.messagingTemplate = messagingTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@MessageMapping("/channel/{broadcaster}/assets/{assetId}/preview")
|
||||||
|
public void previewTransform(
|
||||||
|
@DestinationVariable String broadcaster,
|
||||||
|
@DestinationVariable String assetId,
|
||||||
|
@Payload @Valid TransformRequest request,
|
||||||
|
Principal principal
|
||||||
|
) {
|
||||||
|
String sessionUsername = sessionUsername(principal);
|
||||||
|
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
||||||
|
broadcaster,
|
||||||
|
sessionUsername
|
||||||
|
);
|
||||||
|
channelDirectoryService
|
||||||
|
.previewTransform(broadcaster, assetId, request)
|
||||||
|
.ifPresent((patch) -> messagingTemplate.convertAndSend(
|
||||||
|
topicFor(broadcaster),
|
||||||
|
AssetEvent.preview(broadcaster, assetId, patch)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sessionUsername(Principal principal) {
|
||||||
|
if (principal instanceof OAuth2AuthenticationToken token) {
|
||||||
|
OauthSessionUser user = OauthSessionUser.from(token);
|
||||||
|
return user == null ? null : user.login();
|
||||||
|
}
|
||||||
|
return principal == null ? null : principal.getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String topicFor(String broadcaster) {
|
||||||
|
return "/topic/channel/" + broadcaster.toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public class AssetEvent {
|
|||||||
UPDATED,
|
UPDATED,
|
||||||
VISIBILITY,
|
VISIBILITY,
|
||||||
PLAY,
|
PLAY,
|
||||||
|
PREVIEW,
|
||||||
DELETED,
|
DELETED,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +68,15 @@ public class AssetEvent {
|
|||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static AssetEvent preview(String channel, String assetId, AssetPatch patch) {
|
||||||
|
AssetEvent event = new AssetEvent();
|
||||||
|
event.type = Type.PREVIEW;
|
||||||
|
event.channel = channel;
|
||||||
|
event.patch = patch;
|
||||||
|
event.assetId = assetId;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
public static AssetEvent deleted(String channel, String assetId) {
|
public static AssetEvent deleted(String channel, String assetId) {
|
||||||
AssetEvent event = new AssetEvent();
|
AssetEvent event = new AssetEvent();
|
||||||
event.type = Type.DELETED;
|
event.type = Type.DELETED;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -67,7 +68,14 @@ public class ChannelDirectoryService {
|
|||||||
private static final String DEFAULT_CODE_MEDIA_TYPE = "application/javascript";
|
private static final String DEFAULT_CODE_MEDIA_TYPE = "application/javascript";
|
||||||
private static final int MAX_ALLOWED_SCRIPT_DOMAINS = 32;
|
private static final int MAX_ALLOWED_SCRIPT_DOMAINS = 32;
|
||||||
private static final Pattern ALLOWED_DOMAIN_PATTERN = Pattern.compile("^[a-z0-9.-]+(?::[0-9]{1,5})?$");
|
private static final Pattern ALLOWED_DOMAIN_PATTERN = Pattern.compile("^[a-z0-9.-]+(?::[0-9]{1,5})?$");
|
||||||
|
private static final EnumSet<AssetType> VISUAL_ASSET_TYPES = EnumSet.of(
|
||||||
|
AssetType.IMAGE,
|
||||||
|
AssetType.VIDEO,
|
||||||
|
AssetType.MODEL,
|
||||||
|
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;
|
||||||
@@ -205,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) {
|
||||||
@@ -213,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;
|
||||||
@@ -258,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) {
|
||||||
@@ -1164,6 +1213,75 @@ public class ChannelDirectoryService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Optional<AssetPatch> previewTransform(String broadcaster, String assetId, TransformRequest request) {
|
||||||
|
String normalized = normalize(broadcaster);
|
||||||
|
|
||||||
|
Asset asset = assetRepository
|
||||||
|
.findById(assetId)
|
||||||
|
.filter((stored) -> normalized.equals(stored.getBroadcaster()))
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset not found"));
|
||||||
|
|
||||||
|
if (!VISUAL_ASSET_TYPES.contains(asset.getAssetType())) {
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "Asset is not visual");
|
||||||
|
}
|
||||||
|
|
||||||
|
VisualAsset visual = visualAssetRepository
|
||||||
|
.findById(asset.getId())
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not visual"));
|
||||||
|
|
||||||
|
TransformRequest previewRequest = copyVisualTransformRequest(request);
|
||||||
|
validateVisualBounds(previewRequest);
|
||||||
|
|
||||||
|
AssetPatch.VisualSnapshot before = new AssetPatch.VisualSnapshot(
|
||||||
|
visual.getX(),
|
||||||
|
visual.getY(),
|
||||||
|
visual.getWidth(),
|
||||||
|
visual.getHeight(),
|
||||||
|
visual.getRotation(),
|
||||||
|
visual.getSpeed(),
|
||||||
|
visual.isMuted(),
|
||||||
|
displayOrderValue(asset),
|
||||||
|
visual.getAudioVolume()
|
||||||
|
);
|
||||||
|
|
||||||
|
VisualAsset previewState = new VisualAsset();
|
||||||
|
previewState.setId(visual.getId());
|
||||||
|
previewState.setName(visual.getName());
|
||||||
|
previewState.setX(visual.getX());
|
||||||
|
previewState.setY(visual.getY());
|
||||||
|
previewState.setWidth(visual.getWidth());
|
||||||
|
previewState.setHeight(visual.getHeight());
|
||||||
|
previewState.setRotation(visual.getRotation());
|
||||||
|
previewState.setSpeed(visual.getSpeed());
|
||||||
|
previewState.setMuted(visual.isMuted());
|
||||||
|
previewState.setAudioVolume(visual.getAudioVolume());
|
||||||
|
|
||||||
|
if (previewRequest.getX() != null) {
|
||||||
|
previewState.setX(previewRequest.getX());
|
||||||
|
}
|
||||||
|
if (previewRequest.getY() != null) {
|
||||||
|
previewState.setY(previewRequest.getY());
|
||||||
|
}
|
||||||
|
if (previewRequest.getWidth() != null) {
|
||||||
|
previewState.setWidth(previewRequest.getWidth());
|
||||||
|
}
|
||||||
|
if (previewRequest.getHeight() != null) {
|
||||||
|
previewState.setHeight(previewRequest.getHeight());
|
||||||
|
}
|
||||||
|
if (previewRequest.getRotation() != null) {
|
||||||
|
previewState.setRotation(previewRequest.getRotation());
|
||||||
|
}
|
||||||
|
if (previewRequest.getSpeed() != null) {
|
||||||
|
previewState.setSpeed(previewRequest.getSpeed());
|
||||||
|
}
|
||||||
|
if (previewRequest.getMuted() != null) {
|
||||||
|
previewState.setMuted(previewRequest.getMuted());
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetPatch patch = AssetPatch.fromVisualTransform(before, previewState, previewRequest);
|
||||||
|
return hasPatchChanges(patch) ? Optional.of(patch) : Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void reorderAssets(
|
public void reorderAssets(
|
||||||
String broadcaster,
|
String broadcaster,
|
||||||
@@ -1288,6 +1406,16 @@ public class ChannelDirectoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void validateVisualTransform(TransformRequest req) {
|
private void validateVisualTransform(TransformRequest req) {
|
||||||
|
validateVisualBounds(req);
|
||||||
|
if (req.getOrder() != null && req.getOrder() < 1) {
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "Order must be >= 1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateVisualBounds(TransformRequest req) {
|
||||||
|
if (req == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Settings settings = settingsService.get();
|
Settings settings = settingsService.get();
|
||||||
double maxSpeed = settings.getMaxAssetPlaybackSpeedFraction();
|
double maxSpeed = settings.getMaxAssetPlaybackSpeedFraction();
|
||||||
double minSpeed = settings.getMinAssetPlaybackSpeedFraction();
|
double minSpeed = settings.getMinAssetPlaybackSpeedFraction();
|
||||||
@@ -1297,30 +1425,49 @@ public class ChannelDirectoryService {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
req.getWidth() != null && (req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels)
|
req.getWidth() != null && (req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels)
|
||||||
) throw new ResponseStatusException(
|
) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
BAD_REQUEST,
|
BAD_REQUEST,
|
||||||
"Canvas width out of range [0 to " + canvasMaxSizePixels + "]"
|
"Canvas width out of range [0 to " + canvasMaxSizePixels + "]"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
req.getHeight() != null && (req.getHeight() <= 0 || req.getHeight() > canvasMaxSizePixels)
|
req.getHeight() != null && (req.getHeight() <= 0 || req.getHeight() > canvasMaxSizePixels)
|
||||||
) throw new ResponseStatusException(
|
) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
BAD_REQUEST,
|
BAD_REQUEST,
|
||||||
"Canvas height out of range [0 to " + canvasMaxSizePixels + "]"
|
"Canvas height out of range [0 to " + canvasMaxSizePixels + "]"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
req.getSpeed() != null && (req.getSpeed() < minSpeed || req.getSpeed() > maxSpeed)
|
req.getSpeed() != null && (req.getSpeed() < minSpeed || req.getSpeed() > maxSpeed)
|
||||||
) throw new ResponseStatusException(BAD_REQUEST, "Speed out of range [" + minSpeed + " to " + maxSpeed + "]");
|
) {
|
||||||
if (req.getOrder() != null && req.getOrder() < 1) throw new ResponseStatusException(
|
throw new ResponseStatusException(BAD_REQUEST, "Speed out of range [" + minSpeed + " to " + maxSpeed + "]");
|
||||||
BAD_REQUEST,
|
}
|
||||||
"Order must be >= 1"
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
req.getAudioVolume() != null && (req.getAudioVolume() < minVolume || req.getAudioVolume() > maxVolume)
|
req.getAudioVolume() != null && (req.getAudioVolume() < minVolume || req.getAudioVolume() > maxVolume)
|
||||||
) throw new ResponseStatusException(
|
) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
BAD_REQUEST,
|
BAD_REQUEST,
|
||||||
"Audio volume out of range [" + minVolume + " to " + maxVolume + "]"
|
"Audio volume out of range [" + minVolume + " to " + maxVolume + "]"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TransformRequest copyVisualTransformRequest(TransformRequest source) {
|
||||||
|
TransformRequest copy = new TransformRequest();
|
||||||
|
if (source == null) {
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
copy.setX(source.getX());
|
||||||
|
copy.setY(source.getY());
|
||||||
|
copy.setWidth(source.getWidth());
|
||||||
|
copy.setHeight(source.getHeight());
|
||||||
|
copy.setRotation(source.getRotation());
|
||||||
|
copy.setSpeed(source.getSpeed());
|
||||||
|
copy.setMuted(source.getMuted());
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
private void validateAudioTransform(TransformRequest req) {
|
private void validateAudioTransform(TransformRequest req) {
|
||||||
Settings settings = settingsService.get();
|
Settings settings = settingsService.get();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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/";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.service.git;
|
||||||
|
|
||||||
|
public record GitCommitInfo(String fullSha, String shortSha) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.service.git;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface GitCommitInfoSource {
|
||||||
|
Optional<GitCommitInfo> loadCommitInfo();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
UPDATE channels
|
||||||
|
SET max_volume_db = 0;
|
||||||
@@ -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";
|
||||||
@@ -34,6 +35,9 @@ export function createAdminConsole({
|
|||||||
const previewCache = new Map();
|
const previewCache = new Map();
|
||||||
const previewImageCache = new Map();
|
const previewImageCache = new Map();
|
||||||
const pendingTransformSaves = new Map();
|
const pendingTransformSaves = new Map();
|
||||||
|
const livePreviewQueue = new Map();
|
||||||
|
const livePreviewLastSent = new Map();
|
||||||
|
let livePreviewFrameScheduled = false;
|
||||||
const HANDLE_SIZE = 10;
|
const HANDLE_SIZE = 10;
|
||||||
const ROTATE_HANDLE_OFFSET = 32;
|
const ROTATE_HANDLE_OFFSET = 32;
|
||||||
const VOLUME_SLIDER_MAX = SETTINGS.maxAssetVolumeFraction * 100;
|
const VOLUME_SLIDER_MAX = SETTINGS.maxAssetVolumeFraction * 100;
|
||||||
@@ -181,6 +185,51 @@ export function createAdminConsole({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestLiveTransform(asset) {
|
||||||
|
if (!asset?.id || isAudioAsset(asset)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = buildTransformPayload(asset);
|
||||||
|
if (!payload || !Object.keys(payload).length) {
|
||||||
|
livePreviewQueue.delete(asset.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const serialized = JSON.stringify(payload);
|
||||||
|
if (livePreviewLastSent.get(asset.id) === serialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
livePreviewQueue.set(asset.id, { payload, serialized });
|
||||||
|
requestLivePreviewFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelLiveTransform(assetId) {
|
||||||
|
livePreviewQueue.delete(assetId);
|
||||||
|
livePreviewLastSent.delete(assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestLivePreviewFrame() {
|
||||||
|
if (livePreviewFrameScheduled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
livePreviewFrameScheduled = true;
|
||||||
|
requestAnimationFrame(sendQueuedLiveTransforms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendQueuedLiveTransforms() {
|
||||||
|
livePreviewFrameScheduled = false;
|
||||||
|
if (!livePreviewQueue.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!stompClient || (typeof stompClient.connected === "boolean" && !stompClient.connected)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [assetId, { payload, serialized }] of livePreviewQueue.entries()) {
|
||||||
|
stompClient.send(`/app/channel/${broadcaster}/assets/${assetId}/preview`, {}, JSON.stringify(payload));
|
||||||
|
livePreviewLastSent.set(assetId, serialized);
|
||||||
|
}
|
||||||
|
livePreviewQueue.clear();
|
||||||
|
}
|
||||||
|
|
||||||
function ensureLayerPosition(assetId, placement = "keep") {
|
function ensureLayerPosition(assetId, placement = "keep") {
|
||||||
ensureLayerPositionForState(layerState, assetId, placement);
|
ensureLayerPositionForState(layerState, assetId, placement);
|
||||||
}
|
}
|
||||||
@@ -522,6 +571,9 @@ export function createAdminConsole({
|
|||||||
handleEvent(body);
|
handleEvent(body);
|
||||||
});
|
});
|
||||||
fetchAssets();
|
fetchAssets();
|
||||||
|
if (livePreviewQueue.size) {
|
||||||
|
requestLivePreviewFrame();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
console.warn("WebSocket connection issue", error);
|
console.warn("WebSocket connection issue", error);
|
||||||
@@ -675,6 +727,11 @@ export function createAdminConsole({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
|
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
|
||||||
|
if (event.type === "PREVIEW" && event.patch) {
|
||||||
|
applyPreviewPatch(assetId, event.patch);
|
||||||
|
drawAndList(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.type === "DELETED") {
|
if (event.type === "DELETED") {
|
||||||
assets.delete(assetId);
|
assets.delete(assetId);
|
||||||
layerOrder = layerOrder.filter((id) => id !== assetId);
|
layerOrder = layerOrder.filter((id) => id !== assetId);
|
||||||
@@ -684,6 +741,7 @@ export function createAdminConsole({
|
|||||||
transformBaseline.delete(assetId);
|
transformBaseline.delete(assetId);
|
||||||
loopPlaybackState.delete(assetId);
|
loopPlaybackState.delete(assetId);
|
||||||
cancelPendingTransform(assetId);
|
cancelPendingTransform(assetId);
|
||||||
|
cancelLiveTransform(assetId);
|
||||||
if (selectedAssetId === assetId) {
|
if (selectedAssetId === assetId) {
|
||||||
setSelectedAssetId(null);
|
setSelectedAssetId(null);
|
||||||
}
|
}
|
||||||
@@ -708,6 +766,9 @@ export function createAdminConsole({
|
|||||||
if (!event) {
|
if (!event) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (event.type === "PREVIEW") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const { type, payload, patch } = event;
|
const { type, payload, patch } = event;
|
||||||
if (type === "DELETED" || type === "VISIBILITY") {
|
if (type === "DELETED" || type === "VISIBILITY") {
|
||||||
return true;
|
return true;
|
||||||
@@ -761,6 +822,43 @@ export function createAdminConsole({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyPreviewPatch(assetId, patch) {
|
||||||
|
if (!assetId || !patch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = assets.get(assetId);
|
||||||
|
if (!existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const merged = { ...existing, ...patch };
|
||||||
|
const isAudio = isAudioAsset(merged);
|
||||||
|
const isScript = isCodeAsset(merged);
|
||||||
|
if (patch.hidden) {
|
||||||
|
clearMedia(assetId);
|
||||||
|
loopPlaybackState.delete(assetId);
|
||||||
|
}
|
||||||
|
const targetOrder = Number.isFinite(patch.order) ? patch.order : null;
|
||||||
|
if (!isAudio && Number.isFinite(targetOrder)) {
|
||||||
|
if (isScript) {
|
||||||
|
const currentOrder = getScriptLayerOrder().filter((id) => id !== assetId);
|
||||||
|
const totalCount = currentOrder.length + 1;
|
||||||
|
const insertIndex = Math.max(0, Math.min(currentOrder.length, totalCount - Math.round(targetOrder)));
|
||||||
|
currentOrder.splice(insertIndex, 0, assetId);
|
||||||
|
scriptLayerOrder = currentOrder;
|
||||||
|
} else {
|
||||||
|
const currentOrder = getLayerOrder().filter((id) => id !== assetId);
|
||||||
|
const totalCount = currentOrder.length + 1;
|
||||||
|
const insertIndex = Math.max(0, Math.min(currentOrder.length, totalCount - Math.round(targetOrder)));
|
||||||
|
currentOrder.splice(insertIndex, 0, assetId);
|
||||||
|
layerOrder = currentOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assets.set(assetId, merged);
|
||||||
|
if (!isAudio) {
|
||||||
|
updateRenderState(merged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function markListDirty() {
|
function markListDirty() {
|
||||||
listNeedsRender = true;
|
listNeedsRender = true;
|
||||||
}
|
}
|
||||||
@@ -1078,6 +1176,7 @@ export function createAdminConsole({
|
|||||||
asset.width = nextWidth;
|
asset.width = nextWidth;
|
||||||
asset.height = nextHeight;
|
asset.height = nextHeight;
|
||||||
updateRenderState(asset);
|
updateRenderState(asset);
|
||||||
|
requestLiveTransform(asset);
|
||||||
requestDraw();
|
requestDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2469,6 +2568,7 @@ export function createAdminConsole({
|
|||||||
layerOrder = layerOrder.filter((id) => id !== asset.id);
|
layerOrder = layerOrder.filter((id) => id !== asset.id);
|
||||||
scriptLayerOrder = scriptLayerOrder.filter((id) => id !== asset.id);
|
scriptLayerOrder = scriptLayerOrder.filter((id) => id !== asset.id);
|
||||||
cancelPendingTransform(asset.id);
|
cancelPendingTransform(asset.id);
|
||||||
|
cancelLiveTransform(asset.id);
|
||||||
if (selectedAssetId === asset.id) {
|
if (selectedAssetId === asset.id) {
|
||||||
setSelectedAssetId(null);
|
setSelectedAssetId(null);
|
||||||
}
|
}
|
||||||
@@ -2605,6 +2705,7 @@ export function createAdminConsole({
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
cancelPendingTransform(asset.id);
|
cancelPendingTransform(asset.id);
|
||||||
|
cancelLiveTransform(asset.id);
|
||||||
const payload = buildTransformPayload(asset);
|
const payload = buildTransformPayload(asset);
|
||||||
if (!Object.keys(payload).length) {
|
if (!Object.keys(payload).length) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
@@ -2749,6 +2850,7 @@ export function createAdminConsole({
|
|||||||
asset.y = point.y - interactionState.offsetY;
|
asset.y = point.y - interactionState.offsetY;
|
||||||
updateRenderState(asset);
|
updateRenderState(asset);
|
||||||
canvas.style.cursor = "grabbing";
|
canvas.style.cursor = "grabbing";
|
||||||
|
requestLiveTransform(asset);
|
||||||
requestDraw();
|
requestDraw();
|
||||||
} else if (interactionState.mode === "resize") {
|
} else if (interactionState.mode === "resize") {
|
||||||
resizeFromHandle(interactionState, point);
|
resizeFromHandle(interactionState, point);
|
||||||
@@ -2758,6 +2860,7 @@ export function createAdminConsole({
|
|||||||
asset.rotation = (interactionState.startRotation || 0) + (angle - interactionState.startAngle);
|
asset.rotation = (interactionState.startRotation || 0) + (angle - interactionState.startAngle);
|
||||||
updateRenderState(asset);
|
updateRenderState(asset);
|
||||||
canvas.style.cursor = "grabbing";
|
canvas.style.cursor = "grabbing";
|
||||||
|
requestLiveTransform(asset);
|
||||||
requestDraw();
|
requestDraw();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2771,6 +2874,7 @@ export function createAdminConsole({
|
|||||||
canvas.style.cursor = "default";
|
canvas.style.cursor = "default";
|
||||||
drawAndList();
|
drawAndList();
|
||||||
if (asset) {
|
if (asset) {
|
||||||
|
cancelLiveTransform(asset.id);
|
||||||
persistTransform(asset);
|
persistTransform(asset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +186,11 @@ export class BroadcastRenderer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
|
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
|
||||||
|
if (event.type === "PREVIEW" && event.patch) {
|
||||||
|
this.applyPreviewPatch(assetId, event.patch);
|
||||||
|
this.draw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.type === "VISIBILITY") {
|
if (event.type === "VISIBILITY") {
|
||||||
this.handleVisibilityEvent(event);
|
this.handleVisibilityEvent(event);
|
||||||
return;
|
return;
|
||||||
@@ -276,15 +290,12 @@ export class BroadcastRenderer {
|
|||||||
if (!assetId || !patch) {
|
if (!assetId || !patch) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sanitizedPatch = Object.fromEntries(
|
const sanitizedPatch = this.sanitizePatch(patch);
|
||||||
Object.entries(patch).filter(([, value]) => value !== null && value !== undefined),
|
|
||||||
);
|
|
||||||
const existing = this.state.assets.get(assetId);
|
const existing = this.state.assets.get(assetId);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const merged = this.normalizePayload({ ...existing, ...sanitizedPatch });
|
const merged = this.normalizePayload({ ...existing, ...sanitizedPatch });
|
||||||
console.log(merged);
|
|
||||||
const isVisual = isVisualAsset(merged);
|
const isVisual = isVisualAsset(merged);
|
||||||
const isScript = isCodeAsset(merged);
|
const isScript = isCodeAsset(merged);
|
||||||
if (sanitizedPatch.hidden) {
|
if (sanitizedPatch.hidden) {
|
||||||
@@ -316,6 +327,54 @@ export class BroadcastRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyPreviewPatch(assetId, patch) {
|
||||||
|
if (!assetId || !patch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sanitizedPatch = this.sanitizePatch(patch);
|
||||||
|
if (!Object.keys(sanitizedPatch).length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = this.state.assets.get(assetId);
|
||||||
|
if (!existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const merged = this.normalizePayload({ ...existing, ...sanitizedPatch });
|
||||||
|
if (sanitizedPatch.hidden) {
|
||||||
|
this.hideAssetWithTransition(merged);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isVisual = isVisualAsset(merged);
|
||||||
|
const isScript = isCodeAsset(merged);
|
||||||
|
const targetOrder = Number.isFinite(sanitizedPatch.order) ? sanitizedPatch.order : null;
|
||||||
|
if (Number.isFinite(targetOrder)) {
|
||||||
|
if (isScript) {
|
||||||
|
const currentOrder = getScriptLayerOrder(this.state).filter((id) => id !== assetId);
|
||||||
|
const totalCount = currentOrder.length + 1;
|
||||||
|
const insertIndex = Math.max(0, Math.min(currentOrder.length, totalCount - Math.round(targetOrder)));
|
||||||
|
currentOrder.splice(insertIndex, 0, assetId);
|
||||||
|
this.state.scriptLayerOrder = currentOrder;
|
||||||
|
this.applyScriptCanvasOrder();
|
||||||
|
} else if (isVisual) {
|
||||||
|
const currentOrder = getLayerOrder(this.state).filter((id) => id !== assetId);
|
||||||
|
const totalCount = currentOrder.length + 1;
|
||||||
|
const insertIndex = Math.max(0, Math.min(currentOrder.length, totalCount - Math.round(targetOrder)));
|
||||||
|
currentOrder.splice(insertIndex, 0, assetId);
|
||||||
|
this.state.layerOrder = currentOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.state.assets.set(assetId, merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitizePatch(patch) {
|
||||||
|
if (!patch) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(patch).filter(([, value]) => value !== null && value !== undefined),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
draw() {
|
draw() {
|
||||||
if (this.frameScheduled) {
|
if (this.frameScheduled) {
|
||||||
this.pendingDraw = true;
|
this.pendingDraw = true;
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user