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;
|
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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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.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) { }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user