Improve channel admin page

This commit is contained in:
2025-12-09 16:58:42 +01:00
parent 77c2775b7f
commit 0aac788eda
10 changed files with 434 additions and 218 deletions

View File

@@ -1,7 +1,7 @@
package com.imgfloat.app.controller; package com.imgfloat.app.controller;
import com.imgfloat.app.model.AdminRequest; 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.CanvasSettingsRequest;
import com.imgfloat.app.model.TransformRequest; import com.imgfloat.app.model.TransformRequest;
import com.imgfloat.app.model.TwitchUserProfile; import com.imgfloat.app.model.TwitchUserProfile;
@@ -94,7 +94,7 @@ public class ChannelApiController {
} }
@GetMapping("/assets") @GetMapping("/assets")
public Collection<Asset> listAssets(@PathVariable("broadcaster") String broadcaster, public Collection<AssetView> listAssets(@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication) { OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login(); String login = TwitchUser.from(authentication).login();
if (!channelDirectoryService.isBroadcaster(broadcaster, login) if (!channelDirectoryService.isBroadcaster(broadcaster, login)
@@ -105,7 +105,7 @@ public class ChannelApiController {
} }
@GetMapping("/assets/visible") @GetMapping("/assets/visible")
public Collection<Asset> listVisible(@PathVariable("broadcaster") String broadcaster, public Collection<AssetView> listVisible(@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication) { OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login(); String login = TwitchUser.from(authentication).login();
if (!channelDirectoryService.isBroadcaster(broadcaster, login)) { if (!channelDirectoryService.isBroadcaster(broadcaster, login)) {
@@ -132,7 +132,7 @@ public class ChannelApiController {
} }
@PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @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, @org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file,
OAuth2AuthenticationToken authentication) { OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login(); String login = TwitchUser.from(authentication).login();
@@ -150,7 +150,7 @@ public class ChannelApiController {
} }
@PutMapping("/assets/{assetId}/transform") @PutMapping("/assets/{assetId}/transform")
public ResponseEntity<Asset> transform(@PathVariable("broadcaster") String broadcaster, public ResponseEntity<AssetView> transform(@PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId, @PathVariable("assetId") String assetId,
@Valid @RequestBody TransformRequest request, @Valid @RequestBody TransformRequest request,
OAuth2AuthenticationToken authentication) { OAuth2AuthenticationToken authentication) {
@@ -162,7 +162,7 @@ public class ChannelApiController {
} }
@PutMapping("/assets/{assetId}/visibility") @PutMapping("/assets/{assetId}/visibility")
public ResponseEntity<Asset> visibility(@PathVariable("broadcaster") String broadcaster, public ResponseEntity<AssetView> visibility(@PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId, @PathVariable("assetId") String assetId,
@RequestBody VisibilityRequest request, @RequestBody VisibilityRequest request,
OAuth2AuthenticationToken authentication) { OAuth2AuthenticationToken authentication) {
@@ -173,6 +173,19 @@ public class ChannelApiController {
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); .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}") @DeleteMapping("/assets/{assetId}")
public ResponseEntity<?> delete(@PathVariable("broadcaster") String broadcaster, public ResponseEntity<?> delete(@PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId, @PathVariable("assetId") String assetId,

View File

@@ -53,7 +53,7 @@ public class Asset {
this.rotation = 0; this.rotation = 0;
this.speed = 1.0; this.speed = 1.0;
this.muted = false; this.muted = false;
this.zIndex = 0; this.zIndex = 1;
this.hidden = false; this.hidden = false;
this.createdAt = Instant.now(); this.createdAt = Instant.now();
} }
@@ -71,14 +71,14 @@ public class Asset {
if (this.name == null || this.name.isBlank()) { if (this.name == null || this.name.isBlank()) {
this.name = this.id; this.name = this.id;
} }
if (this.speed == null || this.speed <= 0) { if (this.speed == null) {
this.speed = 1.0; this.speed = 1.0;
} }
if (this.muted == null) { if (this.muted == null) {
this.muted = Boolean.FALSE; this.muted = Boolean.FALSE;
} }
if (this.zIndex == null) { if (this.zIndex == null || this.zIndex < 1) {
this.zIndex = 0; this.zIndex = 1;
} }
} }
@@ -203,11 +203,11 @@ public class Asset {
} }
public Integer getZIndex() { public Integer getZIndex() {
return zIndex == null ? 0 : zIndex; return zIndex == null ? 1 : Math.max(1, zIndex);
} }
public void setZIndex(Integer zIndex) { public void setZIndex(Integer zIndex) {
this.zIndex = zIndex; this.zIndex = zIndex == null ? null : Math.max(1, zIndex);
} }
private static String normalize(String value) { private static String normalize(String value) {

View File

@@ -10,33 +10,33 @@ public class AssetEvent {
private Type type; private Type type;
private String channel; private String channel;
private Asset payload; private AssetView payload;
private String assetId; private String assetId;
public static AssetEvent created(String channel, Asset asset) { public static AssetEvent created(String channel, AssetView asset) {
AssetEvent event = new AssetEvent(); AssetEvent event = new AssetEvent();
event.type = Type.CREATED; event.type = Type.CREATED;
event.channel = channel; event.channel = channel;
event.payload = asset; event.payload = asset;
event.assetId = asset.getId(); event.assetId = asset.id();
return event; return event;
} }
public static AssetEvent updated(String channel, Asset asset) { public static AssetEvent updated(String channel, AssetView asset) {
AssetEvent event = new AssetEvent(); AssetEvent event = new AssetEvent();
event.type = Type.UPDATED; event.type = Type.UPDATED;
event.channel = channel; event.channel = channel;
event.payload = asset; event.payload = asset;
event.assetId = asset.getId(); event.assetId = asset.id();
return event; return event;
} }
public static AssetEvent visibility(String channel, Asset asset) { public static AssetEvent visibility(String channel, AssetView asset) {
AssetEvent event = new AssetEvent(); AssetEvent event = new AssetEvent();
event.type = Type.VISIBILITY; event.type = Type.VISIBILITY;
event.channel = channel; event.channel = channel;
event.payload = asset; event.payload = asset;
event.assetId = asset.getId(); event.assetId = asset.id();
return event; return event;
} }
@@ -56,7 +56,7 @@ public class AssetEvent {
return channel; return channel;
} }
public Asset getPayload() { public AssetView getPayload() {
return payload; return payload;
} }

View 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()
);
}
}

View File

@@ -3,6 +3,7 @@ package com.imgfloat.app.service;
import com.imgfloat.app.model.Asset; import com.imgfloat.app.model.Asset;
import com.imgfloat.app.model.AssetEvent; import com.imgfloat.app.model.AssetEvent;
import com.imgfloat.app.model.Channel; import com.imgfloat.app.model.Channel;
import com.imgfloat.app.model.AssetView;
import com.imgfloat.app.model.CanvasSettingsRequest; import com.imgfloat.app.model.CanvasSettingsRequest;
import com.imgfloat.app.model.TransformRequest; import com.imgfloat.app.model.TransformRequest;
import com.imgfloat.app.model.VisibilityRequest; import com.imgfloat.app.model.VisibilityRequest;
@@ -85,12 +86,14 @@ public class ChannelDirectoryService {
return removed; return removed;
} }
public Collection<Asset> getAssetsForAdmin(String broadcaster) { public Collection<AssetView> getAssetsForAdmin(String broadcaster) {
return sortByZIndex(assetRepository.findByBroadcaster(normalize(broadcaster))); String normalized = normalize(broadcaster);
return sortAndMapAssets(normalized, assetRepository.findByBroadcaster(normalized));
} }
public Collection<Asset> getVisibleAssets(String broadcaster) { public Collection<AssetView> getVisibleAssets(String broadcaster) {
return sortByZIndex(assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster))); String normalized = normalize(broadcaster);
return sortAndMapAssets(normalized, assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster)));
} }
public CanvasSettingsRequest getCanvasSettings(String broadcaster) { public CanvasSettingsRequest getCanvasSettings(String broadcaster) {
@@ -106,7 +109,7 @@ public class ChannelDirectoryService {
return new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight()); 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); Channel channel = getOrCreateChannel(broadcaster);
byte[] bytes = file.getBytes(); byte[] bytes = file.getBytes();
String mediaType = detectMediaType(file, bytes); String mediaType = detectMediaType(file, bytes);
@@ -132,11 +135,12 @@ public class ChannelDirectoryService {
asset.setZIndex(nextZIndex(channel.getBroadcaster())); asset.setZIndex(nextZIndex(channel.getBroadcaster()));
assetRepository.save(asset); assetRepository.save(asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset)); AssetView view = AssetView.from(channel.getBroadcaster(), asset);
return Optional.of(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); String normalized = normalize(broadcaster);
return assetRepository.findById(assetId) return assetRepository.findById(assetId)
.filter(asset -> normalized.equals(asset.getBroadcaster())) .filter(asset -> normalized.equals(asset.getBroadcaster()))
@@ -149,27 +153,29 @@ public class ChannelDirectoryService {
if (request.getZIndex() != null) { if (request.getZIndex() != null) {
asset.setZIndex(request.getZIndex()); asset.setZIndex(request.getZIndex());
} }
if (request.getSpeed() != null && request.getSpeed() > 0) { if (request.getSpeed() != null && request.getSpeed() >= 0) {
asset.setSpeed(request.getSpeed()); asset.setSpeed(request.getSpeed());
} }
if (request.getMuted() != null && asset.isVideo()) { if (request.getMuted() != null && asset.isVideo()) {
asset.setMuted(request.getMuted()); asset.setMuted(request.getMuted());
} }
assetRepository.save(asset); assetRepository.save(asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, asset)); AssetView view = AssetView.from(normalized, asset);
return 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); String normalized = normalize(broadcaster);
return assetRepository.findById(assetId) return assetRepository.findById(assetId)
.filter(asset -> normalized.equals(asset.getBroadcaster())) .filter(asset -> normalized.equals(asset.getBroadcaster()))
.map(asset -> { .map(asset -> {
asset.setHidden(request.isHidden()); asset.setHidden(request.isHidden());
assetRepository.save(asset); assetRepository.save(asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, asset)); AssetView view = AssetView.from(normalized, asset);
return asset; messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, view));
return view;
}); });
} }
@@ -185,6 +191,13 @@ public class ChannelDirectoryService {
.orElse(false); .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) { public boolean isBroadcaster(String broadcaster, String username) {
return broadcaster != null && broadcaster.equalsIgnoreCase(username); return broadcaster != null && broadcaster.equalsIgnoreCase(username);
} }
@@ -215,13 +228,36 @@ public class ChannelDirectoryService {
return value == null ? null : value.toLowerCase(); 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() return assets.stream()
.sorted(Comparator.comparingInt(Asset::getZIndex) .sorted(Comparator.comparingInt(Asset::getZIndex)
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))) .thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder())))
.map(asset -> AssetView.from(broadcaster, asset))
.toList(); .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) { private int nextZIndex(String broadcaster) {
return assetRepository.findByBroadcaster(normalize(broadcaster)).stream() return assetRepository.findByBroadcaster(normalize(broadcaster)).stream()
.mapToInt(Asset::getZIndex) .mapToInt(Asset::getZIndex)
@@ -426,6 +462,8 @@ public class ChannelDirectoryService {
return new Dimension(640, 360); 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 OptimizedAsset(byte[] bytes, String mediaType, int width, int height) { }
private record GifFrame(BufferedImage image, int delayMs) { } private record GifFrame(BufferedImage image, int delayMs) { }

View File

@@ -1,3 +1,11 @@
* {
box-sizing: border-box;
}
.hidden {
display: none !important;
}
body { body {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
background: #0f172a; background: #0f172a;
@@ -565,14 +573,14 @@ body {
.asset-item { .asset-item {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; align-items: stretch;
padding: 10px 12px; padding: 10px 12px;
border-radius: 8px; border-radius: 10px;
background: #111827; background: #111827;
border: 1px solid #1f2937; border: 1px solid #1f2937;
cursor: pointer; cursor: pointer;
gap: 12px; gap: 10px;
} }
.asset-item.selected { .asset-item.selected {
@@ -580,6 +588,17 @@ body {
box-shadow: 0 0 0 1px rgba(124, 58, 237, 0.6); 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 { .asset-item .meta {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -595,6 +614,10 @@ body {
gap: 8px; gap: 8px;
} }
.asset-detail {
margin-top: 4px;
}
.icon-button { .icon-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -641,6 +664,17 @@ body {
flex-shrink: 0; 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 { .control-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
@@ -653,6 +687,10 @@ body {
margin-top: 8px; margin-top: 8px;
} }
.control-grid.three-col {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.control-grid label { .control-grid label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -669,6 +707,15 @@ body {
color: #e2e8f0; color: #e2e8f0;
} }
.range-meta {
display: flex;
justify-content: space-between;
color: #94a3b8;
font-size: 12px;
margin-top: -6px;
padding: 0 2px;
}
.number-input { .number-input {
position: relative; position: relative;
padding-right: 48px !important; padding-right: 48px !important;
@@ -697,6 +744,10 @@ body {
flex-wrap: wrap; flex-wrap: wrap;
} }
.control-actions.compact button {
padding: 10px 12px;
}
.control-actions.filled { .control-actions.filled {
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(124, 58, 237, 0.22); border: 1px solid rgba(124, 58, 237, 0.22);

View File

@@ -10,9 +10,11 @@ const assets = new Map();
const mediaCache = new Map(); const mediaCache = new Map();
const renderStates = new Map(); const renderStates = new Map();
const animatedCache = new Map(); const animatedCache = new Map();
let drawPending = false;
let zOrderDirty = true;
let zOrderCache = [];
let selectedAssetId = null; let selectedAssetId = null;
let interactionState = null; let interactionState = null;
let animationFrameId = null;
let lastSizeInputChanged = null; let lastSizeInputChanged = null;
const HANDLE_SIZE = 10; const HANDLE_SIZE = 10;
const ROTATE_HANDLE_OFFSET = 32; const ROTATE_HANDLE_OFFSET = 32;
@@ -24,17 +26,18 @@ const aspectLockInput = document.getElementById('maintain-aspect');
const speedInput = document.getElementById('asset-speed'); const speedInput = document.getElementById('asset-speed');
const muteInput = document.getElementById('asset-muted'); const muteInput = document.getElementById('asset-muted');
const selectedAssetName = document.getElementById('selected-asset-name'); const selectedAssetName = document.getElementById('selected-asset-name');
const selectedAssetMeta = document.getElementById('selected-asset-meta');
const selectedZLabel = document.getElementById('asset-z-level'); const selectedZLabel = document.getElementById('asset-z-level');
const selectedTypeLabel = document.getElementById('asset-type-label'); const selectedTypeLabel = document.getElementById('asset-type-label');
const selectedVisibilityBadge = document.getElementById('selected-asset-visibility'); const selectedVisibilityBadge = document.getElementById('selected-asset-visibility');
const selectedToggleBtn = document.getElementById('selected-asset-toggle'); const selectedToggleBtn = document.getElementById('selected-asset-toggle');
const selectedDeleteBtn = document.getElementById('selected-asset-delete'); const selectedDeleteBtn = document.getElementById('selected-asset-delete');
const playbackSection = document.getElementById('playback-section');
const controlsPlaceholder = document.getElementById('asset-controls-placeholder');
const aspectLockState = new Map(); const aspectLockState = new Map();
if (widthInput) widthInput.addEventListener('input', () => handleSizeInputChange('width')); if (widthInput) widthInput.addEventListener('input', () => handleSizeInputChange('width'));
if (heightInput) heightInput.addEventListener('input', () => handleSizeInputChange('height')); 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 (muteInput) muteInput.addEventListener('change', updateMuteFromInput);
if (selectedToggleBtn) selectedToggleBtn.addEventListener('click', (event) => { if (selectedToggleBtn) selectedToggleBtn.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
@@ -97,50 +100,90 @@ function resizeCanvas() {
overlayFrame.style.left = `${(bounds.width - displayWidth) / 2}px`; overlayFrame.style.left = `${(bounds.width - displayWidth) / 2}px`;
overlayFrame.style.top = `${(bounds.height - displayHeight) / 2}px`; overlayFrame.style.top = `${(bounds.height - displayHeight) / 2}px`;
} }
draw(); requestDraw();
} }
function renderAssets(list) { function renderAssets(list) {
list.forEach((asset) => assets.set(asset.id, asset)); list.forEach(storeAsset);
drawAndList(); 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) { function handleEvent(event) {
if (event.type === 'DELETED') { if (event.type === 'DELETED') {
assets.delete(event.assetId); assets.delete(event.assetId);
zOrderDirty = true;
clearMedia(event.assetId); clearMedia(event.assetId);
renderStates.delete(event.assetId); renderStates.delete(event.assetId);
if (selectedAssetId === event.assetId) { if (selectedAssetId === event.assetId) {
selectedAssetId = null; selectedAssetId = null;
} }
} else if (event.payload) { } else if (event.payload) {
assets.set(event.payload.id, event.payload); storeAsset(event.payload);
ensureMedia(event.payload); ensureMedia(event.payload);
} }
drawAndList(); drawAndList();
} }
function drawAndList() { function drawAndList() {
draw(); requestDraw();
renderAssetList(); renderAssetList();
} }
function requestDraw() {
if (drawPending) {
return;
}
drawPending = true;
requestAnimationFrame(() => {
drawPending = false;
draw();
});
}
function draw() { function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
getZOrderedAssets().forEach((asset) => drawAsset(asset)); getZOrderedAssets().forEach((asset) => drawAsset(asset));
} }
function getZOrderedAssets() { 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) { function zComparator(a, b) {
const aZ = a?.zIndex ?? 0; const aZ = a?.zIndex ?? 1;
const bZ = b?.zIndex ?? 0; const bZ = b?.zIndex ?? 1;
if (aZ !== bZ) { if (aZ !== bZ) {
return 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) { function drawAsset(asset) {
@@ -181,16 +224,14 @@ function drawAsset(asset) {
function smoothState(asset) { function smoothState(asset) {
const previous = renderStates.get(asset.id) || { ...asset }; const previous = renderStates.get(asset.id) || { ...asset };
const factor = interactionState && interactionState.assetId === asset.id ? 0.5 : 0.18; const factor = interactionState && interactionState.assetId === asset.id ? 0.45 : 0.18;
const next = { previous.x = lerp(previous.x, asset.x, factor);
x: lerp(previous.x, asset.x, factor), previous.y = lerp(previous.y, asset.y, factor);
y: lerp(previous.y, asset.y, factor), previous.width = lerp(previous.width, asset.width, factor);
width: lerp(previous.width, asset.width, factor), previous.height = lerp(previous.height, asset.height, factor);
height: lerp(previous.height, asset.height, factor), previous.rotation = smoothAngle(previous.rotation, asset.rotation, factor);
rotation: smoothAngle(previous.rotation, asset.rotation, factor) renderStates.set(asset.id, previous);
}; return previous;
renderStates.set(asset.id, next);
return next;
} }
function smoothAngle(current, target, factor) { function smoothAngle(current, target, factor) {
@@ -373,8 +414,8 @@ function resizeFromHandle(state, point) {
asset.y = basis.y + shift.y; asset.y = basis.y + shift.y;
asset.width = nextWidth; asset.width = nextWidth;
asset.height = nextHeight; asset.height = nextHeight;
renderStates.set(asset.id, { ...asset }); updateRenderState(asset);
draw(); requestDraw();
} }
function updateHoverCursor(point) { function updateHoverCursor(point) {
@@ -390,19 +431,9 @@ function updateHoverCursor(point) {
canvas.style.cursor = hit ? 'move' : 'default'; canvas.style.cursor = hit ? 'move' : 'default';
} }
function startRenderLoop() {
if (animationFrameId) {
return;
}
const tick = () => {
draw();
animationFrameId = requestAnimationFrame(tick);
};
animationFrameId = requestAnimationFrame(tick);
}
function isVideoAsset(asset) { 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) { function isVideoElement(element) {
@@ -419,7 +450,7 @@ function getDisplayMediaType(asset) {
} }
function isGifAsset(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) { function isDrawable(element) {
@@ -471,12 +502,17 @@ function ensureMedia(asset) {
element.muted = asset.muted ?? true; element.muted = asset.muted ?? true;
element.playsInline = true; element.playsInline = true;
element.autoplay = true; element.autoplay = true;
element.onloadeddata = draw; element.onloadeddata = requestDraw;
element.src = asset.url; element.src = asset.url;
element.playbackRate = asset.speed && asset.speed > 0 ? asset.speed : 1; const playback = asset.speed ?? 1;
element.play().catch(() => {}); element.playbackRate = Math.max(playback, 0.01);
if (playback === 0) {
element.pause();
} else { } else {
element.onload = draw; element.play().catch(() => {});
}
} else {
element.onload = requestDraw;
element.src = asset.url; element.src = asset.url;
} }
mediaCache.set(asset.id, element); mediaCache.set(asset.id, element);
@@ -537,7 +573,7 @@ function scheduleNextFrame(controller) {
createImageBitmap(image) createImageBitmap(image)
.then((bitmap) => { .then((bitmap) => {
controller.bitmap = bitmap; controller.bitmap = bitmap;
draw(); requestDraw();
}) })
.finally(() => image.close?.()); .finally(() => image.close?.());
@@ -564,24 +600,34 @@ function applyMediaSettings(element, asset) {
if (!isVideoElement(element)) { if (!isVideoElement(element)) {
return; return;
} }
const nextSpeed = asset.speed && asset.speed > 0 ? asset.speed : 1; const nextSpeed = asset.speed ?? 1;
if (element.playbackRate !== nextSpeed) { const effectiveSpeed = Math.max(nextSpeed, 0.01);
element.playbackRate = nextSpeed; if (element.playbackRate !== effectiveSpeed) {
element.playbackRate = effectiveSpeed;
} }
const shouldMute = asset.muted ?? true; const shouldMute = asset.muted ?? true;
if (element.muted !== shouldMute) { if (element.muted !== shouldMute) {
element.muted = shouldMute; element.muted = shouldMute;
} }
if (element.paused) { if (nextSpeed === 0) {
element.pause();
} else if (element.paused) {
element.play().catch(() => {}); element.play().catch(() => {});
} }
} }
function renderAssetList() { function renderAssetList() {
const list = document.getElementById('asset-list'); const list = document.getElementById('asset-list');
if (controlsPlaceholder && controlsPanel && controlsPanel.parentElement !== controlsPlaceholder) {
controlsPlaceholder.appendChild(controlsPanel);
}
if (controlsPanel) {
controlsPanel.classList.add('hidden');
}
list.innerHTML = ''; list.innerHTML = '';
if (!assets.size) { if (!assets.size) {
selectedAssetId = null;
const empty = document.createElement('li'); const empty = document.createElement('li');
empty.textContent = 'No assets yet. Upload to get started.'; empty.textContent = 'No assets yet. Upload to get started.';
list.appendChild(empty); list.appendChild(empty);
@@ -600,6 +646,9 @@ function renderAssetList() {
li.classList.add('hidden'); li.classList.add('hidden');
} }
const row = document.createElement('div');
row.className = 'asset-row';
const preview = createPreviewElement(asset); const preview = createPreviewElement(asset);
const meta = document.createElement('div'); const meta = document.createElement('div');
@@ -607,7 +656,7 @@ function renderAssetList() {
const name = document.createElement('strong'); const name = document.createElement('strong');
name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`; name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`;
const details = document.createElement('small'); 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(name);
meta.appendChild(details); meta.appendChild(details);
@@ -617,7 +666,8 @@ function renderAssetList() {
const toggleBtn = document.createElement('button'); const toggleBtn = document.createElement('button');
toggleBtn.type = 'button'; toggleBtn.type = 'button';
toggleBtn.className = 'ghost icon-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) => { toggleBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
selectedAssetId = asset.id; selectedAssetId = asset.id;
@@ -627,7 +677,8 @@ function renderAssetList() {
const deleteBtn = document.createElement('button'); const deleteBtn = document.createElement('button');
deleteBtn.type = 'button'; deleteBtn.type = 'button';
deleteBtn.className = 'ghost danger icon-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) => { deleteBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
deleteAsset(asset); deleteAsset(asset);
@@ -636,19 +687,29 @@ function renderAssetList() {
actions.appendChild(toggleBtn); actions.appendChild(toggleBtn);
actions.appendChild(deleteBtn); actions.appendChild(deleteBtn);
row.appendChild(preview);
row.appendChild(meta);
row.appendChild(actions);
li.addEventListener('click', () => { li.addEventListener('click', () => {
selectedAssetId = asset.id; selectedAssetId = asset.id;
renderStates.set(asset.id, { ...asset }); updateRenderState(asset);
drawAndList(); drawAndList();
}); });
li.appendChild(preview); li.appendChild(row);
li.appendChild(meta);
li.appendChild(actions); 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); list.appendChild(li);
}); });
updateSelectedAssetControls();
} }
function createPreviewElement(asset) { function createPreviewElement(asset) {
@@ -675,22 +736,17 @@ function getSelectedAsset() {
return selectedAssetId ? assets.get(selectedAssetId) : null; return selectedAssetId ? assets.get(selectedAssetId) : null;
} }
function updateSelectedAssetControls() { function updateSelectedAssetControls(asset = getSelectedAsset()) {
if (!controlsPanel) { if (!controlsPanel || !asset) {
return; if (controlsPanel) controlsPanel.classList.add('hidden');
}
const asset = getSelectedAsset();
if (!asset) {
controlsPanel.classList.add('hidden');
return; return;
} }
controlsPanel.classList.remove('hidden'); controlsPanel.classList.remove('hidden');
lastSizeInputChanged = null; lastSizeInputChanged = null;
selectedAssetName.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`; 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) { if (selectedZLabel) {
selectedZLabel.textContent = asset.zIndex ?? 0; selectedZLabel.textContent = asset.zIndex ?? 1;
} }
if (selectedTypeLabel) { if (selectedTypeLabel) {
selectedTypeLabel.textContent = getDisplayMediaType(asset); selectedTypeLabel.textContent = getDisplayMediaType(asset);
@@ -700,8 +756,11 @@ function updateSelectedAssetControls() {
selectedVisibilityBadge.classList.toggle('danger', !!asset.hidden); selectedVisibilityBadge.classList.toggle('danger', !!asset.hidden);
} }
if (selectedToggleBtn) { if (selectedToggleBtn) {
selectedToggleBtn.querySelector('.label').textContent = asset.hidden ? 'Show' : 'Hide'; const icon = selectedToggleBtn.querySelector('i');
selectedToggleBtn.querySelector('.icon').textContent = asset.hidden ? '👁️' : '🙈'; 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); if (widthInput) widthInput.value = Math.round(asset.width);
@@ -711,7 +770,13 @@ function updateSelectedAssetControls() {
aspectLockInput.onchange = () => setAspectLock(asset.id, aspectLockInput.checked); aspectLockInput.onchange = () => setAspectLock(asset.id, aspectLockInput.checked);
} }
if (speedInput) { 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) { if (muteInput) {
muteInput.checked = !!asset.muted; muteInput.checked = !!asset.muted;
@@ -740,18 +805,22 @@ function applyTransformFromInputs() {
asset.width = Math.max(10, nextWidth); asset.width = Math.max(10, nextWidth);
asset.height = Math.max(10, nextHeight); asset.height = Math.max(10, nextHeight);
renderStates.set(asset.id, { ...asset }); updateRenderState(asset);
persistTransform(asset); persistTransform(asset);
drawAndList(); drawAndList();
} }
function updatePlaybackFromInputs() { function updatePlaybackFromInputs() {
const asset = getSelectedAsset(); const asset = getSelectedAsset();
if (!asset) return; if (!asset || !isVideoAsset(asset)) return;
const percent = Math.max(10, Math.min(400, parseFloat(speedInput?.value) || 100)); const percent = Math.max(0, Math.min(1000, parseFloat(speedInput?.value) || 100));
asset.speed = percent / 100; asset.speed = percent / 100;
renderStates.set(asset.id, { ...asset }); updateRenderState(asset);
persistTransform(asset); persistTransform(asset);
const media = mediaCache.get(asset.id);
if (media) {
applyMediaSettings(media, asset);
}
drawAndList(); drawAndList();
} }
@@ -759,7 +828,7 @@ function updateMuteFromInput() {
const asset = getSelectedAsset(); const asset = getSelectedAsset();
if (!asset || !isVideoAsset(asset)) return; if (!asset || !isVideoAsset(asset)) return;
asset.muted = !!muteInput?.checked; asset.muted = !!muteInput?.checked;
renderStates.set(asset.id, { ...asset }); updateRenderState(asset);
persistTransform(asset); persistTransform(asset);
const media = mediaCache.get(asset.id); const media = mediaCache.get(asset.id);
if (media) { if (media) {
@@ -773,7 +842,7 @@ function nudgeRotation(delta) {
if (!asset) return; if (!asset) return;
const next = (asset.rotation || 0) + delta; const next = (asset.rotation || 0) + delta;
asset.rotation = next; asset.rotation = next;
renderStates.set(asset.id, { ...asset }); updateRenderState(asset);
persistTransform(asset); persistTransform(asset);
drawAndList(); drawAndList();
} }
@@ -785,7 +854,7 @@ function recenterSelectedAsset() {
const centerY = (canvas.height - asset.height) / 2; const centerY = (canvas.height - asset.height) / 2;
asset.x = centerX; asset.x = centerX;
asset.y = centerY; asset.y = centerY;
renderStates.set(asset.id, { ...asset }); updateRenderState(asset);
persistTransform(asset); persistTransform(asset);
drawAndList(); drawAndList();
} }
@@ -829,13 +898,15 @@ function sendToBack() {
function applyZOrder(ordered) { function applyZOrder(ordered) {
const changed = []; const changed = [];
ordered.forEach((item, index) => { ordered.forEach((item, index) => {
if ((item.zIndex ?? 0) !== index) { const nextIndex = index + 1;
item.zIndex = index; if ((item.zIndex ?? 1) !== nextIndex) {
item.zIndex = nextIndex;
changed.push(item); changed.push(item);
} }
assets.set(item.id, item); assets.set(item.id, item);
renderStates.set(item.id, { ...item }); updateRenderState(item);
}); });
zOrderDirty = true;
changed.forEach((item) => persistTransform(item, true)); changed.forEach((item) => persistTransform(item, true));
drawAndList(); drawAndList();
} }
@@ -891,7 +962,8 @@ function updateVisibility(asset, hidden) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hidden }) body: JSON.stringify({ hidden })
}).then((r) => r.json()).then((updated) => { }).then((r) => r.json()).then((updated) => {
assets.set(updated.id, updated); storeAsset(updated);
updateRenderState(updated);
drawAndList(); drawAndList();
}); });
} }
@@ -901,6 +973,7 @@ function deleteAsset(asset) {
assets.delete(asset.id); assets.delete(asset.id);
mediaCache.delete(asset.id); mediaCache.delete(asset.id);
renderStates.delete(asset.id); renderStates.delete(asset.id);
zOrderDirty = true;
if (selectedAssetId === asset.id) { if (selectedAssetId === asset.id) {
selectedAssetId = null; selectedAssetId = null;
} }
@@ -953,6 +1026,7 @@ function findAssetAtPoint(x, y) {
} }
function persistTransform(asset, silent = false) { function persistTransform(asset, silent = false) {
asset.zIndex = Math.max(1, asset.zIndex ?? 1);
fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, { fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -967,7 +1041,8 @@ function persistTransform(asset, silent = false) {
zIndex: asset.zIndex zIndex: asset.zIndex
}) })
}).then((r) => r.json()).then((updated) => { }).then((r) => r.json()).then((updated) => {
assets.set(updated.id, updated); storeAsset(updated);
updateRenderState(updated);
if (!silent) { if (!silent) {
drawAndList(); drawAndList();
} }
@@ -1001,7 +1076,7 @@ canvas.addEventListener('mousedown', (event) => {
const hit = findAssetAtPoint(point.x, point.y); const hit = findAssetAtPoint(point.x, point.y);
if (hit) { if (hit) {
selectedAssetId = hit.id; selectedAssetId = hit.id;
renderStates.set(hit.id, { ...hit }); updateRenderState(hit);
interactionState = { interactionState = {
mode: 'move', mode: 'move',
assetId: hit.id, assetId: hit.id,
@@ -1033,18 +1108,18 @@ canvas.addEventListener('mousemove', (event) => {
if (interactionState.mode === 'move') { if (interactionState.mode === 'move') {
asset.x = point.x - interactionState.offsetX; asset.x = point.x - interactionState.offsetX;
asset.y = point.y - interactionState.offsetY; asset.y = point.y - interactionState.offsetY;
renderStates.set(asset.id, { ...asset }); updateRenderState(asset);
canvas.style.cursor = 'grabbing'; canvas.style.cursor = 'grabbing';
draw(); requestDraw();
} else if (interactionState.mode === 'resize') { } else if (interactionState.mode === 'resize') {
resizeFromHandle(interactionState, point); resizeFromHandle(interactionState, point);
canvas.style.cursor = cursorForHandle(interactionState.handle); canvas.style.cursor = cursorForHandle(interactionState.handle);
} else if (interactionState.mode === 'rotate') { } else if (interactionState.mode === 'rotate') {
const angle = angleFromCenter(asset, point); const angle = angleFromCenter(asset, point);
asset.rotation = (interactionState.startRotation || 0) + (angle - interactionState.startAngle); asset.rotation = (interactionState.startRotation || 0) + (angle - interactionState.startAngle);
renderStates.set(asset.id, { ...asset }); updateRenderState(asset);
canvas.style.cursor = 'grabbing'; canvas.style.cursor = 'grabbing';
draw(); requestDraw();
} }
}); });
@@ -1070,6 +1145,5 @@ window.addEventListener('resize', () => {
fetchCanvasSettings().finally(() => { fetchCanvasSettings().finally(() => {
resizeCanvas(); resizeCanvas();
startRenderLoop();
connect(); connect();
}); });

View File

@@ -22,7 +22,10 @@ function connect() {
} }
function renderAssets(list) { 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(); draw();
} }
@@ -55,8 +58,9 @@ function handleEvent(event) {
clearMedia(event.assetId); clearMedia(event.assetId);
renderStates.delete(event.assetId); renderStates.delete(event.assetId);
} else if (event.payload && !event.payload.hidden) { } else if (event.payload && !event.payload.hidden) {
assets.set(event.payload.id, event.payload); const payload = { ...event.payload, zIndex: Math.max(1, event.payload.zIndex ?? 1) };
ensureMedia(event.payload); assets.set(payload.id, payload);
ensureMedia(payload);
} else if (event.payload && event.payload.hidden) { } else if (event.payload && event.payload.hidden) {
assets.delete(event.payload.id); assets.delete(event.payload.id);
clearMedia(event.payload.id); clearMedia(event.payload.id);
@@ -75,8 +79,8 @@ function getZOrderedAssets() {
} }
function zComparator(a, b) { function zComparator(a, b) {
const aZ = a?.zIndex ?? 0; const aZ = a?.zIndex ?? 1;
const bZ = b?.zIndex ?? 0; const bZ = b?.zIndex ?? 1;
if (aZ !== bZ) { if (aZ !== bZ) {
return aZ - bZ; return aZ - bZ;
} }
@@ -125,7 +129,7 @@ function lerp(a, b, t) {
} }
function isVideoAsset(asset) { function isVideoAsset(asset) {
return (asset.mediaType && asset.mediaType.startsWith('video/')) || asset.url?.startsWith('data:video/'); return asset?.mediaType?.startsWith('video/');
} }
function isVideoElement(element) { function isVideoElement(element) {
@@ -133,7 +137,7 @@ function isVideoElement(element) {
} }
function isGifAsset(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) { function isDrawable(element) {
@@ -187,8 +191,13 @@ function ensureMedia(asset) {
element.autoplay = true; element.autoplay = true;
element.onloadeddata = draw; element.onloadeddata = draw;
element.src = asset.url; 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(() => {}); element.play().catch(() => {});
}
} else { } else {
element.onload = draw; element.onload = draw;
element.src = asset.url; element.src = asset.url;
@@ -279,15 +288,18 @@ function applyMediaSettings(element, asset) {
if (!isVideoElement(element)) { if (!isVideoElement(element)) {
return; return;
} }
const nextSpeed = asset.speed && asset.speed > 0 ? asset.speed : 1; const nextSpeed = asset.speed ?? 1;
if (element.playbackRate !== nextSpeed) { const effectiveSpeed = Math.max(nextSpeed, 0.01);
element.playbackRate = nextSpeed; if (element.playbackRate !== effectiveSpeed) {
element.playbackRate = effectiveSpeed;
} }
const shouldMute = asset.muted ?? true; const shouldMute = asset.muted ?? true;
if (element.muted !== shouldMute) { if (element.muted !== shouldMute) {
element.muted = shouldMute; element.muted = shouldMute;
} }
if (element.paused) { if (nextSpeed === 0) {
element.pause();
} else if (element.paused) {
element.play().catch(() => {}); element.play().catch(() => {});
} }
} }

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Imgfloat Admin</title> <title>Imgfloat Admin</title>
<link rel="stylesheet" href="/css/styles.css" /> <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://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/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.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"> <section class="controls">
<div> <div>
<h3>Overlay assets</h3> <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/*" /> <input id="asset-file" type="file" accept="image/*,video/*" />
<button onclick="uploadAsset()">Upload</button> <button onclick="uploadAsset()">Upload</button>
<ul id="asset-list" class="asset-list"></ul> <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 id="asset-controls" class="panel hidden asset-settings">
<div class="panel-header"> <div class="panel-header">
<div class="selected-asset-banner"> <h4 id="selected-asset-name">Asset settings</h4>
<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>
<div class="selected-asset-actions"> <div class="selected-asset-actions">
<button type="button" id="selected-asset-toggle" class="ghost icon-button" title="Toggle visibility"> <button type="button" id="selected-asset-toggle" class="ghost icon-button" title="Toggle visibility">
<span class="icon" aria-hidden="true">🙈</span> <i class="fa-solid fa-eye" aria-hidden="true"></i>
<span class="label">Hide</span> <span class="sr-only">Toggle visibility</span>
</button> </button>
<button type="button" id="selected-asset-delete" class="ghost danger icon-button" title="Delete asset"> <button type="button" id="selected-asset-delete" class="ghost danger icon-button" title="Delete asset">
<span class="icon" aria-hidden="true">🗑️</span> <i class="fa-solid fa-trash" aria-hidden="true"></i>
<span class="label">Delete</span> <span class="sr-only">Delete asset</span>
</button> </button>
</div> </div>
</div> </div>
<div class="panel-summary"> <div class="badge-row stacked">
<p class="muted">Fine-tune the selected overlay item with sizing, playback, and layering controls.</p> <span class="badge subtle" id="selected-asset-visibility">Visible</span>
</div> <span class="badge subtle" id="asset-type-label"></span>
</div> </div>
<div class="panel-section"> <div class="panel-section">
<div class="section-header"> <div class="section-header">
<h5>Size & placement</h5> <h5>Layout & order</h5>
<p class="muted">Keep your overlay crisp by locking aspect ratio while resizing.</p>
</div> </div>
<div class="control-grid condensed"> <div class="control-grid condensed three-col">
<label> <label>
Width Width
<input id="asset-width" class="number-input" type="number" min="10" step="5" /> <input id="asset-width" class="number-input" type="number" min="10" step="5" />
@@ -68,56 +61,47 @@
</label> </label>
<label class="checkbox-inline"> <label class="checkbox-inline">
<input id="maintain-aspect" type="checkbox" checked /> <input id="maintain-aspect" type="checkbox" checked />
Maintain aspect ratio Maintain aspect
</label> </label>
</div> </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"> <div class="control-grid condensed">
<label> <label>
Animation speed (% of original) Layer (Z)
<input id="asset-speed" class="number-input" type="number" min="10" max="400" step="5" value="100" /> <div class="badge-row stacked">
<span class="badge">Layer <strong id="asset-z-level">1</strong></span>
</div>
</label> </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"> <label class="checkbox-inline">
<input id="asset-muted" type="checkbox" /> <input id="asset-muted" type="checkbox" />
Mute Mute
</label> </label>
</div> </div>
</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"> <div class="control-actions filled compact">
<button type="button" onclick="nudgeRotation(-5)" class="secondary">Rotate left</button> <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">Rotate right</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()">Apply size</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>
<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> </div>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@ package com.imgfloat.app;
import com.imgfloat.app.model.TransformRequest; import com.imgfloat.app.model.TransformRequest;
import com.imgfloat.app.model.VisibilityRequest; import com.imgfloat.app.model.VisibilityRequest;
import com.imgfloat.app.model.Asset; import com.imgfloat.app.model.Asset;
import com.imgfloat.app.model.AssetView;
import com.imgfloat.app.model.Channel; import com.imgfloat.app.model.Channel;
import com.imgfloat.app.repository.AssetRepository; import com.imgfloat.app.repository.AssetRepository;
import com.imgfloat.app.repository.ChannelRepository; import com.imgfloat.app.repository.ChannelRepository;
@@ -51,7 +52,7 @@ class ChannelDirectoryServiceTest {
void createsAssetsAndBroadcastsEvents() throws Exception { void createsAssetsAndBroadcastsEvents() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng()); 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(); assertThat(created).isPresent();
ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class); ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.contains("/topic/channel/caster"), captor.capture()); verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.contains("/topic/channel/caster"), captor.capture());
@@ -61,7 +62,7 @@ class ChannelDirectoryServiceTest {
void updatesTransformAndVisibility() throws Exception { void updatesTransformAndVisibility() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng()); MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng());
String channel = "caster"; String channel = "caster";
String id = service.createAsset(channel, file).orElseThrow().getId(); String id = service.createAsset(channel, file).orElseThrow().id();
TransformRequest transform = new TransformRequest(); TransformRequest transform = new TransformRequest();
transform.setX(10); transform.setX(10);