Add Spring Boot Twitch overlay server with CI and Docker

This commit is contained in:
2025-12-02 16:32:19 +01:00
parent dbcca9002c
commit 969e302802
30 changed files with 1463 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
body {
font-family: Arial, sans-serif;
background: #0f172a;
color: #e2e8f0;
margin: 0;
padding: 0;
}
.container {
max-width: 960px;
margin: 40px auto;
background: #111827;
padding: 24px;
border-radius: 12px;
box-shadow: 0 5px 16px rgba(0,0,0,0.3);
}
.button, button {
background: #7c3aed;
color: white;
padding: 10px 16px;
border: none;
border-radius: 8px;
cursor: pointer;
text-decoration: none;
}
.secondary {
background: #475569;
}
.admin-layout header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
}
.controls {
display: flex;
gap: 24px;
padding: 16px;
background: #0b1220;
}
.controls ul {
list-style: none;
padding: 0;
margin-top: 12px;
}
.controls li {
margin: 6px 0;
}
.overlay {
position: relative;
height: 600px;
background: black;
}
.overlay iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
.overlay canvas, .broadcast-body canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.broadcast-body {
margin: 0;
overflow: hidden;
background: transparent;
}

View File

@@ -0,0 +1,91 @@
let stompClient;
const canvas = document.getElementById('admin-canvas');
const ctx = canvas.getContext('2d');
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
const images = new Map();
function connect() {
const socket = new SockJS('/ws');
stompClient = Stomp.over(socket);
stompClient.connect({}, () => {
stompClient.subscribe(`/topic/channel/${broadcaster}`, (payload) => {
const body = JSON.parse(payload.body);
handleEvent(body);
});
fetchImages();
fetchAdmins();
});
}
function fetchImages() {
fetch(`/api/channels/${broadcaster}/images`).then(r => r.json()).then(renderImages);
}
function fetchAdmins() {
fetch(`/api/channels/${broadcaster}/admins`).then(r => r.json()).then(list => {
const adminList = document.getElementById('admin-list');
adminList.innerHTML = '';
list.forEach(a => {
const li = document.createElement('li');
li.textContent = a;
adminList.appendChild(li);
});
}).catch(() => {});
}
function renderImages(list) {
list.forEach(img => images.set(img.id, img));
draw();
}
function handleEvent(event) {
if (event.type === 'DELETED') {
images.delete(event.imageId);
} else if (event.payload) {
images.set(event.payload.id, event.payload);
}
draw();
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
images.forEach(img => {
ctx.save();
ctx.globalAlpha = img.hidden ? 0.35 : 1;
ctx.translate(img.x, img.y);
ctx.rotate(img.rotation * Math.PI / 180);
ctx.fillStyle = 'rgba(124, 58, 237, 0.25)';
ctx.fillRect(0, 0, img.width, img.height);
ctx.restore();
});
}
function uploadImage() {
const url = document.getElementById('image-url').value;
const width = parseFloat(document.getElementById('image-width').value);
const height = parseFloat(document.getElementById('image-height').value);
fetch(`/api/channels/${broadcaster}/images`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({url, width, height})
});
}
function addAdmin() {
const usernameInput = document.getElementById('new-admin');
const username = usernameInput.value;
fetch(`/api/channels/${broadcaster}/admins`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username})
}).then(() => fetchAdmins());
}
window.addEventListener('resize', () => {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
draw();
});
connect();

View File

@@ -0,0 +1,57 @@
const canvas = document.getElementById('broadcast-canvas');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const images = new Map();
function connect() {
const socket = new SockJS('/ws');
const stompClient = Stomp.over(socket);
stompClient.connect({}, () => {
stompClient.subscribe(`/topic/channel/${broadcaster}`, (payload) => {
const body = JSON.parse(payload.body);
handleEvent(body);
});
fetch(`/api/channels/${broadcaster}/images/visible`).then(r => r.json()).then(renderImages);
});
}
function renderImages(list) {
list.forEach(img => images.set(img.id, img));
draw();
}
function handleEvent(event) {
if (event.type === 'DELETED') {
images.delete(event.imageId);
} else if (event.payload && !event.payload.hidden) {
images.set(event.payload.id, event.payload);
} else if (event.payload && event.payload.hidden) {
images.delete(event.payload.id);
}
draw();
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
images.forEach(img => {
ctx.save();
ctx.globalAlpha = 1;
ctx.translate(img.x, img.y);
ctx.rotate(img.rotation * Math.PI / 180);
const image = new Image();
image.src = img.url;
image.onload = () => {
ctx.drawImage(image, 0, 0, img.width, img.height);
};
ctx.restore();
});
}
window.addEventListener('resize', () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
draw();
});
connect();