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,11 @@
package com.imgfloat.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ImgfloatApplication {
public static void main(String[] args) {
SpringApplication.run(ImgfloatApplication.class, args);
}
}

View File

@@ -0,0 +1,29 @@
package com.imgfloat.app.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/css/**", "/js/**", "/webjars/**", "/actuator/health").permitAll()
.requestMatchers("/ws/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(Customizer.withDefaults())
.logout(logout -> logout.logoutSuccessUrl("/").permitAll())
.csrf(csrf -> csrf.ignoringRequestMatchers("/ws/**", "/api/**"));
return http.build();
}
}

View File

@@ -0,0 +1,22 @@
package com.imgfloat.app.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
}

View File

@@ -0,0 +1,145 @@
package com.imgfloat.app.controller;
import com.imgfloat.app.model.AdminRequest;
import com.imgfloat.app.model.ImageLayer;
import com.imgfloat.app.model.ImageRequest;
import com.imgfloat.app.model.TransformRequest;
import com.imgfloat.app.model.VisibilityRequest;
import com.imgfloat.app.service.ChannelDirectoryService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.Collection;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@RestController
@RequestMapping("/api/channels/{broadcaster}")
public class ChannelApiController {
private final ChannelDirectoryService channelDirectoryService;
public ChannelApiController(ChannelDirectoryService channelDirectoryService) {
this.channelDirectoryService = channelDirectoryService;
}
@PostMapping("/admins")
public ResponseEntity<?> addAdmin(@PathVariable("broadcaster") String broadcaster,
@Valid @RequestBody AdminRequest request,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
ensureBroadcaster(broadcaster, login);
boolean added = channelDirectoryService.addAdmin(broadcaster, request.getUsername());
return ResponseEntity.ok().body(added);
}
@GetMapping("/admins")
public Collection<String> listAdmins(@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
ensureBroadcaster(broadcaster, login);
return channelDirectoryService.getOrCreateChannel(broadcaster).getAdmins();
}
@DeleteMapping("/admins/{username}")
public ResponseEntity<?> removeAdmin(@PathVariable("broadcaster") String broadcaster,
@PathVariable("username") String username,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
ensureBroadcaster(broadcaster, login);
boolean removed = channelDirectoryService.removeAdmin(broadcaster, username);
return ResponseEntity.ok().body(removed);
}
@GetMapping("/images")
public Collection<ImageLayer> listImages(@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
if (!channelDirectoryService.isBroadcaster(broadcaster, login)
&& !channelDirectoryService.isAdmin(broadcaster, login)) {
throw new ResponseStatusException(FORBIDDEN, "Not authorized");
}
return channelDirectoryService.getImagesForAdmin(broadcaster);
}
@GetMapping("/images/visible")
public Collection<ImageLayer> listVisible(@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
if (!channelDirectoryService.isBroadcaster(broadcaster, login)) {
throw new ResponseStatusException(FORBIDDEN, "Only broadcaster can load public overlay");
}
return channelDirectoryService.getVisibleImages(broadcaster);
}
@PostMapping("/images")
public ResponseEntity<ImageLayer> createImage(@PathVariable("broadcaster") String broadcaster,
@Valid @RequestBody ImageRequest request,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
ensureAuthorized(broadcaster, login);
return channelDirectoryService.createImage(broadcaster, request)
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Channel not found"));
}
@PutMapping("/images/{imageId}/transform")
public ResponseEntity<ImageLayer> transform(@PathVariable("broadcaster") String broadcaster,
@PathVariable("imageId") String imageId,
@Valid @RequestBody TransformRequest request,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
ensureAuthorized(broadcaster, login);
return channelDirectoryService.updateTransform(broadcaster, imageId, request)
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Image not found"));
}
@PutMapping("/images/{imageId}/visibility")
public ResponseEntity<ImageLayer> visibility(@PathVariable("broadcaster") String broadcaster,
@PathVariable("imageId") String imageId,
@RequestBody VisibilityRequest request,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
ensureAuthorized(broadcaster, login);
return channelDirectoryService.updateVisibility(broadcaster, imageId, request)
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Image not found"));
}
@DeleteMapping("/images/{imageId}")
public ResponseEntity<?> delete(@PathVariable("broadcaster") String broadcaster,
@PathVariable("imageId") String imageId,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
ensureAuthorized(broadcaster, login);
boolean removed = channelDirectoryService.deleteImage(broadcaster, imageId);
if (!removed) {
throw new ResponseStatusException(NOT_FOUND, "Image not found");
}
return ResponseEntity.ok().build();
}
private void ensureBroadcaster(String broadcaster, String login) {
if (!channelDirectoryService.isBroadcaster(broadcaster, login)) {
throw new ResponseStatusException(FORBIDDEN, "Only broadcasters can manage admins");
}
}
private void ensureAuthorized(String broadcaster, String login) {
if (!channelDirectoryService.isBroadcaster(broadcaster, login)
&& !channelDirectoryService.isAdmin(broadcaster, login)) {
throw new ResponseStatusException(FORBIDDEN, "No permission for channel");
}
}
}

View File

@@ -0,0 +1,76 @@
package com.imgfloat.app.controller;
import com.imgfloat.app.service.ChannelDirectoryService;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.server.ResponseStatusException;
import static org.springframework.http.HttpStatus.FORBIDDEN;
@Controller
public class ViewController {
private final ChannelDirectoryService channelDirectoryService;
public ViewController(ChannelDirectoryService channelDirectoryService) {
this.channelDirectoryService = channelDirectoryService;
}
@org.springframework.web.bind.annotation.GetMapping("/")
public String home(OAuth2AuthenticationToken authentication, Model model) {
if (authentication != null) {
String login = TwitchUser.from(authentication).login();
model.addAttribute("username", login);
model.addAttribute("channel", login);
return "dashboard";
}
return "index";
}
@org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/admin")
public String adminView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication,
Model model) {
String login = TwitchUser.from(authentication).login();
if (!channelDirectoryService.isBroadcaster(broadcaster, login)
&& !channelDirectoryService.isAdmin(broadcaster, login)) {
throw new ResponseStatusException(FORBIDDEN, "Not authorized for admin tools");
}
model.addAttribute("broadcaster", broadcaster.toLowerCase());
model.addAttribute("username", login);
return "admin";
}
@org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/broadcast")
public String broadcastView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication,
Model model) {
String login = TwitchUser.from(authentication).login();
if (!channelDirectoryService.isBroadcaster(broadcaster, login)) {
throw new ResponseStatusException(FORBIDDEN, "Only the broadcaster can render this view");
}
model.addAttribute("broadcaster", broadcaster.toLowerCase());
model.addAttribute("username", login);
return "broadcast";
}
}
record TwitchUser(String login, String displayName) {
static TwitchUser from(OAuth2AuthenticationToken authentication) {
if (authentication == null) {
throw new ResponseStatusException(FORBIDDEN, "Authentication required");
}
String login = authentication.getPrincipal().<String>getAttribute("preferred_username");
if (login == null) {
login = authentication.getPrincipal().<String>getAttribute("login");
}
if (login == null) {
login = authentication.getPrincipal().getName();
}
String displayName = authentication.getPrincipal().<String>getAttribute("display_name");
if (displayName == null) {
displayName = login;
}
return new TwitchUser(login, displayName);
}
}

View File

@@ -0,0 +1,16 @@
package com.imgfloat.app.model;
import jakarta.validation.constraints.NotBlank;
public class AdminRequest {
@NotBlank
private String username;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}

View File

@@ -0,0 +1,38 @@
package com.imgfloat.app.model;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public class Channel {
private final String broadcaster;
private final Set<String> admins;
private final Map<String, ImageLayer> images;
public Channel(String broadcaster) {
this.broadcaster = broadcaster.toLowerCase();
this.admins = ConcurrentHashMap.newKeySet();
this.images = new ConcurrentHashMap<>();
}
public String getBroadcaster() {
return broadcaster;
}
public Set<String> getAdmins() {
return Collections.unmodifiableSet(admins);
}
public Map<String, ImageLayer> getImages() {
return images;
}
public boolean addAdmin(String username) {
return admins.add(username.toLowerCase());
}
public boolean removeAdmin(String username) {
return admins.remove(username.toLowerCase());
}
}

View File

@@ -0,0 +1,66 @@
package com.imgfloat.app.model;
public class ImageEvent {
public enum Type {
CREATED,
UPDATED,
VISIBILITY,
DELETED
}
private Type type;
private String channel;
private ImageLayer payload;
private String imageId;
public static ImageEvent created(String channel, ImageLayer layer) {
ImageEvent event = new ImageEvent();
event.type = Type.CREATED;
event.channel = channel;
event.payload = layer;
event.imageId = layer.getId();
return event;
}
public static ImageEvent updated(String channel, ImageLayer layer) {
ImageEvent event = new ImageEvent();
event.type = Type.UPDATED;
event.channel = channel;
event.payload = layer;
event.imageId = layer.getId();
return event;
}
public static ImageEvent visibility(String channel, ImageLayer layer) {
ImageEvent event = new ImageEvent();
event.type = Type.VISIBILITY;
event.channel = channel;
event.payload = layer;
event.imageId = layer.getId();
return event;
}
public static ImageEvent deleted(String channel, String imageId) {
ImageEvent event = new ImageEvent();
event.type = Type.DELETED;
event.channel = channel;
event.imageId = imageId;
return event;
}
public Type getType() {
return type;
}
public String getChannel() {
return channel;
}
public ImageLayer getPayload() {
return payload;
}
public String getImageId() {
return imageId;
}
}

View File

@@ -0,0 +1,92 @@
package com.imgfloat.app.model;
import java.time.Instant;
import java.util.UUID;
public class ImageLayer {
private final String id;
private String url;
private double x;
private double y;
private double width;
private double height;
private double rotation;
private boolean hidden;
private final Instant createdAt;
public ImageLayer(String url, double width, double height) {
this.id = UUID.randomUUID().toString();
this.url = url;
this.width = width;
this.height = height;
this.x = 0;
this.y = 0;
this.rotation = 0;
this.hidden = true;
this.createdAt = Instant.now();
}
public String getId() {
return id;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public double getX() {
return x;
}
public void setX(double x) {
this.x = x;
}
public double getY() {
return y;
}
public void setY(double y) {
this.y = y;
}
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;
}
public double getRotation() {
return rotation;
}
public void setRotation(double rotation) {
this.rotation = rotation;
}
public boolean isHidden() {
return hidden;
}
public void setHidden(boolean hidden) {
this.hidden = hidden;
}
public Instant getCreatedAt() {
return createdAt;
}
}

View File

@@ -0,0 +1,39 @@
package com.imgfloat.app.model;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public class ImageRequest {
@NotBlank
private String url;
@Min(1)
private double width;
@Min(1)
private double height;
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
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

@@ -0,0 +1,49 @@
package com.imgfloat.app.model;
public class TransformRequest {
private double x;
private double y;
private double width;
private double height;
private double rotation;
public double getX() {
return x;
}
public void setX(double x) {
this.x = x;
}
public double getY() {
return y;
}
public void setY(double y) {
this.y = y;
}
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;
}
public double getRotation() {
return rotation;
}
public void setRotation(double rotation) {
this.rotation = rotation;
}
}

View File

@@ -0,0 +1,13 @@
package com.imgfloat.app.model;
public class VisibilityRequest {
private boolean hidden;
public boolean isHidden() {
return hidden;
}
public void setHidden(boolean hidden) {
this.hidden = hidden;
}
}

View File

@@ -0,0 +1,114 @@
package com.imgfloat.app.service;
import com.imgfloat.app.model.Channel;
import com.imgfloat.app.model.ImageEvent;
import com.imgfloat.app.model.ImageLayer;
import com.imgfloat.app.model.ImageRequest;
import com.imgfloat.app.model.TransformRequest;
import com.imgfloat.app.model.VisibilityRequest;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class ChannelDirectoryService {
private final Map<String, Channel> channels = new ConcurrentHashMap<>();
private final SimpMessagingTemplate messagingTemplate;
public ChannelDirectoryService(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
public Channel getOrCreateChannel(String broadcaster) {
return channels.computeIfAbsent(broadcaster.toLowerCase(), Channel::new);
}
public boolean addAdmin(String broadcaster, String username) {
Channel channel = getOrCreateChannel(broadcaster);
boolean added = channel.addAdmin(username);
if (added) {
messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin added: " + username);
}
return added;
}
public boolean removeAdmin(String broadcaster, String username) {
Channel channel = getOrCreateChannel(broadcaster);
boolean removed = channel.removeAdmin(username);
if (removed) {
messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin removed: " + username);
}
return removed;
}
public Collection<ImageLayer> getImagesForAdmin(String broadcaster) {
return getOrCreateChannel(broadcaster).getImages().values();
}
public Collection<ImageLayer> getVisibleImages(String broadcaster) {
return getOrCreateChannel(broadcaster).getImages().values().stream()
.filter(image -> !image.isHidden())
.toList();
}
public Optional<ImageLayer> createImage(String broadcaster, ImageRequest request) {
Channel channel = getOrCreateChannel(broadcaster);
ImageLayer layer = new ImageLayer(request.getUrl(), request.getWidth(), request.getHeight());
channel.getImages().put(layer.getId(), layer);
messagingTemplate.convertAndSend(topicFor(broadcaster), ImageEvent.created(broadcaster, layer));
return Optional.of(layer);
}
public Optional<ImageLayer> updateTransform(String broadcaster, String imageId, TransformRequest request) {
Channel channel = getOrCreateChannel(broadcaster);
ImageLayer layer = channel.getImages().get(imageId);
if (layer == null) {
return Optional.empty();
}
layer.setX(request.getX());
layer.setY(request.getY());
layer.setWidth(request.getWidth());
layer.setHeight(request.getHeight());
layer.setRotation(request.getRotation());
messagingTemplate.convertAndSend(topicFor(broadcaster), ImageEvent.updated(broadcaster, layer));
return Optional.of(layer);
}
public Optional<ImageLayer> updateVisibility(String broadcaster, String imageId, VisibilityRequest request) {
Channel channel = getOrCreateChannel(broadcaster);
ImageLayer layer = channel.getImages().get(imageId);
if (layer == null) {
return Optional.empty();
}
layer.setHidden(request.isHidden());
messagingTemplate.convertAndSend(topicFor(broadcaster), ImageEvent.visibility(broadcaster, layer));
return Optional.of(layer);
}
public boolean deleteImage(String broadcaster, String imageId) {
Channel channel = getOrCreateChannel(broadcaster);
ImageLayer removed = channel.getImages().remove(imageId);
if (removed != null) {
messagingTemplate.convertAndSend(topicFor(broadcaster), ImageEvent.deleted(broadcaster, imageId));
return true;
}
return false;
}
public boolean isBroadcaster(String broadcaster, String username) {
return broadcaster != null && broadcaster.equalsIgnoreCase(username);
}
public boolean isAdmin(String broadcaster, String username) {
Channel channel = channels.get(broadcaster.toLowerCase());
return channel != null && channel.getAdmins().contains(username.toLowerCase());
}
private String topicFor(String broadcaster) {
return "/topic/channel/" + broadcaster.toLowerCase();
}
}

View File

@@ -0,0 +1,35 @@
server:
port: ${SERVER_PORT:8080}
ssl:
enabled: ${SSL_ENABLED:false}
key-store: ${SSL_KEYSTORE_PATH:classpath:keystore.p12}
key-store-password: ${SSL_KEYSTORE_PASSWORD:changeit}
key-store-type: ${SSL_KEYSTORE_TYPE:PKCS12}
spring:
application:
name: imgfloat
thymeleaf:
cache: false
security:
oauth2:
client:
registration:
twitch:
client-id: ${TWITCH_CLIENT_ID:changeme}
client-secret: ${TWITCH_CLIENT_SECRET:changeme}
redirect-uri: "{baseUrl}/login/oauth2/code/twitch"
authorization-grant-type: authorization_code
scope: ["user:read:email"]
provider:
twitch:
authorization-uri: https://id.twitch.tv/oauth2/authorize
token-uri: https://id.twitch.tv/oauth2/token
user-info-uri: https://api.twitch.tv/helix/users
user-name-attribute: preferred_username
management:
endpoints:
web:
exposure:
include: health,info

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

View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Imgfloat Admin</title>
<link rel="stylesheet" href="/css/styles.css" />
<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/stompjs@2.3.3/lib/stomp.min.js"></script>
</head>
<body>
<div class="admin-layout">
<header>
<h2>Channel <span th:text="${broadcaster}"></span> overlay controls</h2>
<div class="actions">
<form th:action="@{/logout}" method="post">
<button class="secondary" type="submit">Logout</button>
</form>
</div>
</header>
<section class="controls">
<div>
<h3>Admins</h3>
<input id="new-admin" placeholder="Twitch username" />
<button onclick="addAdmin()">Add admin</button>
<ul id="admin-list"></ul>
</div>
<div>
<h3>Images</h3>
<input id="image-url" placeholder="Image URL" />
<input id="image-width" placeholder="Width" type="number" value="600" />
<input id="image-height" placeholder="Height" type="number" value="400" />
<button onclick="uploadImage()">Upload</button>
<ul id="image-list"></ul>
</div>
</section>
<section class="overlay">
<iframe th:src="${'https://player.twitch.tv/?channel=' + broadcaster + '&parent=localhost'}" allowfullscreen></iframe>
<canvas id="admin-canvas"></canvas>
</section>
</div>
<script th:inline="javascript">
const broadcaster = /*[[${broadcaster}]]*/ '';
const username = /*[[${username}]]*/ '';
</script>
<script src="/js/admin.js"></script>
</body>
</html>

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Imgfloat Broadcast</title>
<link rel="stylesheet" href="/css/styles.css" />
<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>
</head>
<body class="broadcast-body">
<canvas id="broadcast-canvas"></canvas>
<script th:inline="javascript">
const broadcaster = /*[[${broadcaster}]]*/ '';
</script>
<script src="/js/broadcast.js"></script>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Imgfloat Dashboard</title>
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<div class="container">
<h1>Welcome, <span th:text="${username}">user</span></h1>
<p>Manage your overlay or invite channel admins.</p>
<div class="actions">
<a class="button" th:href="@{'/view/' + ${channel} + '/admin'}">Admin console</a>
<a class="button" th:href="@{'/view/' + ${channel} + '/broadcast'}">Broadcast overlay</a>
<form th:action="@{/logout}" method="post">
<button class="secondary" type="submit">Logout</button>
</form>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Imgfloat - Twitch overlay</title>
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<div class="container">
<h1>Imgfloat</h1>
<p>Authenticate with Twitch to manage your channel overlays and invite channel admins.</p>
<a class="button" href="/oauth2/authorization/twitch">Login with Twitch</a>
</div>
</body>
</html>