mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Improve channel admin page
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
package com.imgfloat.app.controller;
|
||||
|
||||
import com.imgfloat.app.model.AdminRequest;
|
||||
import com.imgfloat.app.model.Asset;
|
||||
import com.imgfloat.app.model.AssetView;
|
||||
import com.imgfloat.app.model.CanvasSettingsRequest;
|
||||
import com.imgfloat.app.model.TransformRequest;
|
||||
import com.imgfloat.app.model.TwitchUserProfile;
|
||||
@@ -94,7 +94,7 @@ public class ChannelApiController {
|
||||
}
|
||||
|
||||
@GetMapping("/assets")
|
||||
public Collection<Asset> listAssets(@PathVariable("broadcaster") String broadcaster,
|
||||
public Collection<AssetView> listAssets(@PathVariable("broadcaster") String broadcaster,
|
||||
OAuth2AuthenticationToken authentication) {
|
||||
String login = TwitchUser.from(authentication).login();
|
||||
if (!channelDirectoryService.isBroadcaster(broadcaster, login)
|
||||
@@ -105,7 +105,7 @@ public class ChannelApiController {
|
||||
}
|
||||
|
||||
@GetMapping("/assets/visible")
|
||||
public Collection<Asset> listVisible(@PathVariable("broadcaster") String broadcaster,
|
||||
public Collection<AssetView> listVisible(@PathVariable("broadcaster") String broadcaster,
|
||||
OAuth2AuthenticationToken authentication) {
|
||||
String login = TwitchUser.from(authentication).login();
|
||||
if (!channelDirectoryService.isBroadcaster(broadcaster, login)) {
|
||||
@@ -132,7 +132,7 @@ public class ChannelApiController {
|
||||
}
|
||||
|
||||
@PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<Asset> createAsset(@PathVariable("broadcaster") String broadcaster,
|
||||
public ResponseEntity<AssetView> createAsset(@PathVariable("broadcaster") String broadcaster,
|
||||
@org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file,
|
||||
OAuth2AuthenticationToken authentication) {
|
||||
String login = TwitchUser.from(authentication).login();
|
||||
@@ -150,7 +150,7 @@ public class ChannelApiController {
|
||||
}
|
||||
|
||||
@PutMapping("/assets/{assetId}/transform")
|
||||
public ResponseEntity<Asset> transform(@PathVariable("broadcaster") String broadcaster,
|
||||
public ResponseEntity<AssetView> transform(@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
@Valid @RequestBody TransformRequest request,
|
||||
OAuth2AuthenticationToken authentication) {
|
||||
@@ -162,7 +162,7 @@ public class ChannelApiController {
|
||||
}
|
||||
|
||||
@PutMapping("/assets/{assetId}/visibility")
|
||||
public ResponseEntity<Asset> visibility(@PathVariable("broadcaster") String broadcaster,
|
||||
public ResponseEntity<AssetView> visibility(@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
@RequestBody VisibilityRequest request,
|
||||
OAuth2AuthenticationToken authentication) {
|
||||
@@ -173,6 +173,19 @@ public class ChannelApiController {
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
|
||||
}
|
||||
|
||||
@GetMapping("/assets/{assetId}/content")
|
||||
public ResponseEntity<byte[]> getAssetContent(@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
OAuth2AuthenticationToken authentication) {
|
||||
String login = TwitchUser.from(authentication).login();
|
||||
ensureAuthorized(broadcaster, login);
|
||||
return channelDirectoryService.getAssetContent(broadcaster, assetId)
|
||||
.map(content -> ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||
.body(content.bytes()))
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
|
||||
}
|
||||
|
||||
@DeleteMapping("/assets/{assetId}")
|
||||
public ResponseEntity<?> delete(@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
|
||||
@@ -53,7 +53,7 @@ public class Asset {
|
||||
this.rotation = 0;
|
||||
this.speed = 1.0;
|
||||
this.muted = false;
|
||||
this.zIndex = 0;
|
||||
this.zIndex = 1;
|
||||
this.hidden = false;
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
@@ -71,14 +71,14 @@ public class Asset {
|
||||
if (this.name == null || this.name.isBlank()) {
|
||||
this.name = this.id;
|
||||
}
|
||||
if (this.speed == null || this.speed <= 0) {
|
||||
if (this.speed == null) {
|
||||
this.speed = 1.0;
|
||||
}
|
||||
if (this.muted == null) {
|
||||
this.muted = Boolean.FALSE;
|
||||
}
|
||||
if (this.zIndex == null) {
|
||||
this.zIndex = 0;
|
||||
if (this.zIndex == null || this.zIndex < 1) {
|
||||
this.zIndex = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,11 +203,11 @@ public class Asset {
|
||||
}
|
||||
|
||||
public Integer getZIndex() {
|
||||
return zIndex == null ? 0 : zIndex;
|
||||
return zIndex == null ? 1 : Math.max(1, zIndex);
|
||||
}
|
||||
|
||||
public void setZIndex(Integer zIndex) {
|
||||
this.zIndex = zIndex;
|
||||
this.zIndex = zIndex == null ? null : Math.max(1, zIndex);
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
|
||||
@@ -10,33 +10,33 @@ public class AssetEvent {
|
||||
|
||||
private Type type;
|
||||
private String channel;
|
||||
private Asset payload;
|
||||
private AssetView payload;
|
||||
private String assetId;
|
||||
|
||||
public static AssetEvent created(String channel, Asset asset) {
|
||||
public static AssetEvent created(String channel, AssetView asset) {
|
||||
AssetEvent event = new AssetEvent();
|
||||
event.type = Type.CREATED;
|
||||
event.channel = channel;
|
||||
event.payload = asset;
|
||||
event.assetId = asset.getId();
|
||||
event.assetId = asset.id();
|
||||
return event;
|
||||
}
|
||||
|
||||
public static AssetEvent updated(String channel, Asset asset) {
|
||||
public static AssetEvent updated(String channel, AssetView asset) {
|
||||
AssetEvent event = new AssetEvent();
|
||||
event.type = Type.UPDATED;
|
||||
event.channel = channel;
|
||||
event.payload = asset;
|
||||
event.assetId = asset.getId();
|
||||
event.assetId = asset.id();
|
||||
return event;
|
||||
}
|
||||
|
||||
public static AssetEvent visibility(String channel, Asset asset) {
|
||||
public static AssetEvent visibility(String channel, AssetView asset) {
|
||||
AssetEvent event = new AssetEvent();
|
||||
event.type = Type.VISIBILITY;
|
||||
event.channel = channel;
|
||||
event.payload = asset;
|
||||
event.assetId = asset.getId();
|
||||
event.assetId = asset.id();
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ public class AssetEvent {
|
||||
return channel;
|
||||
}
|
||||
|
||||
public Asset getPayload() {
|
||||
public AssetView getPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
||||
43
src/main/java/com/imgfloat/app/model/AssetView.java
Normal file
43
src/main/java/com/imgfloat/app/model/AssetView.java
Normal file
@@ -0,0 +1,43 @@
|
||||
package com.imgfloat.app.model;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record AssetView(
|
||||
String id,
|
||||
String broadcaster,
|
||||
String name,
|
||||
String url,
|
||||
double x,
|
||||
double y,
|
||||
double width,
|
||||
double height,
|
||||
double rotation,
|
||||
Double speed,
|
||||
Boolean muted,
|
||||
String mediaType,
|
||||
String originalMediaType,
|
||||
Integer zIndex,
|
||||
boolean hidden,
|
||||
Instant createdAt
|
||||
) {
|
||||
public static AssetView from(String broadcaster, Asset asset) {
|
||||
return new AssetView(
|
||||
asset.getId(),
|
||||
asset.getBroadcaster(),
|
||||
asset.getName(),
|
||||
"/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content",
|
||||
asset.getX(),
|
||||
asset.getY(),
|
||||
asset.getWidth(),
|
||||
asset.getHeight(),
|
||||
asset.getRotation(),
|
||||
asset.getSpeed(),
|
||||
asset.isMuted(),
|
||||
asset.getMediaType(),
|
||||
asset.getOriginalMediaType(),
|
||||
asset.getZIndex(),
|
||||
asset.isHidden(),
|
||||
asset.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.imgfloat.app.service;
|
||||
import com.imgfloat.app.model.Asset;
|
||||
import com.imgfloat.app.model.AssetEvent;
|
||||
import com.imgfloat.app.model.Channel;
|
||||
import com.imgfloat.app.model.AssetView;
|
||||
import com.imgfloat.app.model.CanvasSettingsRequest;
|
||||
import com.imgfloat.app.model.TransformRequest;
|
||||
import com.imgfloat.app.model.VisibilityRequest;
|
||||
@@ -85,12 +86,14 @@ public class ChannelDirectoryService {
|
||||
return removed;
|
||||
}
|
||||
|
||||
public Collection<Asset> getAssetsForAdmin(String broadcaster) {
|
||||
return sortByZIndex(assetRepository.findByBroadcaster(normalize(broadcaster)));
|
||||
public Collection<AssetView> getAssetsForAdmin(String broadcaster) {
|
||||
String normalized = normalize(broadcaster);
|
||||
return sortAndMapAssets(normalized, assetRepository.findByBroadcaster(normalized));
|
||||
}
|
||||
|
||||
public Collection<Asset> getVisibleAssets(String broadcaster) {
|
||||
return sortByZIndex(assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster)));
|
||||
public Collection<AssetView> getVisibleAssets(String broadcaster) {
|
||||
String normalized = normalize(broadcaster);
|
||||
return sortAndMapAssets(normalized, assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster)));
|
||||
}
|
||||
|
||||
public CanvasSettingsRequest getCanvasSettings(String broadcaster) {
|
||||
@@ -106,7 +109,7 @@ public class ChannelDirectoryService {
|
||||
return new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight());
|
||||
}
|
||||
|
||||
public Optional<Asset> createAsset(String broadcaster, MultipartFile file) throws IOException {
|
||||
public Optional<AssetView> createAsset(String broadcaster, MultipartFile file) throws IOException {
|
||||
Channel channel = getOrCreateChannel(broadcaster);
|
||||
byte[] bytes = file.getBytes();
|
||||
String mediaType = detectMediaType(file, bytes);
|
||||
@@ -132,11 +135,12 @@ public class ChannelDirectoryService {
|
||||
asset.setZIndex(nextZIndex(channel.getBroadcaster()));
|
||||
|
||||
assetRepository.save(asset);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset));
|
||||
return Optional.of(asset);
|
||||
AssetView view = AssetView.from(channel.getBroadcaster(), asset);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view));
|
||||
return Optional.of(view);
|
||||
}
|
||||
|
||||
public Optional<Asset> updateTransform(String broadcaster, String assetId, TransformRequest request) {
|
||||
public Optional<AssetView> updateTransform(String broadcaster, String assetId, TransformRequest request) {
|
||||
String normalized = normalize(broadcaster);
|
||||
return assetRepository.findById(assetId)
|
||||
.filter(asset -> normalized.equals(asset.getBroadcaster()))
|
||||
@@ -149,27 +153,29 @@ public class ChannelDirectoryService {
|
||||
if (request.getZIndex() != null) {
|
||||
asset.setZIndex(request.getZIndex());
|
||||
}
|
||||
if (request.getSpeed() != null && request.getSpeed() > 0) {
|
||||
if (request.getSpeed() != null && request.getSpeed() >= 0) {
|
||||
asset.setSpeed(request.getSpeed());
|
||||
}
|
||||
if (request.getMuted() != null && asset.isVideo()) {
|
||||
asset.setMuted(request.getMuted());
|
||||
}
|
||||
assetRepository.save(asset);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, asset));
|
||||
return asset;
|
||||
AssetView view = AssetView.from(normalized, asset);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view));
|
||||
return view;
|
||||
});
|
||||
}
|
||||
|
||||
public Optional<Asset> updateVisibility(String broadcaster, String assetId, VisibilityRequest request) {
|
||||
public Optional<AssetView> updateVisibility(String broadcaster, String assetId, VisibilityRequest request) {
|
||||
String normalized = normalize(broadcaster);
|
||||
return assetRepository.findById(assetId)
|
||||
.filter(asset -> normalized.equals(asset.getBroadcaster()))
|
||||
.map(asset -> {
|
||||
asset.setHidden(request.isHidden());
|
||||
assetRepository.save(asset);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, asset));
|
||||
return asset;
|
||||
AssetView view = AssetView.from(normalized, asset);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, view));
|
||||
return view;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -185,6 +191,13 @@ public class ChannelDirectoryService {
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
public Optional<AssetContent> getAssetContent(String broadcaster, String assetId) {
|
||||
String normalized = normalize(broadcaster);
|
||||
return assetRepository.findById(assetId)
|
||||
.filter(asset -> normalized.equals(asset.getBroadcaster()))
|
||||
.flatMap(this::decodeAssetData);
|
||||
}
|
||||
|
||||
public boolean isBroadcaster(String broadcaster, String username) {
|
||||
return broadcaster != null && broadcaster.equalsIgnoreCase(username);
|
||||
}
|
||||
@@ -215,13 +228,36 @@ public class ChannelDirectoryService {
|
||||
return value == null ? null : value.toLowerCase();
|
||||
}
|
||||
|
||||
private List<Asset> sortByZIndex(Collection<Asset> assets) {
|
||||
private List<AssetView> sortAndMapAssets(String broadcaster, Collection<Asset> assets) {
|
||||
return assets.stream()
|
||||
.sorted(Comparator.comparingInt(Asset::getZIndex)
|
||||
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder())))
|
||||
.map(asset -> AssetView.from(broadcaster, asset))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Optional<AssetContent> decodeAssetData(Asset asset) {
|
||||
String url = asset.getUrl();
|
||||
if (url == null || !url.startsWith("data:")) {
|
||||
return Optional.empty();
|
||||
}
|
||||
int commaIndex = url.indexOf(',');
|
||||
if (commaIndex < 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String metadata = url.substring(5, commaIndex);
|
||||
String[] parts = metadata.split(";", 2);
|
||||
String mediaType = parts.length > 0 && !parts[0].isBlank() ? parts[0] : "application/octet-stream";
|
||||
String encoded = url.substring(commaIndex + 1);
|
||||
try {
|
||||
byte[] bytes = Base64.getDecoder().decode(encoded);
|
||||
return Optional.of(new AssetContent(bytes, mediaType));
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.warn("Unable to decode asset data for {}", asset.getId(), e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private int nextZIndex(String broadcaster) {
|
||||
return assetRepository.findByBroadcaster(normalize(broadcaster)).stream()
|
||||
.mapToInt(Asset::getZIndex)
|
||||
@@ -426,6 +462,8 @@ public class ChannelDirectoryService {
|
||||
return new Dimension(640, 360);
|
||||
}
|
||||
|
||||
public record AssetContent(byte[] bytes, String mediaType) { }
|
||||
|
||||
private record OptimizedAsset(byte[] bytes, String mediaType, int width, int height) { }
|
||||
|
||||
private record GifFrame(BufferedImage image, int delayMs) { }
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: #0f172a;
|
||||
@@ -565,14 +573,14 @@ body {
|
||||
|
||||
.asset-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
background: #111827;
|
||||
border: 1px solid #1f2937;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.asset-item.selected {
|
||||
@@ -580,6 +588,17 @@ body {
|
||||
box-shadow: 0 0 0 1px rgba(124, 58, 237, 0.6);
|
||||
}
|
||||
|
||||
.asset-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.asset-row .meta {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.asset-item .meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -595,6 +614,10 @@ body {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.asset-detail {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -641,6 +664,17 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.control-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
@@ -653,6 +687,10 @@ body {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.control-grid.three-col {
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.control-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -669,6 +707,15 @@ body {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.range-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
margin-top: -6px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.number-input {
|
||||
position: relative;
|
||||
padding-right: 48px !important;
|
||||
@@ -697,6 +744,10 @@ body {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-actions.compact button {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.control-actions.filled {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(124, 58, 237, 0.22);
|
||||
|
||||
@@ -10,9 +10,11 @@ const assets = new Map();
|
||||
const mediaCache = new Map();
|
||||
const renderStates = new Map();
|
||||
const animatedCache = new Map();
|
||||
let drawPending = false;
|
||||
let zOrderDirty = true;
|
||||
let zOrderCache = [];
|
||||
let selectedAssetId = null;
|
||||
let interactionState = null;
|
||||
let animationFrameId = null;
|
||||
let lastSizeInputChanged = null;
|
||||
const HANDLE_SIZE = 10;
|
||||
const ROTATE_HANDLE_OFFSET = 32;
|
||||
@@ -24,17 +26,18 @@ const aspectLockInput = document.getElementById('maintain-aspect');
|
||||
const speedInput = document.getElementById('asset-speed');
|
||||
const muteInput = document.getElementById('asset-muted');
|
||||
const selectedAssetName = document.getElementById('selected-asset-name');
|
||||
const selectedAssetMeta = document.getElementById('selected-asset-meta');
|
||||
const selectedZLabel = document.getElementById('asset-z-level');
|
||||
const selectedTypeLabel = document.getElementById('asset-type-label');
|
||||
const selectedVisibilityBadge = document.getElementById('selected-asset-visibility');
|
||||
const selectedToggleBtn = document.getElementById('selected-asset-toggle');
|
||||
const selectedDeleteBtn = document.getElementById('selected-asset-delete');
|
||||
const playbackSection = document.getElementById('playback-section');
|
||||
const controlsPlaceholder = document.getElementById('asset-controls-placeholder');
|
||||
const aspectLockState = new Map();
|
||||
|
||||
if (widthInput) widthInput.addEventListener('input', () => handleSizeInputChange('width'));
|
||||
if (heightInput) heightInput.addEventListener('input', () => handleSizeInputChange('height'));
|
||||
if (speedInput) speedInput.addEventListener('change', updatePlaybackFromInputs);
|
||||
if (speedInput) speedInput.addEventListener('input', updatePlaybackFromInputs);
|
||||
if (muteInput) muteInput.addEventListener('change', updateMuteFromInput);
|
||||
if (selectedToggleBtn) selectedToggleBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
@@ -97,50 +100,90 @@ function resizeCanvas() {
|
||||
overlayFrame.style.left = `${(bounds.width - displayWidth) / 2}px`;
|
||||
overlayFrame.style.top = `${(bounds.height - displayHeight) / 2}px`;
|
||||
}
|
||||
draw();
|
||||
requestDraw();
|
||||
}
|
||||
|
||||
function renderAssets(list) {
|
||||
list.forEach((asset) => assets.set(asset.id, asset));
|
||||
list.forEach(storeAsset);
|
||||
drawAndList();
|
||||
}
|
||||
|
||||
function storeAsset(asset) {
|
||||
if (!asset) return;
|
||||
asset.zIndex = Math.max(1, asset.zIndex ?? 1);
|
||||
if (asset.createdAt && typeof asset.createdAtMs === 'undefined') {
|
||||
asset.createdAtMs = new Date(asset.createdAt).getTime();
|
||||
}
|
||||
assets.set(asset.id, asset);
|
||||
zOrderDirty = true;
|
||||
if (!renderStates.has(asset.id)) {
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
}
|
||||
}
|
||||
|
||||
function updateRenderState(asset) {
|
||||
if (!asset) return;
|
||||
const state = renderStates.get(asset.id) || {};
|
||||
state.x = asset.x;
|
||||
state.y = asset.y;
|
||||
state.width = asset.width;
|
||||
state.height = asset.height;
|
||||
state.rotation = asset.rotation;
|
||||
renderStates.set(asset.id, state);
|
||||
}
|
||||
|
||||
function handleEvent(event) {
|
||||
if (event.type === 'DELETED') {
|
||||
assets.delete(event.assetId);
|
||||
zOrderDirty = true;
|
||||
clearMedia(event.assetId);
|
||||
renderStates.delete(event.assetId);
|
||||
if (selectedAssetId === event.assetId) {
|
||||
selectedAssetId = null;
|
||||
}
|
||||
} else if (event.payload) {
|
||||
assets.set(event.payload.id, event.payload);
|
||||
storeAsset(event.payload);
|
||||
ensureMedia(event.payload);
|
||||
}
|
||||
drawAndList();
|
||||
}
|
||||
|
||||
function drawAndList() {
|
||||
draw();
|
||||
requestDraw();
|
||||
renderAssetList();
|
||||
}
|
||||
|
||||
function requestDraw() {
|
||||
if (drawPending) {
|
||||
return;
|
||||
}
|
||||
drawPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
drawPending = false;
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
getZOrderedAssets().forEach((asset) => drawAsset(asset));
|
||||
}
|
||||
|
||||
function getZOrderedAssets() {
|
||||
return Array.from(assets.values()).sort(zComparator);
|
||||
if (zOrderDirty) {
|
||||
zOrderCache = Array.from(assets.values()).sort(zComparator);
|
||||
zOrderDirty = false;
|
||||
}
|
||||
return zOrderCache;
|
||||
}
|
||||
|
||||
function zComparator(a, b) {
|
||||
const aZ = a?.zIndex ?? 0;
|
||||
const bZ = b?.zIndex ?? 0;
|
||||
const aZ = a?.zIndex ?? 1;
|
||||
const bZ = b?.zIndex ?? 1;
|
||||
if (aZ !== bZ) {
|
||||
return aZ - bZ;
|
||||
}
|
||||
return new Date(a?.createdAt || 0) - new Date(b?.createdAt || 0);
|
||||
return (a?.createdAtMs || 0) - (b?.createdAtMs || 0);
|
||||
}
|
||||
|
||||
function drawAsset(asset) {
|
||||
@@ -181,16 +224,14 @@ function drawAsset(asset) {
|
||||
|
||||
function smoothState(asset) {
|
||||
const previous = renderStates.get(asset.id) || { ...asset };
|
||||
const factor = interactionState && interactionState.assetId === asset.id ? 0.5 : 0.18;
|
||||
const next = {
|
||||
x: lerp(previous.x, asset.x, factor),
|
||||
y: lerp(previous.y, asset.y, factor),
|
||||
width: lerp(previous.width, asset.width, factor),
|
||||
height: lerp(previous.height, asset.height, factor),
|
||||
rotation: smoothAngle(previous.rotation, asset.rotation, factor)
|
||||
};
|
||||
renderStates.set(asset.id, next);
|
||||
return next;
|
||||
const factor = interactionState && interactionState.assetId === asset.id ? 0.45 : 0.18;
|
||||
previous.x = lerp(previous.x, asset.x, factor);
|
||||
previous.y = lerp(previous.y, asset.y, factor);
|
||||
previous.width = lerp(previous.width, asset.width, factor);
|
||||
previous.height = lerp(previous.height, asset.height, factor);
|
||||
previous.rotation = smoothAngle(previous.rotation, asset.rotation, factor);
|
||||
renderStates.set(asset.id, previous);
|
||||
return previous;
|
||||
}
|
||||
|
||||
function smoothAngle(current, target, factor) {
|
||||
@@ -373,8 +414,8 @@ function resizeFromHandle(state, point) {
|
||||
asset.y = basis.y + shift.y;
|
||||
asset.width = nextWidth;
|
||||
asset.height = nextHeight;
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
draw();
|
||||
updateRenderState(asset);
|
||||
requestDraw();
|
||||
}
|
||||
|
||||
function updateHoverCursor(point) {
|
||||
@@ -390,19 +431,9 @@ function updateHoverCursor(point) {
|
||||
canvas.style.cursor = hit ? 'move' : 'default';
|
||||
}
|
||||
|
||||
function startRenderLoop() {
|
||||
if (animationFrameId) {
|
||||
return;
|
||||
}
|
||||
const tick = () => {
|
||||
draw();
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
};
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function isVideoAsset(asset) {
|
||||
return (asset.mediaType && asset.mediaType.startsWith('video/')) || asset.url?.startsWith('data:video/');
|
||||
const type = asset?.mediaType || asset?.originalMediaType || '';
|
||||
return type.startsWith('video/');
|
||||
}
|
||||
|
||||
function isVideoElement(element) {
|
||||
@@ -419,7 +450,7 @@ function getDisplayMediaType(asset) {
|
||||
}
|
||||
|
||||
function isGifAsset(asset) {
|
||||
return (asset.mediaType && asset.mediaType.toLowerCase() === 'image/gif') || asset.url?.startsWith('data:image/gif');
|
||||
return asset?.mediaType?.toLowerCase() === 'image/gif';
|
||||
}
|
||||
|
||||
function isDrawable(element) {
|
||||
@@ -471,12 +502,17 @@ function ensureMedia(asset) {
|
||||
element.muted = asset.muted ?? true;
|
||||
element.playsInline = true;
|
||||
element.autoplay = true;
|
||||
element.onloadeddata = draw;
|
||||
element.onloadeddata = requestDraw;
|
||||
element.src = asset.url;
|
||||
element.playbackRate = asset.speed && asset.speed > 0 ? asset.speed : 1;
|
||||
element.play().catch(() => {});
|
||||
const playback = asset.speed ?? 1;
|
||||
element.playbackRate = Math.max(playback, 0.01);
|
||||
if (playback === 0) {
|
||||
element.pause();
|
||||
} else {
|
||||
element.onload = draw;
|
||||
element.play().catch(() => {});
|
||||
}
|
||||
} else {
|
||||
element.onload = requestDraw;
|
||||
element.src = asset.url;
|
||||
}
|
||||
mediaCache.set(asset.id, element);
|
||||
@@ -537,7 +573,7 @@ function scheduleNextFrame(controller) {
|
||||
createImageBitmap(image)
|
||||
.then((bitmap) => {
|
||||
controller.bitmap = bitmap;
|
||||
draw();
|
||||
requestDraw();
|
||||
})
|
||||
.finally(() => image.close?.());
|
||||
|
||||
@@ -564,24 +600,34 @@ function applyMediaSettings(element, asset) {
|
||||
if (!isVideoElement(element)) {
|
||||
return;
|
||||
}
|
||||
const nextSpeed = asset.speed && asset.speed > 0 ? asset.speed : 1;
|
||||
if (element.playbackRate !== nextSpeed) {
|
||||
element.playbackRate = nextSpeed;
|
||||
const nextSpeed = asset.speed ?? 1;
|
||||
const effectiveSpeed = Math.max(nextSpeed, 0.01);
|
||||
if (element.playbackRate !== effectiveSpeed) {
|
||||
element.playbackRate = effectiveSpeed;
|
||||
}
|
||||
const shouldMute = asset.muted ?? true;
|
||||
if (element.muted !== shouldMute) {
|
||||
element.muted = shouldMute;
|
||||
}
|
||||
if (element.paused) {
|
||||
if (nextSpeed === 0) {
|
||||
element.pause();
|
||||
} else if (element.paused) {
|
||||
element.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function renderAssetList() {
|
||||
const list = document.getElementById('asset-list');
|
||||
if (controlsPlaceholder && controlsPanel && controlsPanel.parentElement !== controlsPlaceholder) {
|
||||
controlsPlaceholder.appendChild(controlsPanel);
|
||||
}
|
||||
if (controlsPanel) {
|
||||
controlsPanel.classList.add('hidden');
|
||||
}
|
||||
list.innerHTML = '';
|
||||
|
||||
if (!assets.size) {
|
||||
selectedAssetId = null;
|
||||
const empty = document.createElement('li');
|
||||
empty.textContent = 'No assets yet. Upload to get started.';
|
||||
list.appendChild(empty);
|
||||
@@ -600,6 +646,9 @@ function renderAssetList() {
|
||||
li.classList.add('hidden');
|
||||
}
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'asset-row';
|
||||
|
||||
const preview = createPreviewElement(asset);
|
||||
|
||||
const meta = document.createElement('div');
|
||||
@@ -607,7 +656,7 @@ function renderAssetList() {
|
||||
const name = document.createElement('strong');
|
||||
name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`;
|
||||
const details = document.createElement('small');
|
||||
details.textContent = `Z ${asset.zIndex ?? 0} · ${Math.round(asset.width)}x${Math.round(asset.height)} · ${getDisplayMediaType(asset)} · ${asset.hidden ? 'Hidden' : 'Visible'}`;
|
||||
details.textContent = `Z ${asset.zIndex ?? 1} · ${Math.round(asset.width)}x${Math.round(asset.height)} · ${getDisplayMediaType(asset)} · ${asset.hidden ? 'Hidden' : 'Visible'}`;
|
||||
meta.appendChild(name);
|
||||
meta.appendChild(details);
|
||||
|
||||
@@ -617,7 +666,8 @@ function renderAssetList() {
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
toggleBtn.className = 'ghost icon-button';
|
||||
toggleBtn.innerHTML = `<span class="icon" aria-hidden="true">${asset.hidden ? '👁️' : '🙈'}</span><span class="label">${asset.hidden ? 'Show' : 'Hide'}</span>`;
|
||||
toggleBtn.innerHTML = `<i class="fa-solid ${asset.hidden ? 'fa-eye' : 'fa-eye-slash'}"></i>`;
|
||||
toggleBtn.title = asset.hidden ? 'Show asset' : 'Hide asset';
|
||||
toggleBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectedAssetId = asset.id;
|
||||
@@ -627,7 +677,8 @@ function renderAssetList() {
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.type = 'button';
|
||||
deleteBtn.className = 'ghost danger icon-button';
|
||||
deleteBtn.innerHTML = '<span class="icon" aria-hidden="true">🗑️</span><span class="label">Delete</span>';
|
||||
deleteBtn.innerHTML = '<i class="fa-solid fa-trash"></i>';
|
||||
deleteBtn.title = 'Delete asset';
|
||||
deleteBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
deleteAsset(asset);
|
||||
@@ -636,19 +687,29 @@ function renderAssetList() {
|
||||
actions.appendChild(toggleBtn);
|
||||
actions.appendChild(deleteBtn);
|
||||
|
||||
row.appendChild(preview);
|
||||
row.appendChild(meta);
|
||||
row.appendChild(actions);
|
||||
|
||||
li.addEventListener('click', () => {
|
||||
selectedAssetId = asset.id;
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
updateRenderState(asset);
|
||||
drawAndList();
|
||||
});
|
||||
|
||||
li.appendChild(preview);
|
||||
li.appendChild(meta);
|
||||
li.appendChild(actions);
|
||||
li.appendChild(row);
|
||||
|
||||
if (asset.id === selectedAssetId && controlsPanel) {
|
||||
controlsPanel.classList.remove('hidden');
|
||||
const detail = document.createElement('div');
|
||||
detail.className = 'asset-detail';
|
||||
detail.appendChild(controlsPanel);
|
||||
li.appendChild(detail);
|
||||
updateSelectedAssetControls(asset);
|
||||
}
|
||||
|
||||
list.appendChild(li);
|
||||
});
|
||||
|
||||
updateSelectedAssetControls();
|
||||
}
|
||||
|
||||
function createPreviewElement(asset) {
|
||||
@@ -675,22 +736,17 @@ function getSelectedAsset() {
|
||||
return selectedAssetId ? assets.get(selectedAssetId) : null;
|
||||
}
|
||||
|
||||
function updateSelectedAssetControls() {
|
||||
if (!controlsPanel) {
|
||||
return;
|
||||
}
|
||||
const asset = getSelectedAsset();
|
||||
if (!asset) {
|
||||
controlsPanel.classList.add('hidden');
|
||||
function updateSelectedAssetControls(asset = getSelectedAsset()) {
|
||||
if (!controlsPanel || !asset) {
|
||||
if (controlsPanel) controlsPanel.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
controlsPanel.classList.remove('hidden');
|
||||
lastSizeInputChanged = null;
|
||||
selectedAssetName.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`;
|
||||
selectedAssetMeta.textContent = `Z ${asset.zIndex ?? 0} · ${Math.round(asset.width)}x${Math.round(asset.height)} · ${getDisplayMediaType(asset)} · ${asset.hidden ? 'Hidden' : 'Visible'}`;
|
||||
if (selectedZLabel) {
|
||||
selectedZLabel.textContent = asset.zIndex ?? 0;
|
||||
selectedZLabel.textContent = asset.zIndex ?? 1;
|
||||
}
|
||||
if (selectedTypeLabel) {
|
||||
selectedTypeLabel.textContent = getDisplayMediaType(asset);
|
||||
@@ -700,8 +756,11 @@ function updateSelectedAssetControls() {
|
||||
selectedVisibilityBadge.classList.toggle('danger', !!asset.hidden);
|
||||
}
|
||||
if (selectedToggleBtn) {
|
||||
selectedToggleBtn.querySelector('.label').textContent = asset.hidden ? 'Show' : 'Hide';
|
||||
selectedToggleBtn.querySelector('.icon').textContent = asset.hidden ? '👁️' : '🙈';
|
||||
const icon = selectedToggleBtn.querySelector('i');
|
||||
if (icon) {
|
||||
icon.className = `fa-solid ${asset.hidden ? 'fa-eye' : 'fa-eye-slash'}`;
|
||||
}
|
||||
selectedToggleBtn.title = asset.hidden ? 'Show asset' : 'Hide asset';
|
||||
}
|
||||
|
||||
if (widthInput) widthInput.value = Math.round(asset.width);
|
||||
@@ -711,7 +770,13 @@ function updateSelectedAssetControls() {
|
||||
aspectLockInput.onchange = () => setAspectLock(asset.id, aspectLockInput.checked);
|
||||
}
|
||||
if (speedInput) {
|
||||
speedInput.value = Math.round((asset.speed && asset.speed > 0 ? asset.speed : 1) * 100);
|
||||
const percent = Math.round((asset.speed ?? 1) * 100);
|
||||
speedInput.value = Math.min(1000, Math.max(0, percent));
|
||||
}
|
||||
if (playbackSection) {
|
||||
const shouldShowPlayback = isVideoAsset(asset);
|
||||
playbackSection.classList.toggle('hidden', !shouldShowPlayback);
|
||||
speedInput?.classList?.toggle('disabled', !shouldShowPlayback);
|
||||
}
|
||||
if (muteInput) {
|
||||
muteInput.checked = !!asset.muted;
|
||||
@@ -740,18 +805,22 @@ function applyTransformFromInputs() {
|
||||
|
||||
asset.width = Math.max(10, nextWidth);
|
||||
asset.height = Math.max(10, nextHeight);
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
updateRenderState(asset);
|
||||
persistTransform(asset);
|
||||
drawAndList();
|
||||
}
|
||||
|
||||
function updatePlaybackFromInputs() {
|
||||
const asset = getSelectedAsset();
|
||||
if (!asset) return;
|
||||
const percent = Math.max(10, Math.min(400, parseFloat(speedInput?.value) || 100));
|
||||
if (!asset || !isVideoAsset(asset)) return;
|
||||
const percent = Math.max(0, Math.min(1000, parseFloat(speedInput?.value) || 100));
|
||||
asset.speed = percent / 100;
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
updateRenderState(asset);
|
||||
persistTransform(asset);
|
||||
const media = mediaCache.get(asset.id);
|
||||
if (media) {
|
||||
applyMediaSettings(media, asset);
|
||||
}
|
||||
drawAndList();
|
||||
}
|
||||
|
||||
@@ -759,7 +828,7 @@ function updateMuteFromInput() {
|
||||
const asset = getSelectedAsset();
|
||||
if (!asset || !isVideoAsset(asset)) return;
|
||||
asset.muted = !!muteInput?.checked;
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
updateRenderState(asset);
|
||||
persistTransform(asset);
|
||||
const media = mediaCache.get(asset.id);
|
||||
if (media) {
|
||||
@@ -773,7 +842,7 @@ function nudgeRotation(delta) {
|
||||
if (!asset) return;
|
||||
const next = (asset.rotation || 0) + delta;
|
||||
asset.rotation = next;
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
updateRenderState(asset);
|
||||
persistTransform(asset);
|
||||
drawAndList();
|
||||
}
|
||||
@@ -785,7 +854,7 @@ function recenterSelectedAsset() {
|
||||
const centerY = (canvas.height - asset.height) / 2;
|
||||
asset.x = centerX;
|
||||
asset.y = centerY;
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
updateRenderState(asset);
|
||||
persistTransform(asset);
|
||||
drawAndList();
|
||||
}
|
||||
@@ -829,13 +898,15 @@ function sendToBack() {
|
||||
function applyZOrder(ordered) {
|
||||
const changed = [];
|
||||
ordered.forEach((item, index) => {
|
||||
if ((item.zIndex ?? 0) !== index) {
|
||||
item.zIndex = index;
|
||||
const nextIndex = index + 1;
|
||||
if ((item.zIndex ?? 1) !== nextIndex) {
|
||||
item.zIndex = nextIndex;
|
||||
changed.push(item);
|
||||
}
|
||||
assets.set(item.id, item);
|
||||
renderStates.set(item.id, { ...item });
|
||||
updateRenderState(item);
|
||||
});
|
||||
zOrderDirty = true;
|
||||
changed.forEach((item) => persistTransform(item, true));
|
||||
drawAndList();
|
||||
}
|
||||
@@ -891,7 +962,8 @@ function updateVisibility(asset, hidden) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hidden })
|
||||
}).then((r) => r.json()).then((updated) => {
|
||||
assets.set(updated.id, updated);
|
||||
storeAsset(updated);
|
||||
updateRenderState(updated);
|
||||
drawAndList();
|
||||
});
|
||||
}
|
||||
@@ -901,6 +973,7 @@ function deleteAsset(asset) {
|
||||
assets.delete(asset.id);
|
||||
mediaCache.delete(asset.id);
|
||||
renderStates.delete(asset.id);
|
||||
zOrderDirty = true;
|
||||
if (selectedAssetId === asset.id) {
|
||||
selectedAssetId = null;
|
||||
}
|
||||
@@ -953,6 +1026,7 @@ function findAssetAtPoint(x, y) {
|
||||
}
|
||||
|
||||
function persistTransform(asset, silent = false) {
|
||||
asset.zIndex = Math.max(1, asset.zIndex ?? 1);
|
||||
fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -967,7 +1041,8 @@ function persistTransform(asset, silent = false) {
|
||||
zIndex: asset.zIndex
|
||||
})
|
||||
}).then((r) => r.json()).then((updated) => {
|
||||
assets.set(updated.id, updated);
|
||||
storeAsset(updated);
|
||||
updateRenderState(updated);
|
||||
if (!silent) {
|
||||
drawAndList();
|
||||
}
|
||||
@@ -1001,7 +1076,7 @@ canvas.addEventListener('mousedown', (event) => {
|
||||
const hit = findAssetAtPoint(point.x, point.y);
|
||||
if (hit) {
|
||||
selectedAssetId = hit.id;
|
||||
renderStates.set(hit.id, { ...hit });
|
||||
updateRenderState(hit);
|
||||
interactionState = {
|
||||
mode: 'move',
|
||||
assetId: hit.id,
|
||||
@@ -1033,18 +1108,18 @@ canvas.addEventListener('mousemove', (event) => {
|
||||
if (interactionState.mode === 'move') {
|
||||
asset.x = point.x - interactionState.offsetX;
|
||||
asset.y = point.y - interactionState.offsetY;
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
updateRenderState(asset);
|
||||
canvas.style.cursor = 'grabbing';
|
||||
draw();
|
||||
requestDraw();
|
||||
} else if (interactionState.mode === 'resize') {
|
||||
resizeFromHandle(interactionState, point);
|
||||
canvas.style.cursor = cursorForHandle(interactionState.handle);
|
||||
} else if (interactionState.mode === 'rotate') {
|
||||
const angle = angleFromCenter(asset, point);
|
||||
asset.rotation = (interactionState.startRotation || 0) + (angle - interactionState.startAngle);
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
updateRenderState(asset);
|
||||
canvas.style.cursor = 'grabbing';
|
||||
draw();
|
||||
requestDraw();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1070,6 +1145,5 @@ window.addEventListener('resize', () => {
|
||||
|
||||
fetchCanvasSettings().finally(() => {
|
||||
resizeCanvas();
|
||||
startRenderLoop();
|
||||
connect();
|
||||
});
|
||||
|
||||
@@ -22,7 +22,10 @@ function connect() {
|
||||
}
|
||||
|
||||
function renderAssets(list) {
|
||||
list.forEach(asset => assets.set(asset.id, asset));
|
||||
list.forEach(asset => {
|
||||
asset.zIndex = Math.max(1, asset.zIndex ?? 1);
|
||||
assets.set(asset.id, asset);
|
||||
});
|
||||
draw();
|
||||
}
|
||||
|
||||
@@ -55,8 +58,9 @@ function handleEvent(event) {
|
||||
clearMedia(event.assetId);
|
||||
renderStates.delete(event.assetId);
|
||||
} else if (event.payload && !event.payload.hidden) {
|
||||
assets.set(event.payload.id, event.payload);
|
||||
ensureMedia(event.payload);
|
||||
const payload = { ...event.payload, zIndex: Math.max(1, event.payload.zIndex ?? 1) };
|
||||
assets.set(payload.id, payload);
|
||||
ensureMedia(payload);
|
||||
} else if (event.payload && event.payload.hidden) {
|
||||
assets.delete(event.payload.id);
|
||||
clearMedia(event.payload.id);
|
||||
@@ -75,8 +79,8 @@ function getZOrderedAssets() {
|
||||
}
|
||||
|
||||
function zComparator(a, b) {
|
||||
const aZ = a?.zIndex ?? 0;
|
||||
const bZ = b?.zIndex ?? 0;
|
||||
const aZ = a?.zIndex ?? 1;
|
||||
const bZ = b?.zIndex ?? 1;
|
||||
if (aZ !== bZ) {
|
||||
return aZ - bZ;
|
||||
}
|
||||
@@ -125,7 +129,7 @@ function lerp(a, b, t) {
|
||||
}
|
||||
|
||||
function isVideoAsset(asset) {
|
||||
return (asset.mediaType && asset.mediaType.startsWith('video/')) || asset.url?.startsWith('data:video/');
|
||||
return asset?.mediaType?.startsWith('video/');
|
||||
}
|
||||
|
||||
function isVideoElement(element) {
|
||||
@@ -133,7 +137,7 @@ function isVideoElement(element) {
|
||||
}
|
||||
|
||||
function isGifAsset(asset) {
|
||||
return (asset.mediaType && asset.mediaType.toLowerCase() === 'image/gif') || asset.url?.startsWith('data:image/gif');
|
||||
return asset?.mediaType?.toLowerCase() === 'image/gif';
|
||||
}
|
||||
|
||||
function isDrawable(element) {
|
||||
@@ -187,8 +191,13 @@ function ensureMedia(asset) {
|
||||
element.autoplay = true;
|
||||
element.onloadeddata = draw;
|
||||
element.src = asset.url;
|
||||
element.playbackRate = asset.speed && asset.speed > 0 ? asset.speed : 1;
|
||||
const playback = asset.speed ?? 1;
|
||||
element.playbackRate = Math.max(playback, 0.01);
|
||||
if (playback === 0) {
|
||||
element.pause();
|
||||
} else {
|
||||
element.play().catch(() => {});
|
||||
}
|
||||
} else {
|
||||
element.onload = draw;
|
||||
element.src = asset.url;
|
||||
@@ -279,15 +288,18 @@ function applyMediaSettings(element, asset) {
|
||||
if (!isVideoElement(element)) {
|
||||
return;
|
||||
}
|
||||
const nextSpeed = asset.speed && asset.speed > 0 ? asset.speed : 1;
|
||||
if (element.playbackRate !== nextSpeed) {
|
||||
element.playbackRate = nextSpeed;
|
||||
const nextSpeed = asset.speed ?? 1;
|
||||
const effectiveSpeed = Math.max(nextSpeed, 0.01);
|
||||
if (element.playbackRate !== effectiveSpeed) {
|
||||
element.playbackRate = effectiveSpeed;
|
||||
}
|
||||
const shouldMute = asset.muted ?? true;
|
||||
if (element.muted !== shouldMute) {
|
||||
element.muted = shouldMute;
|
||||
}
|
||||
if (element.paused) {
|
||||
if (nextSpeed === 0) {
|
||||
element.pause();
|
||||
} else if (element.paused) {
|
||||
element.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<title>Imgfloat Admin</title>
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
|
||||
@@ -21,43 +22,35 @@
|
||||
<section class="controls">
|
||||
<div>
|
||||
<h3>Overlay assets</h3>
|
||||
<p>Upload images to place on the broadcaster's overlay. Changes are visible to the broadcaster instantly.</p>
|
||||
<p>Upload overlay visuals and adjust them inline.</p>
|
||||
<input id="asset-file" type="file" accept="image/*,video/*" />
|
||||
<button onclick="uploadAsset()">Upload</button>
|
||||
<ul id="asset-list" class="asset-list"></ul>
|
||||
<div id="asset-controls-placeholder" class="hidden"></div>
|
||||
<div id="asset-controls" class="panel hidden asset-settings">
|
||||
<div class="panel-header">
|
||||
<div class="selected-asset-banner">
|
||||
<div class="selected-asset-main">
|
||||
<p class="eyebrow subtle">Asset settings</p>
|
||||
<div class="title-row">
|
||||
<h4 id="selected-asset-name">Selected asset</h4>
|
||||
<span class="badge subtle" id="selected-asset-visibility">Visible</span>
|
||||
</div>
|
||||
<p class="muted meta-text" id="selected-asset-meta"></p>
|
||||
</div>
|
||||
<h4 id="selected-asset-name">Asset settings</h4>
|
||||
<div class="selected-asset-actions">
|
||||
<button type="button" id="selected-asset-toggle" class="ghost icon-button" title="Toggle visibility">
|
||||
<span class="icon" aria-hidden="true">🙈</span>
|
||||
<span class="label">Hide</span>
|
||||
<i class="fa-solid fa-eye" aria-hidden="true"></i>
|
||||
<span class="sr-only">Toggle visibility</span>
|
||||
</button>
|
||||
<button type="button" id="selected-asset-delete" class="ghost danger icon-button" title="Delete asset">
|
||||
<span class="icon" aria-hidden="true">🗑️</span>
|
||||
<span class="label">Delete</span>
|
||||
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
||||
<span class="sr-only">Delete asset</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-summary">
|
||||
<p class="muted">Fine-tune the selected overlay item with sizing, playback, and layering controls.</p>
|
||||
</div>
|
||||
<div class="badge-row stacked">
|
||||
<span class="badge subtle" id="selected-asset-visibility">Visible</span>
|
||||
<span class="badge subtle" id="asset-type-label"></span>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<div class="section-header">
|
||||
<h5>Size & placement</h5>
|
||||
<p class="muted">Keep your overlay crisp by locking aspect ratio while resizing.</p>
|
||||
<h5>Layout & order</h5>
|
||||
</div>
|
||||
<div class="control-grid condensed">
|
||||
<div class="control-grid condensed three-col">
|
||||
<label>
|
||||
Width
|
||||
<input id="asset-width" class="number-input" type="number" min="10" step="5" />
|
||||
@@ -68,56 +61,47 @@
|
||||
</label>
|
||||
<label class="checkbox-inline">
|
||||
<input id="maintain-aspect" type="checkbox" checked />
|
||||
Maintain aspect ratio
|
||||
Maintain aspect
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section two-col">
|
||||
<div>
|
||||
<div class="section-header">
|
||||
<h5>Playback</h5>
|
||||
<p class="muted">Tweak animation speed or mute video assets.</p>
|
||||
</div>
|
||||
<div class="control-grid condensed">
|
||||
<label>
|
||||
Animation speed (% of original)
|
||||
<input id="asset-speed" class="number-input" type="number" min="10" max="400" step="5" value="100" />
|
||||
Layer (Z)
|
||||
<div class="badge-row stacked">
|
||||
<span class="badge">Layer <strong id="asset-z-level">1</strong></span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-actions filled compact">
|
||||
<button type="button" onclick="sendToBack()" class="secondary" title="Send to back"><i class="fa-solid fa-angles-down"></i></button>
|
||||
<button type="button" onclick="bringBackward()" class="secondary" title="Move backward"><i class="fa-solid fa-arrow-down"></i></button>
|
||||
<button type="button" onclick="bringForward()" class="secondary" title="Move forward"><i class="fa-solid fa-arrow-up"></i></button>
|
||||
<button type="button" onclick="bringToFront()" class="secondary" title="Bring to front"><i class="fa-solid fa-angles-up"></i></button>
|
||||
<button type="button" onclick="recenterSelectedAsset()" class="secondary" title="Center on canvas"><i class="fa-solid fa-bullseye"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section" id="playback-section">
|
||||
<div class="section-header">
|
||||
<h5>Playback</h5>
|
||||
</div>
|
||||
<div class="control-grid condensed">
|
||||
<label>
|
||||
Animation speed
|
||||
<input id="asset-speed" class="range-input" type="range" min="0" max="1000" step="10" value="100" />
|
||||
</label>
|
||||
<div class="range-meta"><span>0%</span><span>1000%</span></div>
|
||||
<label class="checkbox-inline">
|
||||
<input id="asset-muted" type="checkbox" />
|
||||
Mute
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="section-header">
|
||||
<h5>Layering</h5>
|
||||
<p class="muted">Reorder assets to fit your scene.</p>
|
||||
</div>
|
||||
<div class="control-grid condensed">
|
||||
<label>
|
||||
Layer (Z)
|
||||
<div class="badge-row stacked">
|
||||
<span class="badge">Layer <strong id="asset-z-level">0</strong></span>
|
||||
<span class="badge subtle" id="asset-type-label"></span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-actions filled">
|
||||
<button type="button" onclick="nudgeRotation(-5)" class="secondary">Rotate left</button>
|
||||
<button type="button" onclick="nudgeRotation(5)" class="secondary">Rotate right</button>
|
||||
<button type="button" onclick="applyTransformFromInputs()">Apply size</button>
|
||||
<button type="button" onclick="recenterSelectedAsset()" class="secondary">Re-center asset</button>
|
||||
</div>
|
||||
<div class="control-actions filled">
|
||||
<button type="button" onclick="sendToBack()" class="secondary">Send to back</button>
|
||||
<button type="button" onclick="bringBackward()" class="secondary">Bring backward</button>
|
||||
<button type="button" onclick="bringForward()" class="secondary">Bring forward</button>
|
||||
<button type="button" onclick="bringToFront()" class="secondary">Bring to front</button>
|
||||
<div class="control-actions filled compact">
|
||||
<button type="button" onclick="nudgeRotation(-5)" class="secondary" title="Rotate left"><i class="fa-solid fa-rotate-left"></i></button>
|
||||
<button type="button" onclick="nudgeRotation(5)" class="secondary" title="Rotate right"><i class="fa-solid fa-rotate-right"></i></button>
|
||||
<button type="button" onclick="applyTransformFromInputs()" title="Apply size"><i class="fa-solid fa-floppy-disk"></i><span class="sr-only">Apply size</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.imgfloat.app;
|
||||
import com.imgfloat.app.model.TransformRequest;
|
||||
import com.imgfloat.app.model.VisibilityRequest;
|
||||
import com.imgfloat.app.model.Asset;
|
||||
import com.imgfloat.app.model.AssetView;
|
||||
import com.imgfloat.app.model.Channel;
|
||||
import com.imgfloat.app.repository.AssetRepository;
|
||||
import com.imgfloat.app.repository.ChannelRepository;
|
||||
@@ -51,7 +52,7 @@ class ChannelDirectoryServiceTest {
|
||||
void createsAssetsAndBroadcastsEvents() throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng());
|
||||
|
||||
Optional<?> created = service.createAsset("caster", file);
|
||||
Optional<AssetView> created = service.createAsset("caster", file);
|
||||
assertThat(created).isPresent();
|
||||
ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
|
||||
verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.contains("/topic/channel/caster"), captor.capture());
|
||||
@@ -61,7 +62,7 @@ class ChannelDirectoryServiceTest {
|
||||
void updatesTransformAndVisibility() throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng());
|
||||
String channel = "caster";
|
||||
String id = service.createAsset(channel, file).orElseThrow().getId();
|
||||
String id = service.createAsset(channel, file).orElseThrow().id();
|
||||
|
||||
TransformRequest transform = new TransformRequest();
|
||||
transform.setX(10);
|
||||
|
||||
Reference in New Issue
Block a user