Improve translation controls

This commit is contained in:
2025-12-09 10:29:10 +01:00
parent fe915b71f4
commit d92617f82c
11 changed files with 413 additions and 52 deletions

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

View File

@@ -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,

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

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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;

View File

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

View File

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

View File

@@ -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();

View File

@@ -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"></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>

View File

@@ -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>