mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Improve translation controls
This commit is contained in:
60
src/main/java/com/imgfloat/app/config/SchemaMigration.java
Normal file
60
src/main/java/com/imgfloat/app/config/SchemaMigration.java
Normal file
@@ -0,0 +1,60 @@
|
||||
package com.imgfloat.app.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class SchemaMigration implements ApplicationRunner {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SchemaMigration.class);
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public SchemaMigration(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
ensureChannelCanvasColumns();
|
||||
}
|
||||
|
||||
private void ensureChannelCanvasColumns() {
|
||||
List<String> columns;
|
||||
try {
|
||||
columns = jdbcTemplate.query("PRAGMA table_info(channels)", (rs, rowNum) -> rs.getString("name"));
|
||||
} catch (DataAccessException ex) {
|
||||
logger.warn("Unable to inspect channels table for canvas columns", ex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (columns.isEmpty()) {
|
||||
// Table does not exist yet; Hibernate will create it with the correct columns.
|
||||
return;
|
||||
}
|
||||
|
||||
addColumnIfMissing(columns, "canvas_width", "REAL", "1920");
|
||||
addColumnIfMissing(columns, "canvas_height", "REAL", "1080");
|
||||
}
|
||||
|
||||
private void addColumnIfMissing(List<String> existingColumns, String columnName, String dataType, String defaultValue) {
|
||||
if (existingColumns.contains(columnName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
jdbcTemplate.execute("ALTER TABLE channels ADD COLUMN " + columnName + " " + dataType + " DEFAULT " + defaultValue);
|
||||
logger.info("Added missing column '{}' to channels table", columnName);
|
||||
} catch (DataAccessException ex) {
|
||||
logger.warn("Failed to add column '{}' to channels table", columnName, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.imgfloat.app.controller;
|
||||
|
||||
import com.imgfloat.app.model.AdminRequest;
|
||||
import com.imgfloat.app.model.Asset;
|
||||
import com.imgfloat.app.model.CanvasSettingsRequest;
|
||||
import com.imgfloat.app.model.TransformRequest;
|
||||
import com.imgfloat.app.model.VisibilityRequest;
|
||||
import com.imgfloat.app.service.ChannelDirectoryService;
|
||||
@@ -85,6 +86,23 @@ public class ChannelApiController {
|
||||
return channelDirectoryService.getVisibleAssets(broadcaster);
|
||||
}
|
||||
|
||||
@GetMapping("/canvas")
|
||||
public CanvasSettingsRequest getCanvas(@PathVariable("broadcaster") String broadcaster,
|
||||
OAuth2AuthenticationToken authentication) {
|
||||
String login = TwitchUser.from(authentication).login();
|
||||
ensureAuthorized(broadcaster, login);
|
||||
return channelDirectoryService.getCanvasSettings(broadcaster);
|
||||
}
|
||||
|
||||
@PutMapping("/canvas")
|
||||
public CanvasSettingsRequest updateCanvas(@PathVariable("broadcaster") String broadcaster,
|
||||
@Valid @RequestBody CanvasSettingsRequest request,
|
||||
OAuth2AuthenticationToken authentication) {
|
||||
String login = TwitchUser.from(authentication).login();
|
||||
ensureBroadcaster(broadcaster, login);
|
||||
return channelDirectoryService.updateCanvasSettings(broadcaster, request);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<Asset> createAsset(@PathVariable("broadcaster") String broadcaster,
|
||||
@org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.imgfloat.app.model;
|
||||
|
||||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
public class CanvasSettingsRequest {
|
||||
@Positive
|
||||
private double width;
|
||||
|
||||
@Positive
|
||||
private double height;
|
||||
|
||||
public CanvasSettingsRequest() {
|
||||
}
|
||||
|
||||
public CanvasSettingsRequest(double width, double height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
public double getWidth() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public void setWidth(double width) {
|
||||
this.width = width;
|
||||
}
|
||||
|
||||
public double getHeight() {
|
||||
return height;
|
||||
}
|
||||
|
||||
public void setHeight(double height) {
|
||||
this.height = height;
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,17 @@ public class Channel {
|
||||
@Column(name = "admin_username")
|
||||
private Set<String> admins = new HashSet<>();
|
||||
|
||||
private double canvasWidth = 1920;
|
||||
|
||||
private double canvasHeight = 1080;
|
||||
|
||||
public Channel() {
|
||||
}
|
||||
|
||||
public Channel(String broadcaster) {
|
||||
this.broadcaster = normalize(broadcaster);
|
||||
this.canvasWidth = 1920;
|
||||
this.canvasHeight = 1080;
|
||||
}
|
||||
|
||||
public String getBroadcaster() {
|
||||
@@ -51,6 +57,22 @@ public class Channel {
|
||||
return admins.remove(normalize(username));
|
||||
}
|
||||
|
||||
public double getCanvasWidth() {
|
||||
return canvasWidth;
|
||||
}
|
||||
|
||||
public void setCanvasWidth(double canvasWidth) {
|
||||
this.canvasWidth = canvasWidth;
|
||||
}
|
||||
|
||||
public double getCanvasHeight() {
|
||||
return canvasHeight;
|
||||
}
|
||||
|
||||
public void setCanvasHeight(double canvasHeight) {
|
||||
this.canvasHeight = canvasHeight;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
@PreUpdate
|
||||
public void normalizeFields() {
|
||||
@@ -58,6 +80,12 @@ public class Channel {
|
||||
this.admins = admins.stream()
|
||||
.map(Channel::normalize)
|
||||
.collect(Collectors.toSet());
|
||||
if (canvasWidth <= 0) {
|
||||
canvasWidth = 1920;
|
||||
}
|
||||
if (canvasHeight <= 0) {
|
||||
canvasHeight = 1080;
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.imgfloat.app.service;
|
||||
import com.imgfloat.app.model.Asset;
|
||||
import com.imgfloat.app.model.AssetEvent;
|
||||
import com.imgfloat.app.model.Channel;
|
||||
import com.imgfloat.app.model.CanvasSettingsRequest;
|
||||
import com.imgfloat.app.model.TransformRequest;
|
||||
import com.imgfloat.app.model.VisibilityRequest;
|
||||
import com.imgfloat.app.repository.AssetRepository;
|
||||
@@ -68,6 +69,19 @@ public class ChannelDirectoryService {
|
||||
return assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster));
|
||||
}
|
||||
|
||||
public CanvasSettingsRequest getCanvasSettings(String broadcaster) {
|
||||
Channel channel = getOrCreateChannel(broadcaster);
|
||||
return new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight());
|
||||
}
|
||||
|
||||
public CanvasSettingsRequest updateCanvasSettings(String broadcaster, CanvasSettingsRequest request) {
|
||||
Channel channel = getOrCreateChannel(broadcaster);
|
||||
channel.setCanvasWidth(request.getWidth());
|
||||
channel.setCanvasHeight(request.getHeight());
|
||||
channelRepository.save(channel);
|
||||
return new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight());
|
||||
}
|
||||
|
||||
public Optional<Asset> createAsset(String broadcaster, MultipartFile file) throws IOException {
|
||||
Channel channel = getOrCreateChannel(broadcaster);
|
||||
byte[] bytes = file.getBytes();
|
||||
|
||||
@@ -6,6 +6,12 @@ body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.admin-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 960px;
|
||||
margin: 40px auto;
|
||||
@@ -55,8 +61,11 @@ body {
|
||||
|
||||
.overlay {
|
||||
position: relative;
|
||||
height: 600px;
|
||||
flex: 1;
|
||||
min-height: 420px;
|
||||
height: calc(100vh - 260px);
|
||||
background: black;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overlay iframe {
|
||||
@@ -73,8 +82,6 @@ body {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: auto;
|
||||
z-index: 2;
|
||||
}
|
||||
@@ -210,6 +217,18 @@ body {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.checkbox-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.range-value {
|
||||
color: #a5b4fc;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
let stompClient;
|
||||
const canvas = document.getElementById('admin-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
const overlay = document.getElementById('admin-overlay');
|
||||
const overlayFrame = overlay?.querySelector('iframe');
|
||||
let canvasSettings = { width: 1920, height: 1080 };
|
||||
canvas.width = canvasSettings.width;
|
||||
canvas.height = canvasSettings.height;
|
||||
const assets = new Map();
|
||||
const imageCache = new Map();
|
||||
const renderStates = new Map();
|
||||
let selectedAssetId = null;
|
||||
let dragState = null;
|
||||
let animationFrameId = null;
|
||||
let lastSizeInputChanged = null;
|
||||
|
||||
const controlsPanel = document.getElementById('asset-controls');
|
||||
const widthInput = document.getElementById('asset-width');
|
||||
const heightInput = document.getElementById('asset-height');
|
||||
const rotationInput = document.getElementById('asset-rotation');
|
||||
const rotationDisplay = document.getElementById('rotation-display');
|
||||
const aspectLockInput = document.getElementById('maintain-aspect');
|
||||
const selectedAssetName = document.getElementById('selected-asset-name');
|
||||
const selectedAssetMeta = document.getElementById('selected-asset-meta');
|
||||
const aspectLockState = new Map();
|
||||
|
||||
if (rotationInput) {
|
||||
rotationInput.addEventListener('input', updateRotationDisplay);
|
||||
}
|
||||
if (widthInput) widthInput.addEventListener('input', () => handleSizeInputChange('width'));
|
||||
if (heightInput) heightInput.addEventListener('input', () => handleSizeInputChange('height'));
|
||||
|
||||
function connect() {
|
||||
const socket = new SockJS('/ws');
|
||||
@@ -38,6 +41,39 @@ function fetchAssets() {
|
||||
fetch(`/api/channels/${broadcaster}/assets`).then((r) => r.json()).then(renderAssets);
|
||||
}
|
||||
|
||||
function fetchCanvasSettings() {
|
||||
return fetch(`/api/channels/${broadcaster}/canvas`)
|
||||
.then((r) => r.json())
|
||||
.then((settings) => {
|
||||
canvasSettings = settings;
|
||||
resizeCanvas();
|
||||
})
|
||||
.catch(() => resizeCanvas());
|
||||
}
|
||||
|
||||
function resizeCanvas() {
|
||||
if (!overlay) {
|
||||
return;
|
||||
}
|
||||
const bounds = overlay.getBoundingClientRect();
|
||||
const scale = Math.min(bounds.width / canvasSettings.width, bounds.height / canvasSettings.height);
|
||||
const displayWidth = canvasSettings.width * scale;
|
||||
const displayHeight = canvasSettings.height * scale;
|
||||
canvas.width = canvasSettings.width;
|
||||
canvas.height = canvasSettings.height;
|
||||
canvas.style.width = `${displayWidth}px`;
|
||||
canvas.style.height = `${displayHeight}px`;
|
||||
canvas.style.left = `${(bounds.width - displayWidth) / 2}px`;
|
||||
canvas.style.top = `${(bounds.height - displayHeight) / 2}px`;
|
||||
if (overlayFrame) {
|
||||
overlayFrame.style.width = `${displayWidth}px`;
|
||||
overlayFrame.style.height = `${displayHeight}px`;
|
||||
overlayFrame.style.left = `${(bounds.width - displayWidth) / 2}px`;
|
||||
overlayFrame.style.top = `${(bounds.height - displayHeight) / 2}px`;
|
||||
}
|
||||
draw();
|
||||
}
|
||||
|
||||
function renderAssets(list) {
|
||||
list.forEach((asset) => assets.set(asset.id, asset));
|
||||
drawAndList();
|
||||
@@ -70,30 +106,32 @@ function draw() {
|
||||
|
||||
function drawAsset(asset) {
|
||||
const renderState = smoothState(asset);
|
||||
const halfWidth = renderState.width / 2;
|
||||
const halfHeight = renderState.height / 2;
|
||||
ctx.save();
|
||||
ctx.translate(renderState.x, renderState.y);
|
||||
ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight);
|
||||
ctx.rotate(renderState.rotation * Math.PI / 180);
|
||||
|
||||
const image = ensureImage(asset);
|
||||
if (image?.complete) {
|
||||
ctx.globalAlpha = asset.hidden ? 0.35 : 0.9;
|
||||
ctx.drawImage(image, 0, 0, renderState.width, renderState.height);
|
||||
ctx.drawImage(image, -halfWidth, -halfHeight, renderState.width, renderState.height);
|
||||
} else {
|
||||
ctx.globalAlpha = asset.hidden ? 0.2 : 0.4;
|
||||
ctx.fillStyle = 'rgba(124, 58, 237, 0.35)';
|
||||
ctx.fillRect(0, 0, renderState.width, renderState.height);
|
||||
ctx.fillRect(-halfWidth, -halfHeight, renderState.width, renderState.height);
|
||||
}
|
||||
|
||||
if (asset.hidden) {
|
||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.35)';
|
||||
ctx.fillRect(0, 0, renderState.width, renderState.height);
|
||||
ctx.fillRect(-halfWidth, -halfHeight, renderState.width, renderState.height);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.strokeStyle = asset.id === selectedAssetId ? 'rgba(124, 58, 237, 0.9)' : 'rgba(255, 255, 255, 0.4)';
|
||||
ctx.lineWidth = asset.id === selectedAssetId ? 2 : 1;
|
||||
ctx.setLineDash(asset.id === selectedAssetId ? [6, 4] : []);
|
||||
ctx.strokeRect(0, 0, renderState.width, renderState.height);
|
||||
ctx.strokeRect(-halfWidth, -halfHeight, renderState.width, renderState.height);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
@@ -238,27 +276,38 @@ function updateSelectedAssetControls() {
|
||||
}
|
||||
|
||||
controlsPanel.classList.remove('hidden');
|
||||
lastSizeInputChanged = null;
|
||||
selectedAssetName.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`;
|
||||
selectedAssetMeta.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)} · ${asset.hidden ? 'Hidden' : 'Visible'}`;
|
||||
|
||||
if (widthInput) widthInput.value = Math.round(asset.width);
|
||||
if (heightInput) heightInput.value = Math.round(asset.height);
|
||||
if (rotationInput) {
|
||||
rotationInput.value = Math.round(asset.rotation);
|
||||
updateRotationDisplay();
|
||||
if (aspectLockInput) {
|
||||
aspectLockInput.checked = isAspectLocked(asset.id);
|
||||
aspectLockInput.onchange = () => setAspectLock(asset.id, aspectLockInput.checked);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTransformFromInputs() {
|
||||
const asset = getSelectedAsset();
|
||||
if (!asset) return;
|
||||
const nextWidth = parseFloat(widthInput?.value) || asset.width;
|
||||
const nextHeight = parseFloat(heightInput?.value) || asset.height;
|
||||
const nextRotation = parseFloat(rotationInput?.value) || 0;
|
||||
const locked = isAspectLocked(asset.id);
|
||||
const ratio = getAssetAspectRatio(asset);
|
||||
let nextWidth = parseFloat(widthInput?.value) || asset.width;
|
||||
let nextHeight = parseFloat(heightInput?.value) || asset.height;
|
||||
|
||||
if (locked && ratio) {
|
||||
if (lastSizeInputChanged === 'height') {
|
||||
nextWidth = nextHeight * ratio;
|
||||
if (widthInput) widthInput.value = Math.round(nextWidth);
|
||||
} else {
|
||||
nextHeight = nextWidth / ratio;
|
||||
if (heightInput) heightInput.value = Math.round(nextHeight);
|
||||
}
|
||||
}
|
||||
|
||||
asset.width = Math.max(10, nextWidth);
|
||||
asset.height = Math.max(10, nextHeight);
|
||||
asset.rotation = nextRotation;
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
persistTransform(asset);
|
||||
drawAndList();
|
||||
@@ -268,17 +317,63 @@ function nudgeRotation(delta) {
|
||||
const asset = getSelectedAsset();
|
||||
if (!asset) return;
|
||||
const next = (asset.rotation || 0) + delta;
|
||||
if (rotationInput) rotationInput.value = next;
|
||||
asset.rotation = next;
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
updateRotationDisplay();
|
||||
persistTransform(asset);
|
||||
drawAndList();
|
||||
}
|
||||
|
||||
function updateRotationDisplay() {
|
||||
if (rotationDisplay && rotationInput) {
|
||||
const value = Math.round(parseFloat(rotationInput.value || '0'));
|
||||
rotationDisplay.textContent = `${value}°`;
|
||||
function recenterSelectedAsset() {
|
||||
const asset = getSelectedAsset();
|
||||
if (!asset) return;
|
||||
const centerX = (canvas.width - asset.width) / 2;
|
||||
const centerY = (canvas.height - asset.height) / 2;
|
||||
asset.x = centerX;
|
||||
asset.y = centerY;
|
||||
renderStates.set(asset.id, { ...asset });
|
||||
persistTransform(asset);
|
||||
drawAndList();
|
||||
}
|
||||
|
||||
function getAssetAspectRatio(asset) {
|
||||
const image = ensureImage(asset);
|
||||
if (image?.naturalWidth && image?.naturalHeight) {
|
||||
return image.naturalWidth / image.naturalHeight;
|
||||
}
|
||||
if (asset.width && asset.height) {
|
||||
return asset.width / asset.height;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setAspectLock(assetId, locked) {
|
||||
aspectLockState.set(assetId, locked);
|
||||
}
|
||||
|
||||
function isAspectLocked(assetId) {
|
||||
return aspectLockState.has(assetId) ? aspectLockState.get(assetId) : true;
|
||||
}
|
||||
|
||||
function handleSizeInputChange(type) {
|
||||
lastSizeInputChanged = type;
|
||||
const asset = getSelectedAsset();
|
||||
if (!asset || !isAspectLocked(asset.id)) {
|
||||
return;
|
||||
}
|
||||
const ratio = getAssetAspectRatio(asset);
|
||||
if (!ratio) {
|
||||
return;
|
||||
}
|
||||
if (type === 'width' && widthInput && heightInput) {
|
||||
const width = parseFloat(widthInput.value);
|
||||
if (width > 0) {
|
||||
heightInput.value = Math.round(width / ratio);
|
||||
}
|
||||
} else if (type === 'height' && widthInput && heightInput) {
|
||||
const height = parseFloat(heightInput.value);
|
||||
if (height > 0) {
|
||||
widthInput.value = Math.round(height * ratio);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,10 +428,12 @@ function getCanvasPoint(event) {
|
||||
|
||||
function isPointOnAsset(asset, x, y) {
|
||||
ctx.save();
|
||||
ctx.translate(asset.x, asset.y);
|
||||
const halfWidth = asset.width / 2;
|
||||
const halfHeight = asset.height / 2;
|
||||
ctx.translate(asset.x + halfWidth, asset.y + halfHeight);
|
||||
ctx.rotate(asset.rotation * Math.PI / 180);
|
||||
const path = new Path2D();
|
||||
path.rect(0, 0, asset.width, asset.height);
|
||||
path.rect(-halfWidth, -halfHeight, asset.width, asset.height);
|
||||
const hit = ctx.isPointInPath(path, x, y);
|
||||
ctx.restore();
|
||||
return hit;
|
||||
@@ -411,10 +508,11 @@ canvas.addEventListener('mouseup', endDrag);
|
||||
canvas.addEventListener('mouseleave', endDrag);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
draw();
|
||||
resizeCanvas();
|
||||
});
|
||||
|
||||
startRenderLoop();
|
||||
connect();
|
||||
fetchCanvasSettings().finally(() => {
|
||||
resizeCanvas();
|
||||
startRenderLoop();
|
||||
connect();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const canvas = document.getElementById('broadcast-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
let canvasSettings = { width: 1920, height: 1080 };
|
||||
canvas.width = canvasSettings.width;
|
||||
canvas.height = canvasSettings.height;
|
||||
const assets = new Map();
|
||||
const imageCache = new Map();
|
||||
const renderStates = new Map();
|
||||
@@ -24,6 +25,29 @@ function renderAssets(list) {
|
||||
draw();
|
||||
}
|
||||
|
||||
function fetchCanvasSettings() {
|
||||
return fetch(`/api/channels/${broadcaster}/canvas`)
|
||||
.then((r) => r.json())
|
||||
.then((settings) => {
|
||||
canvasSettings = settings;
|
||||
resizeCanvas();
|
||||
})
|
||||
.catch(() => resizeCanvas());
|
||||
}
|
||||
|
||||
function resizeCanvas() {
|
||||
const scale = Math.min(window.innerWidth / canvasSettings.width, window.innerHeight / canvasSettings.height);
|
||||
const displayWidth = canvasSettings.width * scale;
|
||||
const displayHeight = canvasSettings.height * scale;
|
||||
canvas.width = canvasSettings.width;
|
||||
canvas.height = canvasSettings.height;
|
||||
canvas.style.width = `${displayWidth}px`;
|
||||
canvas.style.height = `${displayHeight}px`;
|
||||
canvas.style.left = `${(window.innerWidth - displayWidth) / 2}px`;
|
||||
canvas.style.top = `${(window.innerHeight - displayHeight) / 2}px`;
|
||||
draw();
|
||||
}
|
||||
|
||||
function handleEvent(event) {
|
||||
if (event.type === 'DELETED') {
|
||||
assets.delete(event.assetId);
|
||||
@@ -47,13 +71,15 @@ function draw() {
|
||||
|
||||
function drawAsset(asset) {
|
||||
const renderState = smoothState(asset);
|
||||
const halfWidth = renderState.width / 2;
|
||||
const halfHeight = renderState.height / 2;
|
||||
ctx.save();
|
||||
ctx.translate(renderState.x, renderState.y);
|
||||
ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight);
|
||||
ctx.rotate(renderState.rotation * Math.PI / 180);
|
||||
|
||||
const image = ensureImage(asset);
|
||||
if (image?.complete) {
|
||||
ctx.drawImage(image, 0, 0, renderState.width, renderState.height);
|
||||
ctx.drawImage(image, -halfWidth, -halfHeight, renderState.width, renderState.height);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
@@ -107,10 +133,11 @@ function startRenderLoop() {
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
draw();
|
||||
resizeCanvas();
|
||||
});
|
||||
|
||||
startRenderLoop();
|
||||
connect();
|
||||
fetchCanvasSettings().finally(() => {
|
||||
resizeCanvas();
|
||||
startRenderLoop();
|
||||
connect();
|
||||
});
|
||||
|
||||
@@ -41,4 +41,48 @@ function addAdmin() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderCanvasSettings(settings) {
|
||||
const widthInput = document.getElementById('canvas-width');
|
||||
const heightInput = document.getElementById('canvas-height');
|
||||
if (widthInput) widthInput.value = Math.round(settings.width);
|
||||
if (heightInput) heightInput.value = Math.round(settings.height);
|
||||
}
|
||||
|
||||
function fetchCanvasSettings() {
|
||||
fetch(`/api/channels/${broadcaster}/canvas`)
|
||||
.then((r) => r.json())
|
||||
.then(renderCanvasSettings)
|
||||
.catch(() => renderCanvasSettings({ width: 1920, height: 1080 }));
|
||||
}
|
||||
|
||||
function saveCanvasSettings() {
|
||||
const widthInput = document.getElementById('canvas-width');
|
||||
const heightInput = document.getElementById('canvas-height');
|
||||
const status = document.getElementById('canvas-status');
|
||||
const width = parseFloat(widthInput?.value) || 0;
|
||||
const height = parseFloat(heightInput?.value) || 0;
|
||||
if (width <= 0 || height <= 0) {
|
||||
alert('Please enter a valid width and height.');
|
||||
return;
|
||||
}
|
||||
if (status) status.textContent = 'Saving...';
|
||||
fetch(`/api/channels/${broadcaster}/canvas`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ width, height })
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((settings) => {
|
||||
renderCanvasSettings(settings);
|
||||
if (status) status.textContent = 'Saved.';
|
||||
setTimeout(() => {
|
||||
if (status) status.textContent = '';
|
||||
}, 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
if (status) status.textContent = 'Unable to save right now.';
|
||||
});
|
||||
}
|
||||
|
||||
fetchAdmins();
|
||||
fetchCanvasSettings();
|
||||
|
||||
@@ -39,21 +39,21 @@
|
||||
Height
|
||||
<input id="asset-height" type="number" min="10" step="5" />
|
||||
</label>
|
||||
<label>
|
||||
Rotation
|
||||
<input id="asset-rotation" type="range" min="-180" max="180" step="1" />
|
||||
<div class="range-value" id="rotation-display">0°</div>
|
||||
<label class="checkbox-inline">
|
||||
<input id="maintain-aspect" type="checkbox" checked />
|
||||
Maintain aspect ratio
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-actions">
|
||||
<button type="button" onclick="nudgeRotation(-5)" class="secondary">Rotate -5°</button>
|
||||
<button type="button" onclick="nudgeRotation(5)" class="secondary">Rotate +5°</button>
|
||||
<button type="button" onclick="applyTransformFromInputs()">Apply size & rotation</button>
|
||||
<button type="button" onclick="nudgeRotation(-5)" class="secondary">Rotate left</button>
|
||||
<button type="button" onclick="nudgeRotation(5)" class="secondary">Rotate right</button>
|
||||
<button type="button" onclick="applyTransformFromInputs()">Apply size</button>
|
||||
<button type="button" onclick="recenterSelectedAsset()" class="secondary">Re-center asset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="overlay">
|
||||
<section class="overlay" id="admin-overlay">
|
||||
<iframe th:src="${'https://player.twitch.tv/?channel=' + broadcaster + '&parent=localhost'}" allowfullscreen></iframe>
|
||||
<canvas id="admin-canvas"></canvas>
|
||||
</section>
|
||||
|
||||
@@ -16,6 +16,24 @@
|
||||
<button class="secondary" type="submit">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Canvas size</h3>
|
||||
<p>Set the pixel dimensions for your overlay. Admins will see this aspect ratio when positioning assets.</p>
|
||||
<div class="control-grid">
|
||||
<label>
|
||||
Width
|
||||
<input id="canvas-width" type="number" min="100" step="10" />
|
||||
</label>
|
||||
<label>
|
||||
Height
|
||||
<input id="canvas-height" type="number" min="100" step="10" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-actions">
|
||||
<button type="button" onclick="saveCanvasSettings()">Save canvas size</button>
|
||||
<span id="canvas-status" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Channel admins</h3>
|
||||
<p>Add trusted moderators who can upload overlay assets on your behalf.</p>
|
||||
|
||||
Reference in New Issue
Block a user