initial commit

This commit is contained in:
MrLetsplay 2024-01-10 20:30:41 +01:00
commit 3958100391
Signed by: mr
SSH Key Fingerprint: SHA256:92jBH80vpXyaZHjaIl47pjRq+Yt7XGTArqQg1V7hSqg
17 changed files with 728 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/.settings
/bin
/.classpath
/TEST
/target/

23
.project Normal file
View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>DesktopStreamDeck</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.m2e.core.maven2Builder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.m2e.core.maven2Nature</nature>
<nature>org.eclipse.jdt.core.javanature</nature>
</natures>
</projectDescription>

63
pom.xml Normal file
View File

@ -0,0 +1,63 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>me.mrletsplay</groupId>
<artifactId>StreamDeckDesktop</artifactId>
<version>1.0-SNAPSHOT</version>
<build>
<sourceDirectory>src/main/java</sourceDirectory>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<release>17</release>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<archive>
<manifest>
<mainClass>me.mrletsplay.streamdeck.StreamDeck</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>2.4.3</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>Graphite-Official</id>
<url>https://maven.graphite-official.com/releases</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>me.mrletsplay</groupId>
<artifactId>SimpleHTTPServer</artifactId>
<version>2.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>me.mrletsplay</groupId>
<artifactId>MrCore</artifactId>
<version>4.4</version>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,86 @@
package me.mrletsplay.streamdeck;
import java.io.File;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.Clip;
import javax.sound.sampled.FloatControl;
import javax.sound.sampled.LineEvent;
import javax.sound.sampled.LineUnavailableException;
import javax.sound.sampled.UnsupportedAudioFileException;
import me.mrletsplay.streamdeck.exception.InitializationException;
import me.mrletsplay.streamdeck.exception.SoundboardException;
public class Soundboard {
private static ExecutorService executor;
public static void initialize() throws InitializationException {
executor = Executors.newCachedThreadPool();
}
public static void finish() {
executor.shutdown();
}
public static void play(String path) throws SoundboardException {
AudioInputStream s;
try {
s = AudioSystem.getAudioInputStream(new File("TEST/test.wav"));
} catch (UnsupportedAudioFileException | IOException e) {
throw new SoundboardException(e);
}
executor.submit(() -> {
try(Clip clip = AudioSystem.getClip()) {
Object wait = new Object();
AtomicBoolean playing = new AtomicBoolean(true);
clip.addLineListener(event -> {
if(event.getType() == LineEvent.Type.STOP || event.getType() == LineEvent.Type.CLOSE) {
playing.set(false);
wait.notify();
}
});
clip.open(s);
clip.setFramePosition(0);
// FloatControl gain = (FloatControl) clip.getControl(FloatControl.Type.MASTER_GAIN);
// gain.setValue(20f * (float) Math.log10(0.2));
clip.start();
while(playing.get()) { wait.wait(); };
} catch (LineUnavailableException | IOException | InterruptedException e) {
e.printStackTrace();
}
});
}
public static void test() throws IOException, UnsupportedAudioFileException, LineUnavailableException {
AudioInputStream s = AudioSystem.getAudioInputStream(new File("TEST/test.wav"));
try(Clip c = AudioSystem.getClip()) {
AtomicBoolean e = new AtomicBoolean(true);
c.addLineListener(event -> {
if(event.getType() == LineEvent.Type.STOP) e.set(false);
});
c.open(s);
c.setFramePosition(0);
FloatControl gain = (FloatControl) c.getControl(FloatControl.Type.MASTER_GAIN);
gain.setValue(20f * (float) Math.log10(0.2));
c.start();
c.loop(10);
while(e.get());
}
}
}

View File

@ -0,0 +1,37 @@
package me.mrletsplay.streamdeck;
import me.mrletsplay.simplehttpserver.http.server.HttpServer;
import me.mrletsplay.streamdeck.action.ActionSerializer;
import me.mrletsplay.streamdeck.api.StreamDeckAPI;
import me.mrletsplay.streamdeck.deck.Deck;
import me.mrletsplay.streamdeck.exception.InitializationException;
public class StreamDeck {
private static Deck deck;
public static void main(String[] args) throws InitializationException {
deck = new Deck(20);
ActionSerializer.initialize();
Soundboard.initialize();
HttpServer server = new HttpServer(HttpServer.newConfigurationBuilder()
.hostBindAll()
.port(5734)
.create());
new StreamDeckAPI().register(server.getDocumentProvider());
server.start();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
Soundboard.finish();
}));
}
public static Deck getDeck() {
return deck;
}
}

View File

@ -0,0 +1,14 @@
package me.mrletsplay.streamdeck.action;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Action {
String value();
}

View File

@ -0,0 +1,14 @@
package me.mrletsplay.streamdeck.action;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ActionParameter {
String name() default "";
}

View File

@ -0,0 +1,25 @@
package me.mrletsplay.streamdeck.action;
public class ActionSerializationException extends Exception {
private static final long serialVersionUID = 5454427202803805714L;
public ActionSerializationException() {
super();
}
public ActionSerializationException(String message, Throwable cause) {
super(message, cause);
}
public ActionSerializationException(String message) {
super(message);
}
public ActionSerializationException(Throwable cause) {
super(cause);
}
}

View File

@ -0,0 +1,112 @@
package me.mrletsplay.streamdeck.action;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import me.mrletsplay.mrcore.json.JSONObject;
import me.mrletsplay.mrcore.json.JSONType;
import me.mrletsplay.simplehttpserver.http.validation.JsonObjectValidator;
import me.mrletsplay.streamdeck.action.impl.PlaySoundAction;
import me.mrletsplay.streamdeck.action.impl.PressKeyAction;
import me.mrletsplay.streamdeck.exception.InitializationException;
public class ActionSerializer {
public static final JsonObjectValidator
ACTION_VALIDATOR = new JsonObjectValidator()
.require("type", JSONType.STRING)
.require("data", JSONType.OBJECT);
private static final List<Class<? extends DeckAction>> ACTIONS = List.of(
PlaySoundAction.class,
PressKeyAction.class
);
private static Map<String, Class<? extends DeckAction>> nameToAction;
public static void initialize() throws InitializationException {
Map<String, Class<? extends DeckAction>> nta = new LinkedHashMap<>();
for(var actionClass : ACTIONS) {
Action ac = actionClass.getAnnotation(Action.class);
if(ac == null) throw new InitializationException("Class " + actionClass.getName() + " is missing the @Action annotation");
nta.put(ac.value(), actionClass);
}
nameToAction = Collections.unmodifiableMap(nta);
}
public static JSONObject serializeAction(DeckAction action) throws ActionSerializationException {
if(nameToAction == null) throw new ActionSerializationException("ActionSerializer is not initialized");
Class<? extends DeckAction> actionClass = action.getClass();
if(!ACTIONS.contains(actionClass)) throw new ActionSerializationException("Class " + actionClass.getName() + " is not a registered action class");
Action ac = actionClass.getAnnotation(Action.class);
JSONObject object = new JSONObject();
object.put("type", ac.value());
JSONObject data = new JSONObject();
for(Field f : action.getClass().getDeclaredFields()) {
ActionParameter parameter = f.getAnnotation(ActionParameter.class);
if(parameter == null) continue;
String name = !parameter.name().isEmpty() ? parameter.name() : f.getName();
try {
f.setAccessible(true);
data.put(name, f.get(action));
} catch (IllegalArgumentException | IllegalAccessException e) {
throw new ActionSerializationException(e);
}
}
action.serialize(data);
return object;
}
public static DeckAction deserializeAction(JSONObject action) throws ActionSerializationException {
if(nameToAction == null) throw new ActionSerializationException("ActionSerializer is not initialized");
String actionType = action.optString("type").orElseThrow(() -> new ActionSerializationException("Missing action type"));
Class<? extends DeckAction> actionClass = nameToAction.get(actionType);
if(actionClass == null) throw new ActionSerializationException("Action doesn't exist");
JSONObject data = action.optJSONObject("data").orElseThrow(() -> new ActionSerializationException("Missing data"));
try {
Constructor<? extends DeckAction> constr = actionClass.getDeclaredConstructor();
constr.setAccessible(true);
DeckAction a = constr.newInstance();
for(Field f : action.getClass().getDeclaredFields()) {
ActionParameter parameter = f.getAnnotation(ActionParameter.class);
if(parameter == null) continue;
String name = !parameter.name().isEmpty() ? parameter.name() : f.getName();
try {
f.setAccessible(true);
// TODO: allow JSONSerializable
f.set(a, JSONType.castJSONValueTo(data.get(name), f.getType()));
} catch (IllegalArgumentException | IllegalAccessException e) {
throw new ActionSerializationException(e);
}
}
a.deserialize(data);
} catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException | SecurityException e) {
throw new ActionSerializationException(e);
}
return null;
}
}

View File

@ -0,0 +1,40 @@
package me.mrletsplay.streamdeck.action;
import java.util.Collections;
import java.util.List;
import me.mrletsplay.mrcore.json.JSONObject;
public interface DeckAction {
/**
* Useful if parameters can't be directly mapped to JSON types.<br>
* By default, this does nothing but may be implemented if needed
* @param data The action data
* @throws ActionSerializationException If the action can't be deserialized
*/
public default void deserialize(JSONObject data) throws ActionSerializationException {}
/**
* Must be implemented to correspond to {@link #deserialize(JSONObject)}.<br>
* By default, this does nothing but may be implemented if needed
* @param data The action data
* @throws ActionSerializationException If the action can't be serialized
*/
public default void serialize(JSONObject data) throws ActionSerializationException {}
/**
* Validates the action and returns a list of errors, or an empty list if there are no errors.<br>
* By default, this does nothing but may be implemented to add further validation
* @return A list of errors, or an empty list if there are no errors
*/
public default List<String> validate() {
return Collections.emptyList();
}
/**
* Runs the action
*/
public void run();
}

View File

@ -0,0 +1,18 @@
package me.mrletsplay.streamdeck.action.impl;
import me.mrletsplay.streamdeck.action.Action;
import me.mrletsplay.streamdeck.action.ActionParameter;
import me.mrletsplay.streamdeck.action.DeckAction;
@Action("play_sound")
public final class PlaySoundAction implements DeckAction {
@ActionParameter
private String soundPath;
@Override
public void run() {
}
}

View File

@ -0,0 +1,33 @@
package me.mrletsplay.streamdeck.action.impl;
import java.awt.AWTException;
import java.awt.Robot;
import me.mrletsplay.streamdeck.action.Action;
import me.mrletsplay.streamdeck.action.ActionParameter;
import me.mrletsplay.streamdeck.action.DeckAction;
@Action("press_key")
public final class PressKeyAction implements DeckAction {
private static Robot robot;
static {
try {
robot = new Robot();
} catch (AWTException e) {
throw new RuntimeException(e);
}
}
@ActionParameter
private int key;
@Override
public void run() {
robot.keyPress(key);
robot.delay(100);
robot.keyRelease(key);
}
}

View File

@ -0,0 +1,149 @@
package me.mrletsplay.streamdeck.api;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;
import javax.imageio.ImageIO;
import me.mrletsplay.mrcore.json.JSONArray;
import me.mrletsplay.mrcore.json.JSONObject;
import me.mrletsplay.mrcore.json.JSONType;
import me.mrletsplay.simplehttpserver.http.HttpRequestMethod;
import me.mrletsplay.simplehttpserver.http.HttpStatusCodes;
import me.mrletsplay.simplehttpserver.http.endpoint.Endpoint;
import me.mrletsplay.simplehttpserver.http.endpoint.EndpointCollection;
import me.mrletsplay.simplehttpserver.http.header.DefaultClientContentTypes;
import me.mrletsplay.simplehttpserver.http.request.HttpRequestContext;
import me.mrletsplay.simplehttpserver.http.response.JsonResponse;
import me.mrletsplay.simplehttpserver.http.validation.JsonArrayValidator;
import me.mrletsplay.simplehttpserver.http.validation.JsonObjectValidator;
import me.mrletsplay.simplehttpserver.http.validation.result.ValidationResult;
import me.mrletsplay.streamdeck.StreamDeck;
import me.mrletsplay.streamdeck.action.ActionSerializationException;
import me.mrletsplay.streamdeck.action.ActionSerializer;
import me.mrletsplay.streamdeck.action.DeckAction;
import me.mrletsplay.streamdeck.deck.DeckButton;
public class StreamDeckAPI implements EndpointCollection {
private static final JsonObjectValidator
BUTTON_VALIDATOR = new JsonObjectValidator()
.require("id", JSONType.INTEGER)
.optional("icon", JSONType.STRING)
.optionalObject("action", ActionSerializer.ACTION_VALIDATOR),
STATE_VALIDATOR = new JsonObjectValidator()
.requireArray("buttons", new JsonArrayValidator()
.requireElementObjects(BUTTON_VALIDATOR));
private static JsonResponse error(String errorMessage) {
JSONObject error = new JSONObject();
error.put("error", errorMessage);
return new JsonResponse(error);
}
@Endpoint(method = HttpRequestMethod.GET, path = "/actions")
public void getActions() {
// TODO
}
@Endpoint(method = HttpRequestMethod.GET, path = "/state")
public void getState() {
HttpRequestContext ctx = HttpRequestContext.getCurrentContext();
JSONObject state = new JSONObject();
JSONArray buttons = new JSONArray();
for(DeckButton button : StreamDeck.getDeck().getButtons()) {
JSONObject buttonObj = new JSONObject();
buttonObj.put("id", button.getId());
if(button.getIcon() == null) {
buttonObj.put("icon", null);
}else {
try {
ByteArrayOutputStream bOut = new ByteArrayOutputStream();
ImageIO.write(button.getIcon(), "PNG", bOut);
}catch(IOException e) {
ctx.respond(HttpStatusCodes.INTERNAL_SERVER_ERROR_500, error("Failed to serialize icon: " + e.getMessage()));
return;
}
}
if(button.getAction() == null) {
buttonObj.put("action", null);
}else {
try {
buttonObj.put("action", ActionSerializer.serializeAction(button.getAction()));
}catch(ActionSerializationException e) {
ctx.respond(HttpStatusCodes.INTERNAL_SERVER_ERROR_500, error("Failed to serialize action " + e.getMessage()));
return;
}
}
buttons.add(buttonObj);
}
state.put("buttons", buttons);
ctx.respond(HttpStatusCodes.OK_200, new JsonResponse(buttons));
}
@Endpoint(method = HttpRequestMethod.PATCH, path = "/state")
public void updateState() {
HttpRequestContext ctx = HttpRequestContext.getCurrentContext();
JSONObject content;
if((content = ctx.expectContent(DefaultClientContentTypes.JSON_OBJECT)) == null) {
ctx.respond(HttpStatusCodes.BAD_REQUEST_400, error("Invalid content"));
return;
}
ValidationResult result = STATE_VALIDATOR.validate(content);
if(!result.isOk()) {
ctx.respond(HttpStatusCodes.BAD_REQUEST_400, result.asJsonResponse());
return;
}
for(Object o : content.getJSONArray("buttons")) {
JSONObject buttonObj = (JSONObject) o;
int id = buttonObj.getInt("id");
DeckButton button = StreamDeck.getDeck().getButton(id);
if(buttonObj.has("icon")) {
String icon = buttonObj.getString("icon");
if(icon == null) {
button.setIcon(null);
}else {
try {
button.setIcon(ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(icon))));
} catch (IOException | IllegalArgumentException e) {
ctx.respond(HttpStatusCodes.BAD_REQUEST_400, error("Invalid icon"));
}
}
}
if(buttonObj.has("action")) {
JSONObject action = buttonObj.getJSONObject("action");
if(action == null) {
button.setAction(null);
}else {
DeckAction newAction;
try {
newAction = ActionSerializer.deserializeAction(action);
} catch (ActionSerializationException e) {
ctx.respond(HttpStatusCodes.BAD_REQUEST_400, error("Invalid action: " + e.getMessage()));
return;
}
button.setAction(newAction);
}
}
}
}
@Override
public String getBasePath() {
return "/api";
}
}

View File

@ -0,0 +1,26 @@
package me.mrletsplay.streamdeck.deck;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class Deck {
private List<DeckButton> buttons;
public Deck(int buttonCount) {
List<DeckButton> buttons = new ArrayList<>(buttonCount);
for(int i = 0; i < buttonCount; i++) buttons.add(new DeckButton(i));
this.buttons = Collections.unmodifiableList(buttons);
}
public List<DeckButton> getButtons() {
return buttons;
}
public DeckButton getButton(int id) {
if(id < 0 || id >= buttons.size()) return null;
return buttons.get(id);
}
}

View File

@ -0,0 +1,37 @@
package me.mrletsplay.streamdeck.deck;
import java.awt.image.BufferedImage;
import me.mrletsplay.streamdeck.action.DeckAction;
public class DeckButton {
private int id;
private BufferedImage icon;
private DeckAction action;
public DeckButton(int id) {
this.id = id;
}
public int getId() {
return id;
}
public void setIcon(BufferedImage icon) {
this.icon = icon;
}
public BufferedImage getIcon() {
return icon;
}
public void setAction(DeckAction action) {
this.action = action;
}
public DeckAction getAction() {
return action;
}
}

View File

@ -0,0 +1,23 @@
package me.mrletsplay.streamdeck.exception;
public class InitializationException extends Exception {
private static final long serialVersionUID = 8812199845001173928L;
public InitializationException() {
super();
}
public InitializationException(String message, Throwable cause) {
super(message, cause);
}
public InitializationException(String message) {
super(message);
}
public InitializationException(Throwable cause) {
super(cause);
}
}

View File

@ -0,0 +1,23 @@
package me.mrletsplay.streamdeck.exception;
public class SoundboardException extends Exception {
private static final long serialVersionUID = 8790973950212930162L;
public SoundboardException() {
super();
}
public SoundboardException(String message, Throwable cause) {
super(message, cause);
}
public SoundboardException(String message) {
super(message);
}
public SoundboardException(Throwable cause) {
super(cause);
}
}