diff --git a/eaglercraftbungee/src/main/java/net/md_5/bungee/api/ServerIcon.java b/eaglercraftbungee/src/main/java/net/md_5/bungee/api/ServerIcon.java index cd99421..ea4d8b1 100644 --- a/eaglercraftbungee/src/main/java/net/md_5/bungee/api/ServerIcon.java +++ b/eaglercraftbungee/src/main/java/net/md_5/bungee/api/ServerIcon.java @@ -19,7 +19,7 @@ public class ServerIcon { Graphics2D g = (Graphics2D) icon.getGraphics(); g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, (awtIcon.getWidth() < 64 || awtIcon.getHeight() < 64) ? RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR : RenderingHints.VALUE_INTERPOLATION_BICUBIC); - g.setBackground(Color.BLACK); + g.setBackground(new Color(0, true)); g.clearRect(0, 0, 64, 64); int ow = awtIcon.getWidth(); int oh = awtIcon.getHeight(); diff --git a/eaglercraftbungee/src/main/java/net/md_5/bungee/eaglercraft/ExpiringSet.java b/eaglercraftbungee/src/main/java/net/md_5/bungee/eaglercraft/ExpiringSet.java new file mode 100644 index 0000000..6366363 --- /dev/null +++ b/eaglercraftbungee/src/main/java/net/md_5/bungee/eaglercraft/ExpiringSet.java @@ -0,0 +1,59 @@ +package net.md_5.bungee.eaglercraft; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Map; + +// note that there's a few things not implemented, but I don't care. + +public class ExpiringSet extends HashSet { + private final long expiration; + + private final Map timestamps = new HashMap<>(); + + public ExpiringSet(long expiration) { + this.expiration = expiration; + } + + public void checkForExpirations() { + Iterator iterator = this.timestamps.keySet().iterator(); + long now = System.currentTimeMillis(); + while (iterator.hasNext()) { + T element = iterator.next(); + if (super.contains(element)) { + if (this.timestamps.get(element) + this.expiration < now) { + iterator.remove(); + super.remove(element); + } + } else { + iterator.remove(); + super.remove(element); + } + } + } + + public boolean add(T o) { + checkForExpirations(); + boolean success = super.add(o); + if (success) timestamps.put(o, System.currentTimeMillis()); + return success; + } + + public boolean remove(Object o) { + checkForExpirations(); + boolean success = super.remove(o); + if (success) timestamps.remove(o); + return success; + } + + public void clear() { + this.timestamps.clear(); + super.clear(); + } + + public boolean contains(Object o) { + checkForExpirations(); + return super.contains(o); + } +} diff --git a/eaglercraftbungee/src/main/java/net/md_5/bungee/eaglercraft/PluginEaglerVoice.java b/eaglercraftbungee/src/main/java/net/md_5/bungee/eaglercraft/PluginEaglerVoice.java new file mode 100644 index 0000000..eddfe41 --- /dev/null +++ b/eaglercraftbungee/src/main/java/net/md_5/bungee/eaglercraft/PluginEaglerVoice.java @@ -0,0 +1,157 @@ +package net.md_5.bungee.eaglercraft; + +import net.md_5.bungee.UserConnection; +import net.md_5.bungee.api.event.PlayerDisconnectEvent; +import net.md_5.bungee.api.event.PluginMessageEvent; +import net.md_5.bungee.api.event.PostLoginEvent; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.api.plugin.PluginDescription; +import net.md_5.bungee.event.EventHandler; +import org.json.JSONObject; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.Collections; + +public class PluginEaglerVoice extends Plugin implements Listener { + + private final Map voicePlayers = new HashMap<>(); + private final Map> voiceRequests = new HashMap<>(); + private final Set voicePairs = new HashSet<>(); + + public PluginEaglerVoice() { + super(new PluginDescription("EaglerVoice", PluginEaglerVoice.class.getName(), "1.0.0", "ayunami2000", Collections.emptySet(), null)); + } + + public void onLoad() { + + } + + public void onEnable() { + getProxy().getPluginManager().registerListener(this, this); + } + + public void onDisable() { + + } + + @EventHandler + public void onPluginMessage(PluginMessageEvent event) { + if(event.getSender() instanceof UserConnection && event.getData().length > 0) { + UserConnection connection = (UserConnection) event.getSender(); + String user = connection.getName(); + byte[] msg = event.getData(); + try { + if("EAG|VoiceJoin".equals(event.getTag())) { + if (voicePlayers.containsKey(user)) return; // user is already using voice chat + // send out packet for player joined voice + // notice: everyone on the server can see this packet!! however, it doesn't do anything but let clients know that the player has turned on voice chat + for (UserConnection conn : voicePlayers.values()) conn.sendData("EAG|VoiceJoin", user.getBytes(StandardCharsets.UTF_8)); + voicePlayers.put(user, connection); + }else if("EAG|VoiceLeave".equals(event.getTag())) { + if (!voicePlayers.containsKey(user)) return; // user is not using voice chat + removeUser(user); + }else if("EAG|VoiceReq".equals(event.getTag())) { + if (!voicePlayers.containsKey(user)) return; // user is not using voice chat + String targetUser = new String(msg, StandardCharsets.UTF_8); + if (user.equals(targetUser)) return; // prevent duplicates + if (checkVoicePair(user, targetUser)) return; // already paired + if (!voicePlayers.containsKey(targetUser)) return; // target user is not using voice chat + if (!voiceRequests.containsKey(user)) voiceRequests.put(user, new ExpiringSet<>(2000)); + if (voiceRequests.get(user).contains(targetUser)) return; + voiceRequests.get(user).add(targetUser); + + // check if other has requested earlier + if (voiceRequests.containsKey(targetUser) && voiceRequests.get(targetUser).contains(user)) { + if (voiceRequests.containsKey(targetUser)) { + voiceRequests.get(targetUser).remove(user); + if (voiceRequests.get(targetUser).isEmpty()) voiceRequests.remove(targetUser); + } + if (voiceRequests.containsKey(user)) { + voiceRequests.get(user).remove(targetUser); + if (voiceRequests.get(user).isEmpty()) voiceRequests.remove(user); + } + // send each other add data + voicePairs.add(new String[] { user, targetUser }); + JSONObject json = new JSONObject(); + json.put("username", user); + json.put("offer", false); + voicePlayers.get(targetUser).sendData("EAG|VoiceAdd", json.toString().getBytes(StandardCharsets.UTF_8)); + json.put("username", targetUser); + json.put("offer", true); + connection.sendData("EAG|VoiceAdd", json.toString().getBytes(StandardCharsets.UTF_8)); + } + } else if("EAG|VoiceRemove".equals(event.getTag())) { + if (!voicePlayers.containsKey(user)) return; // user is not using voice chat + String targetUser = new String(msg, StandardCharsets.UTF_8); + if (voicePairs.removeIf(pair -> (pair[0].equals(user) && pair[1].equals(targetUser)) || (pair[0].equals(targetUser) && pair[1].equals(user)))) voicePlayers.get(targetUser).sendData("EAG|VoiceRemove", user.getBytes(StandardCharsets.UTF_8)); + }else if("EAG|VoiceIce".equals(event.getTag())) { + if (!voicePlayers.containsKey(user)) return; // user is not using voice chat + JSONObject json = new JSONObject(new String(msg)); + if (json.has("username") && json.get("username") instanceof String) { + String targetUser = json.getString("username"); + if (checkVoicePair(user, targetUser)) { + if (json.has("ice_candidate")) { + // todo: limit ice_candidate data length or sanitize it fully + json.keySet().removeIf(s -> !s.equals("ice_candidate")); + json.put("username", user); + voicePlayers.get(targetUser).sendData("EAG|VoiceIce", json.toString().getBytes(StandardCharsets.UTF_8)); + } + } + } + }else if("EAG|VoiceDesc".equals(event.getTag())) { + if (!voicePlayers.containsKey(user)) return; // user is not using voice chat + JSONObject json = new JSONObject(new String(msg)); + if (json.has("username") && json.get("username") instanceof String) { + String targetUser = json.getString("username"); + if (checkVoicePair(user, targetUser)) { + if (json.has("session_description")) { + // todo: limit session_description data length or sanitize it fully + json.keySet().removeIf(s -> !s.equals("session_description")); + json.put("username", user); + voicePlayers.get(targetUser).sendData("EAG|VoiceDesc", json.toString().getBytes(StandardCharsets.UTF_8)); + } + } + } + } + }catch(Throwable t) { + // hacker + t.printStackTrace(); // todo: remove in production + removeUser(user); + } + } + } + + @EventHandler + public void onPostLogin(PostLoginEvent event) { + event.getPlayer().sendData("EAG|Voice", new byte[] { }); + } + + @EventHandler + public void onPlayerDisconnect(PlayerDisconnectEvent event) { + String nm = event.getPlayer().getName(); + removeUser(nm); + } + + public void removeUser(String name) { + voicePlayers.remove(name); + for (String[] voicePair : voicePairs) { + String target = null; + if (voicePair[0].equals(name)) { + target = voicePair[1]; + } else if(voicePair[1].equals(name)) { + target = voicePair[0]; + } + if (target != null && voicePlayers.containsKey(target)) voicePlayers.get(target).sendData("EAG|VoiceRemove", name.getBytes(StandardCharsets.UTF_8)); + } + voicePairs.removeIf(pair -> pair[0].equals(name) || pair[1].equals(name)); + } + + private boolean checkVoicePair(String user1, String user2) { + return voicePairs.stream().anyMatch(pair -> (pair[0].equals(user1) && pair[1].equals(user2)) || (pair[0].equals(user2) && pair[1].equals(user1))); + } +} diff --git a/javascript/eagswebrtc.js b/javascript/eagswebrtc.js new file mode 100644 index 0000000..ded458b --- /dev/null +++ b/javascript/eagswebrtc.js @@ -0,0 +1,227 @@ +"use strict"; + +/* + +This is the backend for voice channels in eaglercraft, it links with TeaVM EaglerAdapter at runtime + +Copyright 2022 Calder Young. All rights reserved. + +Based on code written by ayunami2000 + +*/ + +window.initializeVoiceClient = (() => { + + const READYSTATE_NONE = 0; + const READYSTATE_ABORTED = -1; + const READYSTATE_DEVICE_INITIALIZED = 1; + + const TASKSTATE_NONE = -1; + const TASKSTATE_LOADING = 0; + const TASKSTATE_COMPLETE = 1; + const TASKSTATE_FAILED = 2; + + class EaglercraftVoicePeer { + + constructor(client, peerId, peerConnection) { + this.client = client; + this.peerId = peerId; + this.peerConnection = peerConnection; + + const self = this; + this.peerConnection.addEventListener("icecandidate", (evt) => { + if(evt.candidate) { + self.client.iceCandidateHandler(self.peerId, evt.candidate.sdpMLineIndex, evt.candidate.candidate.toJSON().stringify()); + } + }); + + this.peerConnection.addEventListener("track", (evt) => { + self.client.peerTrackHandler(self.peerId, evt.streams[0]); + }); + + this.peerConnection.addStream(this.client.localMediaStream); + this.peerConnection.createOffer((desc) => { + const selfDesc = desc; + self.peerConnection.setLocalDescription(selfDesc, () => { + self.client.descriptionHandler(self.peerId, selfDesc.toJSON().stringify()); + }, (err) => { + console.error("Failed to set local description for \"" + self.peerId + "\"! " + err); + self.client.signalDisconnect(self.peerId); + }); + }, (err) => { + console.error("Failed to set create offer for \"" + self.peerId + "\"! " + err); + self.client.signalDisconnect(self.peerId); + }); + + this.peerConnection.addEventListener("connectionstatechange", (evt) => { + if(evt.connectionState === 'disconnected') { + self.client.signalDisconnect(self.peerId); + } + }); + + } + + disconnect() { + this.peerConnection.close(); + } + + setRemoteDescription(descJSON) { + const self = this; + const remoteDesc = JSON.parse(descJSON); + this.peerConnection.setRemoteDescription(remoteDesc, () => { + if(remoteDesc.type == 'offer') { + self.peerConnection.createAnswer((desc) => { + const selfDesc = desc; + self.peerConnection.setLocalDescription(selfDesc, () => { + self.client.descriptionHandler(self.peerId, selfDesc.toJSON().stringify()); + }, (err) => { + console.error("Failed to set local description for \"" + self.peerId + "\"! " + err); + self.signalDisconnect(peerId); + }); + }, (err) => { + console.error("Failed to create answer for \"" + self.peerId + "\"! " + err); + self.signalDisconnect(peerId); + }); + } + }, (err) => { + console.error("Failed to set remote description for \"" + self.peerId + "\"! " + err); + self.signalDisconnect(peerId); + }); + } + + addICECandidate(candidate) { + this.peerConnection.addICECandidate(new RTCIceCandidate(JSON.parse(candidate))); + } + + } + + class EaglercraftVoiceClient { + + constructor() { + this.ICEServers = []; + this.hasInit = false; + this.peerList = new Map(); + this.readyState = READYSTATE_NONE; + this.taskState = TASKSTATE_NONE; + this.iceCandidateHandler = null; + this.descriptionHandler = null; + this.peerTrackHandler = null; + this.peerDisconnectHandler = null; + this.microphoneVolumeAudioContext = new AudioContext(); + } + + voiceClientSupported() { + return typeof window.RTCPeerConnection !== "undefined" && typeof navigator.mediaDevices !== "undefined" && + typeof navigator.mediaDevices.getUserMedia !== "undefined"; + } + + setICEServers(urls) { + this.ICEServers.length = 0; + for(var i = 0; i < urls.length; ++i) { + this.ICEServers.push({ urls: urls[i] }); + } + } + + setICECandidateHandler(cb) { + this.iceCandidateHandler = cb; + } + + setDescriptionHandler(cb) { + this.descriptionHandler = cb; + } + + setPeerTrackHandler(cb) { + this.peerTrackHandler = cb; + } + + setPeerDisconnectHandler(cb) { + this.peerDisconnectHandler = cb; + } + + activateVoice(tk) { + this.localRawMediaStream.getAudioTracks()[0].enabled = tk; + } + + intitializeDevices() { + if(!this.hasInit) { + this.taskState = TASKSTATE_LOADING; + const self = this; + navigator.mediaDevices.getUserMedia({ audio: true, video: false }).then((stream) => { + self.localRawMediaStream = stream; + self.localRawMediaStream.getAudioTracks()[0].enabled = false; + self.localMediaStream = self.microphoneVolumeAudioContext.createMediaStreamDestination(); + self.localMediaStreamGain = self.microphoneVolumeAudioContext.createGain(); + var localStreamIn = self.microphoneVolumeAudioContext.createMediaStreamSource(stream); + localStreamIn.connect(self.localMediaStreamGain); + self.localMediaStreamGain.connect(self.localMediaStream); + self.localMediaStreamGain.gain = 1.0; + self.readyState = READYSTATE_DEVICE_INITIALIZED; + self.taskState = TASKSTATE_COMPLETE; + this.hasInit = true; + }).catch(() => { + self.readyState = READYSTATE_ABORTED; + self.taskState = TASKSTATE_FAILED; + }); + }else { + self.readyState = READYSTATE_DEVICE_INITIALIZED; + self.taskState = TASKSTATE_COMPLETE; + } + } + + setMicVolume(val) { + if(val > 0.5) val = 0.5 + (val - 0.5) * 2.0; + if(val > 1.5) val = 1.5; + if(val < 0.0) val = 0.0; + self.localMediaStreamGain.gain = val * 2.0; + } + + getTaskState() { + return this.taskState; + } + + getReadyState() { + return this.readyState; + } + + signalConnect(peerId) { + const peerConnection = new RTCPeerConnection({ iceServers: this.ICEServers, optional: [ { DtlsSrtpKeyAgreement: true } ] }); + const peerInstance = new EaglercraftVoicePeer(this, peerId, peerConnection); + this.peerList.set(peerId, peerInstance); + } + + signalDescription(peerId, descJSON) { + var thePeer = this.peerList.get(peerId); + if((typeof thePeer !== "undefined") && thePeer !== null) { + thePeer.setRemoteDescription(descJSON); + } + } + + signalDisconnect(peerId) { + var thePeer = this.peerList.get(peerId); + if((typeof thePeer !== "undefined") && thePeer !== null) { + this.peerList.delete(thePeer); + try { + thePeer.disconnect(); + }catch(e) {} + this.peerDisconnectHandler(peerId); + } + } + + signalICECandidate(peerId, candidate) { + var thePeer = this.peerList.get(peerId); + if((typeof thePeer !== "undefined") && thePeer !== null) { + thePeer.addICECandidate(candidate); + } + } + + } + + window.constructVoiceClient = () => new EaglercraftVoiceClient(); +}); + +window.startVoiceClient = () => { + if(typeof window.constructVoiceClient !== "function") { + window.initializeVoiceClient(); + } + return window.constructVoiceClient(); +}; \ No newline at end of file diff --git a/javascript/index.html b/javascript/index.html index af8ccd8..e6ffa6a 100644 --- a/javascript/index.html +++ b/javascript/index.html @@ -3,6 +3,7 @@ eagler +