Anzeigen: Beitritt/Verlassen als Text und Signalton, Discord-Status, TS-Thema, Sprecher in der Actionbar (abschaltbar)
This commit is contained in:
1 parent
3c0e3ed17a
commit
b335933678
19 files changed
+583
-37
No files matched your search
@@ -0,0 +1,155 @@
|
|||||||
|
package eu.mhsl.voicebridge.bridge;
|
||||||
|
|
||||||
|
import eu.mhsl.voicebridge.bridge.Room.Side;
|
||||||
|
import eu.mhsl.voicebridge.bridge.audio.Mixer;
|
||||||
|
import eu.mhsl.voicebridge.bridge.audio.Opus;
|
||||||
|
import eu.mhsl.voicebridge.bridge.audio.Tones;
|
||||||
|
import eu.mhsl.voicebridge.bridge.discord.DiscordRoomBot;
|
||||||
|
import eu.mhsl.voicebridge.bridge.teamspeak.TeamspeakSide;
|
||||||
|
import io.github.jaredmdobson.concentus.OpusEncoder;
|
||||||
|
import io.github.jaredmdobson.concentus.OpusException;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Macht sichtbar, wer auf den anderen Plattformen ist: Beitritt/Verlassen als Text und Signalton,
|
||||||
|
* Zusammenfassung wenn ein Bot dazukommt, Discord-Voice-Status und TS-Channel-Thema.
|
||||||
|
* Hinweise gehen immer nur an die anderen Plattformen, die eigene zeigt das ja selbst an.
|
||||||
|
*/
|
||||||
|
final class Announcer {
|
||||||
|
/** Discord und TeamSpeak drosseln Änderungen, öfter als alle 5 s wird Status/Thema nicht gesetzt. */
|
||||||
|
private static final long STATUS_INTERVAL_MILLIS = 5000;
|
||||||
|
|
||||||
|
private final PluginLink plugin;
|
||||||
|
private final TeamspeakSide teamspeak;
|
||||||
|
private final ScheduledExecutorService scheduler;
|
||||||
|
private final Map<String, State> states = new HashMap<>();
|
||||||
|
private final AtomicLong toneCounter = new AtomicLong();
|
||||||
|
|
||||||
|
private static final class State {
|
||||||
|
final Map<Side, Map<String, String>> roster = new EnumMap<>(Side.class);
|
||||||
|
boolean teamspeakConnected, discordInVoice;
|
||||||
|
String discordStatus = "", teamspeakTopic = "";
|
||||||
|
long lastStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
Announcer(PluginLink plugin, TeamspeakSide teamspeak, ScheduledExecutorService scheduler) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.teamspeak = teamspeak;
|
||||||
|
this.scheduler = scheduler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Takt für einen Raum.
|
||||||
|
*
|
||||||
|
* @param roster wer auf welcher Seite ist (ID → Name); fehlt eine Seite, ist sie gerade nicht verbunden
|
||||||
|
* und wird nicht verglichen (sonst gäbe es nach jedem Reconnect eine Flut von Meldungen)
|
||||||
|
*/
|
||||||
|
void update(Room room, DiscordRoomBot bot, Map<Side, Map<String, String>> roster, boolean active) {
|
||||||
|
State state = states.computeIfAbsent(room.key(), k -> new State());
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
for (Side side : Side.values()) {
|
||||||
|
Map<String, String> before = state.roster.get(side);
|
||||||
|
Map<String, String> now = roster.get(side);
|
||||||
|
if (before == null || now == null) continue;
|
||||||
|
now.forEach((id, name) -> { if (!before.containsKey(id)) announce(room, bot, roster, side, name, true); });
|
||||||
|
before.forEach((id, name) -> { if (!now.containsKey(id)) announce(room, bot, roster, side, name, false); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.roster.clear();
|
||||||
|
state.roster.putAll(roster);
|
||||||
|
|
||||||
|
// Zusammenfassung, sobald ein Bot im Channel ist
|
||||||
|
boolean tsConnected = teamspeak != null && teamspeak.roomConnected(room);
|
||||||
|
if (tsConnected && !state.teamspeakConnected) {
|
||||||
|
String others = others(roster, Side.TEAMSPEAK, " | ");
|
||||||
|
if (!others.isEmpty()) teamspeak.sendRoomMessage(room, "Verbunden mit " + others);
|
||||||
|
}
|
||||||
|
state.teamspeakConnected = tsConnected;
|
||||||
|
|
||||||
|
boolean dcInVoice = bot != null && bot.inVoice();
|
||||||
|
if (dcInVoice && !state.discordInVoice) {
|
||||||
|
String others = others(roster, Side.DISCORD, " | ");
|
||||||
|
if (!others.isEmpty()) bot.sendText("Verbunden mit " + others);
|
||||||
|
state.discordStatus = ""; // nach dem Beitritt neu setzen
|
||||||
|
}
|
||||||
|
state.discordInVoice = dcInVoice;
|
||||||
|
|
||||||
|
// Wer ist da: Discord-Status und TS-Thema
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (now - state.lastStatus < STATUS_INTERVAL_MILLIS) return;
|
||||||
|
String dcStatus = active ? others(roster, Side.DISCORD, " · ") : "";
|
||||||
|
String tsTopic = active ? others(roster, Side.TEAMSPEAK, " · ") : "";
|
||||||
|
if (dcInVoice && !dcStatus.equals(state.discordStatus)) {
|
||||||
|
bot.setStatus(dcStatus);
|
||||||
|
state.discordStatus = dcStatus;
|
||||||
|
state.lastStatus = now;
|
||||||
|
}
|
||||||
|
if (teamspeak != null && teamspeak.ready() && !tsTopic.equals(state.teamspeakTopic)) {
|
||||||
|
teamspeak.setRoomTopic(room, tsTopic);
|
||||||
|
state.teamspeakTopic = tsTopic;
|
||||||
|
state.lastStatus = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void announce(Room room, DiscordRoomBot bot, Map<Side, Map<String, String>> roster,
|
||||||
|
Side side, String name, boolean joined) {
|
||||||
|
String text = name + " (" + Room.label(side) + ") ist " + (joined ? "dazugekommen." : "gegangen.");
|
||||||
|
List<short[]> tone = joined ? Tones.JOIN : Tones.LEAVE;
|
||||||
|
|
||||||
|
if (side != Side.MINECRAFT && !roster.getOrDefault(Side.MINECRAFT, Map.of()).isEmpty()) {
|
||||||
|
plugin.sendNotice(room.key(), text);
|
||||||
|
playToMinecraft(room, tone);
|
||||||
|
}
|
||||||
|
if (side != Side.TEAMSPEAK && teamspeak != null && teamspeak.roomConnected(room)) {
|
||||||
|
teamspeak.sendRoomMessage(room, text);
|
||||||
|
push(room.toTeamspeak(), tone);
|
||||||
|
}
|
||||||
|
if (side != Side.DISCORD && bot != null && !roster.getOrDefault(Side.DISCORD, Map.of()).isEmpty()) {
|
||||||
|
bot.sendText(text);
|
||||||
|
if (bot.inVoice()) push(room.toDiscord(), tone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Minecraft: Steve, Alex | Discord: Anna" ohne die Seite {@code except}. */
|
||||||
|
static String others(Map<Side, Map<String, String>> roster, Side except, String separator) {
|
||||||
|
List<String> parts = new ArrayList<>();
|
||||||
|
for (Side side : Side.values()) {
|
||||||
|
Map<String, String> people = roster.get(side);
|
||||||
|
if (side == except || people == null || people.isEmpty()) continue;
|
||||||
|
parts.add(Room.label(side) + ": " + String.join(", ", people.values()));
|
||||||
|
}
|
||||||
|
return String.join(separator, parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void push(Mixer mixer, List<short[]> tone) {
|
||||||
|
String source = "tone:" + toneCounter.incrementAndGet();
|
||||||
|
for (short[] frame : tone) mixer.push(source, frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zum Plugin im 20-ms-Takt, wie echte Sprache, sonst verschluckt der Voicechat-Client die Frames. */
|
||||||
|
private void playToMinecraft(Room room, List<short[]> tone) {
|
||||||
|
UUID source = UUID.nameUUIDFromBytes(("voicebridge-tone:" + room.key()).getBytes(StandardCharsets.UTF_8));
|
||||||
|
OpusEncoder encoder = Opus.encoder(1, 48000);
|
||||||
|
for (int i = 0; i < tone.size(); i++) {
|
||||||
|
short[] frame = tone.get(i);
|
||||||
|
scheduler.schedule(() -> {
|
||||||
|
try {
|
||||||
|
plugin.sendAudio(room.key(), source, Opus.encodeMono(encoder, frame));
|
||||||
|
} catch (OpusException ignored) {
|
||||||
|
// ein fehlender Ton-Frame ist egal
|
||||||
|
}
|
||||||
|
}, i * 20L, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,10 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import eu.mhsl.voicebridge.bridge.Room.Side;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.EnumMap;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -33,6 +36,9 @@ public final class Bridge {
|
|||||||
private final TeamspeakSide teamspeak;
|
private final TeamspeakSide teamspeak;
|
||||||
private final ScheduledExecutorService ticker = Executors.newSingleThreadScheduledExecutor(
|
private final ScheduledExecutorService ticker = Executors.newSingleThreadScheduledExecutor(
|
||||||
Thread.ofPlatform().name("bridge-tick").factory());
|
Thread.ofPlatform().name("bridge-tick").factory());
|
||||||
|
private final Announcer announcer;
|
||||||
|
/** Wie lange nach dem letzten Paket jemand noch als "spricht" gilt. */
|
||||||
|
private static final long SPEAKING_MILLIS = 400;
|
||||||
|
|
||||||
private volatile List<Message.RoomInfo> roomInfos = List.of();
|
private volatile List<Message.RoomInfo> roomInfos = List.of();
|
||||||
|
|
||||||
@@ -57,6 +63,15 @@ public final class Bridge {
|
|||||||
this.teamspeak = config.teamspeak().enabled()
|
this.teamspeak = config.teamspeak().enabled()
|
||||||
? new TeamspeakSide(config.teamspeak(), config.dataDir(), List.copyOf(rooms.values()), updater)
|
? new TeamspeakSide(config.teamspeak(), config.dataDir(), List.copyOf(rooms.values()), updater)
|
||||||
: null;
|
: null;
|
||||||
|
for (Room room : rooms.values()) {
|
||||||
|
DiscordRoomBot bot = discordBots.get(room.key());
|
||||||
|
room.setNameResolver((side, id) -> switch (side) {
|
||||||
|
case TEAMSPEAK -> teamspeak != null ? teamspeak.nickname(Integer.parseInt(id)) : id;
|
||||||
|
case DISCORD -> bot != null ? bot.name(Long.parseLong(id)) : id;
|
||||||
|
case MINECRAFT -> id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.announcer = new Announcer(plugin, teamspeak, ticker);
|
||||||
roomInfos = buildInfos();
|
roomInfos = buildInfos();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +82,7 @@ public final class Bridge {
|
|||||||
if (teamspeak != null) teamspeak.start();
|
if (teamspeak != null) teamspeak.start();
|
||||||
discordBots.values().forEach(DiscordRoomBot::start);
|
discordBots.values().forEach(DiscordRoomBot::start);
|
||||||
ticker.scheduleAtFixedRate(this::safeTick, 1, 1, TimeUnit.SECONDS);
|
ticker.scheduleAtFixedRate(this::safeTick, 1, 1, TimeUnit.SECONDS);
|
||||||
|
ticker.scheduleAtFixedRate(this::sendSpeakers, 500, 500, TimeUnit.MILLISECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void safeTick() {
|
private void safeTick() {
|
||||||
@@ -84,9 +100,10 @@ public final class Bridge {
|
|||||||
for (Room room : rooms.values()) {
|
for (Room room : rooms.values()) {
|
||||||
Presence p = presence.get(room.key());
|
Presence p = presence.get(room.key());
|
||||||
DiscordRoomBot bot = discordBots.get(room.key());
|
DiscordRoomBot bot = discordBots.get(room.key());
|
||||||
int mc = plugin.minecraftMembers(room.key());
|
Map<Side, Map<String, String>> roster = roster(room, bot);
|
||||||
int ts = teamspeak != null ? teamspeak.humansIn(room) : 0;
|
int mc = roster.getOrDefault(Side.MINECRAFT, Map.of()).size();
|
||||||
int dc = bot != null ? bot.humans() : 0;
|
int ts = roster.getOrDefault(Side.TEAMSPEAK, Map.of()).size();
|
||||||
|
int dc = roster.getOrDefault(Side.DISCORD, Map.of()).size();
|
||||||
String counts = "Minecraft " + mc + ", TeamSpeak " + ts + ", Discord " + dc;
|
String counts = "Minecraft " + mc + ", TeamSpeak " + ts + ", Discord " + dc;
|
||||||
if (!counts.equals(p.lastCounts)) {
|
if (!counts.equals(p.lastCounts)) {
|
||||||
LOG.info("{}: {}", room.name(), counts);
|
LOG.info("{}: {}", room.name(), counts);
|
||||||
@@ -118,18 +135,45 @@ public final class Bridge {
|
|||||||
p.lastStats = 0;
|
p.lastStats = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
announcer.update(room, bot, roster, active);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.warn("{}: Anzeige fehlgeschlagen", room.name(), e);
|
||||||
|
}
|
||||||
room.dropIdleSources();
|
room.dropIdleSources();
|
||||||
}
|
}
|
||||||
roomInfos = buildInfos();
|
roomInfos = buildInfos();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wer auf welcher Seite ist; Seiten, die gerade nicht verbunden sind, fehlen in der Map. */
|
||||||
|
private Map<Side, Map<String, String>> roster(Room room, DiscordRoomBot bot) {
|
||||||
|
Map<Side, Map<String, String>> roster = new EnumMap<>(Side.class);
|
||||||
|
if (plugin.alive()) roster.put(Side.MINECRAFT, plugin.minecraftMembers(room.key()));
|
||||||
|
if (teamspeak != null && teamspeak.ready()) roster.put(Side.TEAMSPEAK, teamspeak.participants(room));
|
||||||
|
if (bot != null && bot.ready()) roster.put(Side.DISCORD, bot.participants());
|
||||||
|
return roster;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Actionbar im Spiel: wer aus Discord/TeamSpeak gerade spricht. */
|
||||||
|
private void sendSpeakers() {
|
||||||
|
try {
|
||||||
|
for (Room room : rooms.values()) {
|
||||||
|
if (!room.active()) continue;
|
||||||
|
List<String> speakers = room.externalSpeakers(SPEAKING_MILLIS);
|
||||||
|
if (!speakers.isEmpty()) plugin.sendSpeakers(room.key(), speakers);
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOG.warn("Sprecher-Anzeige fehlgeschlagen", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private List<Message.RoomInfo> buildInfos() {
|
private List<Message.RoomInfo> buildInfos() {
|
||||||
List<Message.RoomInfo> infos = new ArrayList<>(rooms.size());
|
List<Message.RoomInfo> infos = new ArrayList<>(rooms.size());
|
||||||
for (Room room : rooms.values()) {
|
for (Room room : rooms.values()) {
|
||||||
DiscordRoomBot bot = discordBots.get(room.key());
|
DiscordRoomBot bot = discordBots.get(room.key());
|
||||||
infos.add(new Message.RoomInfo(room.key(), room.name(), room.groupId(),
|
infos.add(new Message.RoomInfo(room.key(), room.name(), room.groupId(),
|
||||||
teamspeak != null ? teamspeak.humansIn(room) : 0,
|
teamspeak != null ? teamspeak.participants(room).size() : 0,
|
||||||
bot != null ? bot.humans() : 0,
|
bot != null ? bot.participants().size() : 0,
|
||||||
room.active()));
|
room.active()));
|
||||||
}
|
}
|
||||||
return infos;
|
return infos;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import java.net.SocketAddress;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
@@ -25,7 +26,7 @@ final class PluginLink {
|
|||||||
private final DatagramSocket socket;
|
private final DatagramSocket socket;
|
||||||
private final Map<String, Room> rooms;
|
private final Map<String, Room> rooms;
|
||||||
private final Supplier<List<Message.RoomInfo>> roomInfos;
|
private final Supplier<List<Message.RoomInfo>> roomInfos;
|
||||||
private final Map<String, Integer> minecraftMembers = new ConcurrentHashMap<>();
|
private final Map<String, List<Message.Member>> minecraftMembers = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
private volatile SocketAddress plugin;
|
private volatile SocketAddress plugin;
|
||||||
private volatile long lastHello;
|
private volatile long lastHello;
|
||||||
@@ -47,8 +48,22 @@ final class PluginLink {
|
|||||||
return System.currentTimeMillis() - lastHello < ALIVE_MILLIS;
|
return System.currentTimeMillis() - lastHello < ALIVE_MILLIS;
|
||||||
}
|
}
|
||||||
|
|
||||||
int minecraftMembers(String room) {
|
/** Spieler in der Gruppe (UUID → Name), leer solange das Plugin sich nicht meldet. */
|
||||||
return alive() ? minecraftMembers.getOrDefault(room, 0) : 0;
|
Map<String, String> minecraftMembers(String room) {
|
||||||
|
if (!alive()) return Map.of();
|
||||||
|
Map<String, String> result = new TreeMap<>();
|
||||||
|
for (Message.Member member : minecraftMembers.getOrDefault(room, List.of())) result.put(member.id().toString(), member.name());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendNotice(String room, String text) {
|
||||||
|
SocketAddress target = plugin;
|
||||||
|
if (target != null && alive()) send(new Message.Notice(room, text), target);
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendSpeakers(String room, List<String> names) {
|
||||||
|
SocketAddress target = plugin;
|
||||||
|
if (target != null && alive()) send(new Message.Speakers(room, names), target);
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendAudio(String room, UUID source, byte[] opus) {
|
void sendAudio(String room, UUID source, byte[] opus) {
|
||||||
@@ -105,6 +120,8 @@ final class PluginLink {
|
|||||||
if (room != null) room.fromMinecraft(audio.source(), audio.opus());
|
if (room != null) room.fromMinecraft(audio.source(), audio.opus());
|
||||||
}
|
}
|
||||||
case Message.Rooms ignored -> {}
|
case Message.Rooms ignored -> {}
|
||||||
|
case Message.Notice ignored -> {}
|
||||||
|
case Message.Speakers ignored -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.function.BiFunction;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
@@ -35,6 +36,9 @@ public final class Room {
|
|||||||
private final Mixer toDiscord = new Mixer();
|
private final Mixer toDiscord = new Mixer();
|
||||||
private final Map<String, Source> sources = new ConcurrentHashMap<>();
|
private final Map<String, Source> sources = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Anzeigename eines Sprechers (Seite, ID ohne Präfix), für die Actionbar. */
|
||||||
|
private volatile BiFunction<Side, String, String> names = (side, id) -> id;
|
||||||
|
|
||||||
/** Wohin Frames für Minecraft gehen (Plugin-Verbindung). */
|
/** Wohin Frames für Minecraft gehen (Plugin-Verbindung). */
|
||||||
private volatile MinecraftSink minecraft = (room, source, opus) -> {};
|
private volatile MinecraftSink minecraft = (room, source, opus) -> {};
|
||||||
|
|
||||||
@@ -55,6 +59,7 @@ public final class Room {
|
|||||||
final FrameSplitter splitter = new FrameSplitter();
|
final FrameSplitter splitter = new FrameSplitter();
|
||||||
OpusEncoder minecraftEncoder;
|
OpusEncoder minecraftEncoder;
|
||||||
volatile long lastSeen;
|
volatile long lastSeen;
|
||||||
|
volatile String name;
|
||||||
int lastSequence = -1;
|
int lastSequence = -1;
|
||||||
|
|
||||||
Source(Side side, String key) {
|
Source(Side side, String key) {
|
||||||
@@ -78,6 +83,24 @@ public final class Room {
|
|||||||
public Mixer toDiscord() { return toDiscord; }
|
public Mixer toDiscord() { return toDiscord; }
|
||||||
|
|
||||||
public void setMinecraftSink(MinecraftSink sink) { this.minecraft = sink; }
|
public void setMinecraftSink(MinecraftSink sink) { this.minecraft = sink; }
|
||||||
|
public void setNameResolver(BiFunction<Side, String, String> names) { this.names = names; }
|
||||||
|
|
||||||
|
/** Wer aus Discord/TeamSpeak in den letzten {@code millis} Audio geschickt hat, als "Name (Plattform)". */
|
||||||
|
public List<String> externalSpeakers(long millis) {
|
||||||
|
long since = System.currentTimeMillis() - millis;
|
||||||
|
return sources.values().stream()
|
||||||
|
.filter(s -> s.side != Side.MINECRAFT && s.lastSeen >= since)
|
||||||
|
.map(s -> s.name + " (" + label(s.side) + ")")
|
||||||
|
.sorted().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String label(Side side) {
|
||||||
|
return switch (side) {
|
||||||
|
case MINECRAFT -> "Minecraft";
|
||||||
|
case TEAMSPEAK -> "TeamSpeak";
|
||||||
|
case DISCORD -> "Discord";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
void setActive(boolean active) {
|
void setActive(boolean active) {
|
||||||
if (this.active == active) return;
|
if (this.active == active) return;
|
||||||
@@ -109,6 +132,7 @@ public final class Room {
|
|||||||
if (!active || opus.length == 0) return;
|
if (!active || opus.length == 0) return;
|
||||||
Source source = sources.computeIfAbsent(key, k -> new Source(side, k));
|
Source source = sources.computeIfAbsent(key, k -> new Source(side, k));
|
||||||
source.lastSeen = System.currentTimeMillis();
|
source.lastSeen = System.currentTimeMillis();
|
||||||
|
if (source.name == null) source.name = names.apply(side, key.substring(3));
|
||||||
|
|
||||||
List<short[]> frames = new ArrayList<>(2);
|
List<short[]> frames = new ArrayList<>(2);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package eu.mhsl.voicebridge.bridge.audio;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Kurze Signaltöne für Beitritt (aufsteigend) und Verlassen (absteigend), als 20-ms-Frames. */
|
||||||
|
public final class Tones {
|
||||||
|
private static final int AMPLITUDE = 3500;
|
||||||
|
private static final int NOTE_MILLIS = 80;
|
||||||
|
private static final int GAP_MILLIS = 20;
|
||||||
|
|
||||||
|
public static final List<short[]> JOIN = frames(660, 990);
|
||||||
|
public static final List<short[]> LEAVE = frames(990, 660);
|
||||||
|
|
||||||
|
private Tones() {}
|
||||||
|
|
||||||
|
private static List<short[]> frames(double first, double second) {
|
||||||
|
int note = Opus.SAMPLE_RATE * NOTE_MILLIS / 1000;
|
||||||
|
int gap = Opus.SAMPLE_RATE * GAP_MILLIS / 1000;
|
||||||
|
short[] pcm = new short[2 * note + gap];
|
||||||
|
tone(pcm, 0, note, first);
|
||||||
|
tone(pcm, note + gap, note, second);
|
||||||
|
|
||||||
|
List<short[]> frames = new ArrayList<>();
|
||||||
|
for (int offset = 0; offset < pcm.length; offset += Opus.FRAME_SAMPLES) {
|
||||||
|
short[] frame = new short[Opus.FRAME_SAMPLES];
|
||||||
|
System.arraycopy(pcm, offset, frame, 0, Math.min(Opus.FRAME_SAMPLES, pcm.length - offset));
|
||||||
|
frames.add(frame);
|
||||||
|
}
|
||||||
|
return List.copyOf(frames);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sinus mit kurzem Ein- und Ausblenden, damit es nicht knackt. */
|
||||||
|
private static void tone(short[] pcm, int start, int length, double frequency) {
|
||||||
|
int fade = length / 8;
|
||||||
|
for (int i = 0; i < length; i++) {
|
||||||
|
double envelope = Math.min(1.0, Math.min(i, length - 1 - i) / (double) fade);
|
||||||
|
pcm[start + i] = (short) (AMPLITUDE * envelope * Math.sin(2 * Math.PI * frequency * i / Opus.SAMPLE_RATE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,11 +16,13 @@ import net.dv8tion.jda.api.audio.OpusPacket;
|
|||||||
import net.dv8tion.jda.api.entities.Guild;
|
import net.dv8tion.jda.api.entities.Guild;
|
||||||
import net.dv8tion.jda.api.entities.Member;
|
import net.dv8tion.jda.api.entities.Member;
|
||||||
import net.dv8tion.jda.api.entities.channel.concrete.Category;
|
import net.dv8tion.jda.api.entities.channel.concrete.Category;
|
||||||
|
import net.dv8tion.jda.api.entities.channel.attribute.IVoiceStatusChannel;
|
||||||
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
|
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
|
||||||
import net.dv8tion.jda.api.events.guild.GuildJoinEvent;
|
import net.dv8tion.jda.api.events.guild.GuildJoinEvent;
|
||||||
import net.dv8tion.jda.api.hooks.ListenerAdapter;
|
import net.dv8tion.jda.api.hooks.ListenerAdapter;
|
||||||
import net.dv8tion.jda.api.managers.AudioManager;
|
import net.dv8tion.jda.api.managers.AudioManager;
|
||||||
import net.dv8tion.jda.api.requests.GatewayIntent;
|
import net.dv8tion.jda.api.requests.GatewayIntent;
|
||||||
|
import net.dv8tion.jda.api.utils.MarkdownSanitizer;
|
||||||
import net.dv8tion.jda.api.utils.MemberCachePolicy;
|
import net.dv8tion.jda.api.utils.MemberCachePolicy;
|
||||||
import net.dv8tion.jda.api.utils.cache.CacheFlag;
|
import net.dv8tion.jda.api.utils.cache.CacheFlag;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
@@ -29,6 +31,8 @@ import org.slf4j.LoggerFactory;
|
|||||||
|
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
@@ -137,15 +141,56 @@ public final class DiscordRoomBot implements AudioSendHandler, AudioReceiveHandl
|
|||||||
return channel;
|
return channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Menschen im Raum-Channel (Bots zählen nicht). */
|
/** Menschen im Raum-Channel (User-ID → Anzeigename), Bots zählen nicht. */
|
||||||
public int humans() {
|
public Map<String, String> participants() {
|
||||||
|
VoiceChannel channel = channel();
|
||||||
|
if (channel == null) return Map.of();
|
||||||
|
Map<String, String> result = new TreeMap<>();
|
||||||
|
for (Member member : channel.getMembers()) {
|
||||||
|
if (!member.getUser().isBot()) result.put(member.getId(), member.getEffectiveName());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anzeigename eines Users, für die Sprecher-Anzeige. */
|
||||||
|
public String name(long userId) {
|
||||||
Guild guild = this.guild;
|
Guild guild = this.guild;
|
||||||
if (guild == null || channelId == 0) return 0;
|
Member member = guild == null ? null : guild.getMemberById(userId);
|
||||||
VoiceChannel channel = guild.getVoiceChannelById(channelId);
|
return member == null ? "?" : member.getEffectiveName();
|
||||||
if (channel == null) return 0;
|
}
|
||||||
int count = 0;
|
|
||||||
for (Member member : channel.getMembers()) if (!member.getUser().isBot()) count++;
|
/** Ob der Bot eingeloggt ist und seinen Channel kennt. */
|
||||||
return count;
|
public boolean ready() {
|
||||||
|
return guild != null && channelId != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ob der Bot gerade im Voice-Channel ist. */
|
||||||
|
public boolean inVoice() {
|
||||||
|
Guild guild = this.guild;
|
||||||
|
return guild != null && joined && guild.getAudioManager().isConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nachricht im Text-Chat des Voice-Channels. */
|
||||||
|
public void sendText(String text) {
|
||||||
|
VoiceChannel channel = channel();
|
||||||
|
if (channel == null) return;
|
||||||
|
channel.sendMessage(MarkdownSanitizer.escape(text)).setSuppressedNotifications(true).queue(null,
|
||||||
|
e -> LOG.warn("{}: Nachricht nicht gesendet ({}). Hat der Bot \"Nachrichten senden\"?", room.name(), e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Status unter dem Voice-Channel (leer = löschen). Geht nur, solange der Bot im Channel ist. */
|
||||||
|
public void setStatus(String status) {
|
||||||
|
VoiceChannel channel = channel();
|
||||||
|
if (channel == null) return;
|
||||||
|
String text = status.length() > IVoiceStatusChannel.MAX_STATUS_LENGTH
|
||||||
|
? status.substring(0, IVoiceStatusChannel.MAX_STATUS_LENGTH) : status;
|
||||||
|
channel.modifyStatus(text).queue(null,
|
||||||
|
e -> LOG.warn("{}: Status nicht gesetzt ({}). Hat der Bot \"Voice-Channel-Status setzen\"?", room.name(), e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private VoiceChannel channel() {
|
||||||
|
Guild guild = this.guild;
|
||||||
|
return guild == null || channelId == 0 ? null : guild.getVoiceChannelById(channelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setConnected(boolean connected) {
|
public void setConnected(boolean connected) {
|
||||||
|
|||||||
@@ -65,6 +65,18 @@ final class TeamspeakRoomClient implements Microphone, TS3Listener {
|
|||||||
return socket.getClientId();
|
return socket.getClientId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean connected() {
|
||||||
|
return !closing && socket.isConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendChannelMessage(String text) {
|
||||||
|
try {
|
||||||
|
socket.sendChannelMessage(channel, text);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.debug("{}: Textnachricht nicht gesendet: {}", room.name(), e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void disconnect() {
|
void disconnect() {
|
||||||
closing = true;
|
closing = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import java.util.HashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.TreeMap;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
@@ -34,7 +35,7 @@ public final class TeamspeakSide {
|
|||||||
private static final long RETRY_MIN_MILLIS = 30_000;
|
private static final long RETRY_MIN_MILLIS = 30_000;
|
||||||
private static final long RETRY_MAX_MILLIS = 300_000;
|
private static final long RETRY_MAX_MILLIS = 300_000;
|
||||||
|
|
||||||
private record TsClient(int channel, int type) {}
|
private record TsClient(int channel, int type, String name) {}
|
||||||
private record TsChannel(int parent, String name) {}
|
private record TsChannel(int parent, String name) {}
|
||||||
|
|
||||||
private final BridgeConfig.Teamspeak config;
|
private final BridgeConfig.Teamspeak config;
|
||||||
@@ -73,17 +74,51 @@ public final class TeamspeakSide {
|
|||||||
return ready;
|
return ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Menschen im Raum-Channel, ohne unsere eigenen Clients und ohne ServerQuery. */
|
/** Menschen im Raum-Channel (Client-ID → Nickname), ohne unsere eigenen Clients und ohne ServerQuery. */
|
||||||
public int humansIn(Room room) {
|
public Map<String, String> participants(Room room) {
|
||||||
Integer channel = roomChannels.get(room.key());
|
Integer channel = roomChannels.get(room.key());
|
||||||
if (!ready || channel == null) return 0;
|
if (!ready || channel == null) return Map.of();
|
||||||
Set<Integer> own = ownClientIds();
|
Set<Integer> own = ownClientIds();
|
||||||
int count = 0;
|
Map<String, String> result = new TreeMap<>();
|
||||||
for (Map.Entry<Integer, TsClient> e : clients.entrySet()) {
|
for (Map.Entry<Integer, TsClient> e : clients.entrySet()) {
|
||||||
TsClient client = e.getValue();
|
TsClient client = e.getValue();
|
||||||
if (client.channel() == channel && client.type() == CLIENT_TYPE_VOICE && !own.contains(e.getKey())) count++;
|
if (client.channel() == channel && client.type() == CLIENT_TYPE_VOICE && !own.contains(e.getKey())) {
|
||||||
|
result.put(Integer.toString(e.getKey()), client.name());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return count;
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nickname eines Clients, für die Sprecher-Anzeige. */
|
||||||
|
public String nickname(int clientId) {
|
||||||
|
TsClient client = clients.get(clientId);
|
||||||
|
return client == null ? "?" : client.name();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ob der Raum-Client gerade im Channel ist. */
|
||||||
|
public boolean roomConnected(Room room) {
|
||||||
|
TeamspeakRoomClient client = roomClients.get(room.key());
|
||||||
|
return client != null && client.connected();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Textnachricht des Raum-Clients in seinen Channel. */
|
||||||
|
public void sendRoomMessage(Room room, String text) {
|
||||||
|
TeamspeakRoomClient client = roomClients.get(room.key());
|
||||||
|
if (client != null && client.connected()) executor.execute(() -> client.sendChannelMessage(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Channel-Thema per Query setzen (leer = löschen). */
|
||||||
|
public void setRoomTopic(Room room, String topic) {
|
||||||
|
Integer channel = roomChannels.get(room.key());
|
||||||
|
TsQuery q = query;
|
||||||
|
if (!ready || q == null || channel == null) return;
|
||||||
|
executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
q.send("channeledit", "cid", Integer.toString(channel), "channel_topic", topic);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.warn("{}: Channel-Thema nicht gesetzt: {}", room.name(), e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -200,7 +235,8 @@ public final class TeamspeakSide {
|
|||||||
private void loadClients(TsQuery q) throws IOException {
|
private void loadClients(TsQuery q) throws IOException {
|
||||||
Map<Integer, TsClient> fresh = new ConcurrentHashMap<>();
|
Map<Integer, TsClient> fresh = new ConcurrentHashMap<>();
|
||||||
for (Map<String, String> c : q.send("clientlist")) {
|
for (Map<String, String> c : q.send("clientlist")) {
|
||||||
fresh.put(parse(c.get("clid"), -1), new TsClient(parse(c.get("cid"), 0), parse(c.get("client_type"), 0)));
|
fresh.put(parse(c.get("clid"), -1), new TsClient(parse(c.get("cid"), 0), parse(c.get("client_type"), 0),
|
||||||
|
c.getOrDefault("client_nickname", "?")));
|
||||||
}
|
}
|
||||||
clients.clear();
|
clients.clear();
|
||||||
clients.putAll(fresh);
|
clients.putAll(fresh);
|
||||||
@@ -269,10 +305,15 @@ public final class TeamspeakSide {
|
|||||||
int clid = parse(f.get("clid"), -1);
|
int clid = parse(f.get("clid"), -1);
|
||||||
int cid = parse(f.get("cid"), -1);
|
int cid = parse(f.get("cid"), -1);
|
||||||
switch (n.name()) {
|
switch (n.name()) {
|
||||||
case "notifycliententerview" -> clients.put(clid, new TsClient(parse(f.get("ctid"), 0), parse(f.get("client_type"), 0)));
|
case "notifycliententerview" -> clients.put(clid, new TsClient(parse(f.get("ctid"), 0), parse(f.get("client_type"), 0),
|
||||||
|
f.getOrDefault("client_nickname", "?")));
|
||||||
case "notifyclientleftview" -> clients.remove(clid);
|
case "notifyclientleftview" -> clients.remove(clid);
|
||||||
case "notifyclientmoved" -> clients.compute(clid, (id, old) ->
|
case "notifyclientmoved" -> clients.compute(clid, (id, old) ->
|
||||||
new TsClient(parse(f.get("ctid"), 0), old == null ? CLIENT_TYPE_VOICE : old.type()));
|
new TsClient(parse(f.get("ctid"), 0), old == null ? CLIENT_TYPE_VOICE : old.type(), old == null ? "?" : old.name()));
|
||||||
|
case "notifyclientupdated" -> {
|
||||||
|
String name = f.get("client_nickname");
|
||||||
|
if (name != null) clients.computeIfPresent(clid, (id, old) -> new TsClient(old.channel(), old.type(), name));
|
||||||
|
}
|
||||||
case "notifychannelcreated" -> channels.put(cid, new TsChannel(parse(f.get("cpid"), 0), f.getOrDefault("channel_name", "")));
|
case "notifychannelcreated" -> channels.put(cid, new TsChannel(parse(f.get("cpid"), 0), f.getOrDefault("channel_name", "")));
|
||||||
case "notifychanneledited" -> {
|
case "notifychanneledited" -> {
|
||||||
String name = f.get("channel_name");
|
String name = f.get("channel_name");
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package eu.mhsl.voicebridge.bridge;
|
||||||
|
|
||||||
|
import eu.mhsl.voicebridge.bridge.Room.Side;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class AnnouncerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void othersSkipsOwnAndEmptySides() {
|
||||||
|
Map<Side, Map<String, String>> roster = new EnumMap<>(Side.class);
|
||||||
|
roster.put(Side.MINECRAFT, new TreeMap<>(Map.of("a", "Alex", "b", "Steve")));
|
||||||
|
roster.put(Side.TEAMSPEAK, Map.of());
|
||||||
|
roster.put(Side.DISCORD, Map.of("1", "Anna"));
|
||||||
|
|
||||||
|
assertEquals("Minecraft: Alex, Steve", Announcer.others(roster, Side.DISCORD, " · "));
|
||||||
|
assertEquals("Minecraft: Alex, Steve | Discord: Anna", Announcer.others(roster, Side.TEAMSPEAK, " | "));
|
||||||
|
assertEquals("Discord: Anna", Announcer.others(roster, Side.MINECRAFT, " · "));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package eu.mhsl.voicebridge.plugin;
|
||||||
|
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import org.bukkit.NamespacedKey;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.command.TabExecutor;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.persistence.PersistentDataType;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** {@code /crosstalk actionbar}: die Sprecher-Anzeige für sich selbst aus- und einschalten. */
|
||||||
|
final class ActionbarCommand implements TabExecutor {
|
||||||
|
private static NamespacedKey key;
|
||||||
|
|
||||||
|
static void init(Plugin plugin) {
|
||||||
|
key = new NamespacedKey(plugin, "actionbar_off");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Main-Thread: ob der Spieler die Actionbar sehen will (Standard: ja). */
|
||||||
|
static boolean enabled(Player player) {
|
||||||
|
return !player.getPersistentDataContainer().has(key, PersistentDataType.BYTE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, String @NotNull [] args) {
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
sender.sendMessage("Nur für Spieler.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (args.length != 1 || !args[0].equalsIgnoreCase("actionbar")) return false;
|
||||||
|
|
||||||
|
boolean nowEnabled = !enabled(player);
|
||||||
|
if (nowEnabled) player.getPersistentDataContainer().remove(key);
|
||||||
|
else player.getPersistentDataContainer().set(key, PersistentDataType.BYTE, (byte) 1);
|
||||||
|
player.sendMessage(Component.text("Anzeige, wer aus Discord/TeamSpeak spricht: "
|
||||||
|
+ (nowEnabled ? "an" : "aus"), NamedTextColor.GRAY));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, String @NotNull [] args) {
|
||||||
|
return args.length == 1 ? List.of("actionbar") : List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import java.net.DatagramPacket;
|
|||||||
import java.net.DatagramSocket;
|
import java.net.DatagramSocket;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
import java.net.SocketException;
|
import java.net.SocketException;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
@@ -43,7 +44,7 @@ final class BridgeLink {
|
|||||||
return System.currentTimeMillis() - lastReply < ALIVE_MILLIS;
|
return System.currentTimeMillis() - lastReply < ALIVE_MILLIS;
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendHello(Map<String, Integer> members) {
|
void sendHello(Map<String, List<Message.Member>> members) {
|
||||||
boolean alive = isBridgeAlive();
|
boolean alive = isBridgeAlive();
|
||||||
if (wasAlive && !alive) logger.warning("Bridge antwortet nicht mehr.");
|
if (wasAlive && !alive) logger.warning("Bridge antwortet nicht mehr.");
|
||||||
wasAlive = alive;
|
wasAlive = alive;
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ final class BridgeVoicechat implements VoicechatPlugin {
|
|||||||
if (api == null || link == null) return;
|
if (api == null || link == null) return;
|
||||||
|
|
||||||
Map<UUID, List<VoicechatConnection>> byGroup = new HashMap<>();
|
Map<UUID, List<VoicechatConnection>> byGroup = new HashMap<>();
|
||||||
|
Map<UUID, String> names = new HashMap<>();
|
||||||
for (Player player : Bukkit.getOnlinePlayers()) {
|
for (Player player : Bukkit.getOnlinePlayers()) {
|
||||||
|
names.put(player.getUniqueId(), player.getName());
|
||||||
VoicechatConnection connection = api.getConnectionOf(player.getUniqueId());
|
VoicechatConnection connection = api.getConnectionOf(player.getUniqueId());
|
||||||
if (connection == null || !connection.isConnected() || connection.isDisabled()) continue;
|
if (connection == null || !connection.isConnected() || connection.isDisabled()) continue;
|
||||||
Group group = connection.getGroup();
|
Group group = connection.getGroup();
|
||||||
@@ -109,14 +111,17 @@ final class BridgeVoicechat implements VoicechatPlugin {
|
|||||||
byGroup.computeIfAbsent(group.getId(), k -> new ArrayList<>()).add(connection);
|
byGroup.computeIfAbsent(group.getId(), k -> new ArrayList<>()).add(connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Integer> counts = new HashMap<>();
|
Map<String, List<Message.Member>> roster = new HashMap<>();
|
||||||
for (CrosstalkRoom room : rooms.values()) {
|
for (CrosstalkRoom room : rooms.values()) {
|
||||||
List<VoicechatConnection> members = byGroup.getOrDefault(room.groupId(), List.of());
|
List<VoicechatConnection> members = byGroup.getOrDefault(room.groupId(), List.of());
|
||||||
room.setMembers(members);
|
room.setMembers(members);
|
||||||
room.dropIdleSpeakers();
|
room.dropIdleSpeakers();
|
||||||
counts.put(room.key(), members.size());
|
roster.put(room.key(), members.stream().map(c -> {
|
||||||
|
UUID id = c.getPlayer().getUuid();
|
||||||
|
return new Message.Member(id, names.getOrDefault(id, id.toString()));
|
||||||
|
}).toList());
|
||||||
}
|
}
|
||||||
link.sendHello(counts);
|
link.sendHello(roster);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Netzwerk-Thread der Bridge-Verbindung. */
|
/** Netzwerk-Thread der Bridge-Verbindung. */
|
||||||
@@ -127,6 +132,20 @@ final class BridgeVoicechat implements VoicechatPlugin {
|
|||||||
CrosstalkRoom room = rooms.get(audio.room());
|
CrosstalkRoom room = rooms.get(audio.room());
|
||||||
if (room != null) room.play(audio.source(), audio.opus());
|
if (room != null) room.play(audio.source(), audio.opus());
|
||||||
}
|
}
|
||||||
|
case Message.Notice notice -> Bukkit.getScheduler().runTask(plugin, () -> {
|
||||||
|
CrosstalkRoom room = rooms.get(notice.room());
|
||||||
|
if (room == null) return;
|
||||||
|
Component text = Component.text("[" + room.name() + "] ", NamedTextColor.GOLD)
|
||||||
|
.append(Component.text(notice.text(), NamedTextColor.GRAY));
|
||||||
|
room.memberPlayers().forEach(player -> player.sendMessage(text));
|
||||||
|
});
|
||||||
|
case Message.Speakers speakers -> Bukkit.getScheduler().runTask(plugin, () -> {
|
||||||
|
CrosstalkRoom room = rooms.get(speakers.room());
|
||||||
|
if (room == null || speakers.names().isEmpty() || !plugin.getConfig().getBoolean("actionbar", true)) return;
|
||||||
|
Component text = Component.text("Spricht: ", NamedTextColor.GRAY)
|
||||||
|
.append(Component.text(String.join(", ", speakers.names()), NamedTextColor.GOLD));
|
||||||
|
room.memberPlayers().stream().filter(ActionbarCommand::enabled).forEach(player -> player.sendActionBar(text));
|
||||||
|
});
|
||||||
case Message.Hello ignored -> {}
|
case Message.Hello ignored -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import de.maxhenkel.voicechat.api.VoicechatServerApi;
|
|||||||
import de.maxhenkel.voicechat.api.audiochannel.StaticAudioChannel;
|
import de.maxhenkel.voicechat.api.audiochannel.StaticAudioChannel;
|
||||||
import eu.mhsl.voicebridge.protocol.Message;
|
import eu.mhsl.voicebridge.protocol.Message;
|
||||||
|
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,6 +50,11 @@ final class CrosstalkRoom {
|
|||||||
return current != null && current.active();
|
return current != null && current.active();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Main-Thread: die Spieler in der Gruppe. */
|
||||||
|
synchronized List<Player> memberPlayers() {
|
||||||
|
return members.stream().map(c -> Bukkit.getPlayer(c.getPlayer().getUuid())).filter(Objects::nonNull).toList();
|
||||||
|
}
|
||||||
|
|
||||||
synchronized void setMembers(List<VoicechatConnection> members) {
|
synchronized void setMembers(List<VoicechatConnection> members) {
|
||||||
if (sameMembers(this.members, members)) return;
|
if (sameMembers(this.members, members)) return;
|
||||||
this.members = List.copyOf(members);
|
this.members = List.copyOf(members);
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ public final class VoiceBridgePlugin extends JavaPlugin {
|
|||||||
InetSocketAddress bridge = new InetSocketAddress(
|
InetSocketAddress bridge = new InetSocketAddress(
|
||||||
getConfig().getString("bridge.host", "127.0.0.1"),
|
getConfig().getString("bridge.host", "127.0.0.1"),
|
||||||
getConfig().getInt("bridge.port", 24470));
|
getConfig().getInt("bridge.port", 24470));
|
||||||
|
ActionbarCommand.init(this);
|
||||||
|
var command = getCommand("crosstalk");
|
||||||
|
if (command != null) command.setExecutor(new ActionbarCommand());
|
||||||
voicechat = new BridgeVoicechat(this, bridge);
|
voicechat = new BridgeVoicechat(this, bridge);
|
||||||
service.registerPlugin(voicechat);
|
service.registerPlugin(voicechat);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,3 +2,7 @@
|
|||||||
bridge:
|
bridge:
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
port: 24470
|
port: 24470
|
||||||
|
|
||||||
|
# Actionbar "Spricht: …" mit den gerade sprechenden Leuten aus Discord/TeamSpeak.
|
||||||
|
# Jeder Spieler kann sie zusätzlich mit /crosstalk actionbar für sich aus- und einschalten.
|
||||||
|
actionbar: true
|
||||||
@@ -6,3 +6,12 @@ depend: [ voicechat ]
|
|||||||
authors: [ MineTec ]
|
authors: [ MineTec ]
|
||||||
description: Verbindet Simple-Voice-Chat-Gruppen mit Discord und TeamSpeak
|
description: Verbindet Simple-Voice-Chat-Gruppen mit Discord und TeamSpeak
|
||||||
website: mhsl.eu
|
website: mhsl.eu
|
||||||
|
commands:
|
||||||
|
crosstalk:
|
||||||
|
description: Einstellungen für die Crosstalk-Gruppen
|
||||||
|
usage: "/crosstalk actionbar"
|
||||||
|
permission: voicebridge.crosstalk
|
||||||
|
permissions:
|
||||||
|
voicebridge.crosstalk:
|
||||||
|
description: /crosstalk benutzen
|
||||||
|
default: true
|
||||||
@@ -10,7 +10,9 @@ import java.util.UUID;
|
|||||||
public sealed interface Message {
|
public sealed interface Message {
|
||||||
|
|
||||||
/** Plugin → Bridge, jede Sekunde: Herzschlag und verbundene Spieler pro Raum. */
|
/** Plugin → Bridge, jede Sekunde: Herzschlag und verbundene Spieler pro Raum. */
|
||||||
record Hello(int version, Map<String, Integer> members) implements Message {}
|
record Hello(int version, Map<String, List<Member>> members) implements Message {}
|
||||||
|
|
||||||
|
record Member(UUID id, String name) {}
|
||||||
|
|
||||||
/** Bridge → Plugin, Antwort auf jedes Hello: die Räume und wer auf den anderen Seiten ist. */
|
/** Bridge → Plugin, Antwort auf jedes Hello: die Räume und wer auf den anderen Seiten ist. */
|
||||||
record Rooms(List<RoomInfo> rooms) implements Message {}
|
record Rooms(List<RoomInfo> rooms) implements Message {}
|
||||||
@@ -22,4 +24,10 @@ public sealed interface Message {
|
|||||||
* die Spieler-UUID, beim Bridge → Plugin eine feste UUID pro externem Sprecher.
|
* die Spieler-UUID, beim Bridge → Plugin eine feste UUID pro externem Sprecher.
|
||||||
*/
|
*/
|
||||||
record Audio(String room, UUID source, byte[] opus) implements Message {}
|
record Audio(String room, UUID source, byte[] opus) implements Message {}
|
||||||
|
|
||||||
|
/** Bridge → Plugin: Chat-Hinweis an alle in der Gruppe, z. B. wer dazugekommen ist. */
|
||||||
|
record Notice(String room, String text) implements Message {}
|
||||||
|
|
||||||
|
/** Bridge → Plugin: wer aus Discord/TeamSpeak gerade spricht (für die Actionbar). */
|
||||||
|
record Speakers(String room, List<String> names) implements Message {}
|
||||||
}
|
}
|
||||||
@@ -14,13 +14,15 @@ import java.util.UUID;
|
|||||||
|
|
||||||
public final class Protocol {
|
public final class Protocol {
|
||||||
/** Bei inkompatiblen Änderungen am Format hochzählen. */
|
/** Bei inkompatiblen Änderungen am Format hochzählen. */
|
||||||
public static final int VERSION = 1;
|
public static final int VERSION = 2;
|
||||||
public static final int DEFAULT_PORT = 24470;
|
public static final int DEFAULT_PORT = 24470;
|
||||||
public static final int MAX_DATAGRAM = 2048;
|
public static final int MAX_DATAGRAM = 2048;
|
||||||
|
|
||||||
private static final byte HELLO = 1;
|
private static final byte HELLO = 1;
|
||||||
private static final byte ROOMS = 2;
|
private static final byte ROOMS = 2;
|
||||||
private static final byte AUDIO = 3;
|
private static final byte AUDIO = 3;
|
||||||
|
private static final byte NOTICE = 4;
|
||||||
|
private static final byte SPEAKERS = 5;
|
||||||
|
|
||||||
private Protocol() {}
|
private Protocol() {}
|
||||||
|
|
||||||
@@ -32,9 +34,13 @@ public final class Protocol {
|
|||||||
out.writeByte(HELLO);
|
out.writeByte(HELLO);
|
||||||
out.writeInt(hello.version());
|
out.writeInt(hello.version());
|
||||||
out.writeShort(hello.members().size());
|
out.writeShort(hello.members().size());
|
||||||
for (Map.Entry<String, Integer> e : hello.members().entrySet()) {
|
for (Map.Entry<String, List<Message.Member>> e : hello.members().entrySet()) {
|
||||||
out.writeUTF(e.getKey());
|
out.writeUTF(e.getKey());
|
||||||
out.writeShort(e.getValue());
|
out.writeShort(e.getValue().size());
|
||||||
|
for (Message.Member member : e.getValue()) {
|
||||||
|
writeUuid(out, member.id());
|
||||||
|
out.writeUTF(member.name());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case Message.Rooms rooms -> {
|
case Message.Rooms rooms -> {
|
||||||
@@ -55,6 +61,17 @@ public final class Protocol {
|
|||||||
writeUuid(out, audio.source());
|
writeUuid(out, audio.source());
|
||||||
out.write(audio.opus());
|
out.write(audio.opus());
|
||||||
}
|
}
|
||||||
|
case Message.Notice notice -> {
|
||||||
|
out.writeByte(NOTICE);
|
||||||
|
out.writeUTF(notice.room());
|
||||||
|
out.writeUTF(notice.text());
|
||||||
|
}
|
||||||
|
case Message.Speakers speakers -> {
|
||||||
|
out.writeByte(SPEAKERS);
|
||||||
|
out.writeUTF(speakers.room());
|
||||||
|
out.writeShort(speakers.names().size());
|
||||||
|
for (String name : speakers.names()) out.writeUTF(name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new UncheckedIOException(e);
|
throw new UncheckedIOException(e);
|
||||||
@@ -69,8 +86,14 @@ public final class Protocol {
|
|||||||
case HELLO -> {
|
case HELLO -> {
|
||||||
int version = in.readInt();
|
int version = in.readInt();
|
||||||
int count = in.readUnsignedShort();
|
int count = in.readUnsignedShort();
|
||||||
Map<String, Integer> members = new LinkedHashMap<>();
|
Map<String, List<Message.Member>> members = new LinkedHashMap<>();
|
||||||
for (int i = 0; i < count; i++) members.put(in.readUTF(), in.readUnsignedShort());
|
for (int i = 0; i < count; i++) {
|
||||||
|
String room = in.readUTF();
|
||||||
|
int size = in.readUnsignedShort();
|
||||||
|
List<Message.Member> list = new ArrayList<>(size);
|
||||||
|
for (int j = 0; j < size; j++) list.add(new Message.Member(readUuid(in), in.readUTF()));
|
||||||
|
members.put(room, list);
|
||||||
|
}
|
||||||
yield new Message.Hello(version, members);
|
yield new Message.Hello(version, members);
|
||||||
}
|
}
|
||||||
case ROOMS -> {
|
case ROOMS -> {
|
||||||
@@ -83,6 +106,14 @@ public final class Protocol {
|
|||||||
yield new Message.Rooms(rooms);
|
yield new Message.Rooms(rooms);
|
||||||
}
|
}
|
||||||
case AUDIO -> new Message.Audio(in.readUTF(), readUuid(in), in.readAllBytes());
|
case AUDIO -> new Message.Audio(in.readUTF(), readUuid(in), in.readAllBytes());
|
||||||
|
case NOTICE -> new Message.Notice(in.readUTF(), in.readUTF());
|
||||||
|
case SPEAKERS -> {
|
||||||
|
String room = in.readUTF();
|
||||||
|
int size = in.readUnsignedShort();
|
||||||
|
List<String> names = new ArrayList<>(size);
|
||||||
|
for (int i = 0; i < size; i++) names.add(in.readUTF());
|
||||||
|
yield new Message.Speakers(room, names);
|
||||||
|
}
|
||||||
default -> throw new IOException("Unbekannter Nachrichtentyp " + type);
|
default -> throw new IOException("Unbekannter Nachrichtentyp " + type);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ class ProtocolTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void hello() throws IOException {
|
void hello() throws IOException {
|
||||||
Message.Hello hello = new Message.Hello(Protocol.VERSION, Map.of("crosstalk-1", 3));
|
Message.Hello hello = new Message.Hello(Protocol.VERSION,
|
||||||
|
Map.of("crosstalk-1", List.of(new Message.Member(UUID.randomUUID(), "Steve"))));
|
||||||
assertEquals(hello, roundTrip(hello));
|
assertEquals(hello, roundTrip(hello));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +40,14 @@ class ProtocolTest {
|
|||||||
assertArrayEquals(opus, audio.opus());
|
assertArrayEquals(opus, audio.opus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noticeAndSpeakers() throws IOException {
|
||||||
|
Message.Notice notice = new Message.Notice("crosstalk-1", "Anna (Discord) ist dazugekommen.");
|
||||||
|
assertEquals(notice, roundTrip(notice));
|
||||||
|
Message.Speakers speakers = new Message.Speakers("crosstalk-1", List.of("Anna (Discord)", "Bob (TeamSpeak)"));
|
||||||
|
assertEquals(speakers, roundTrip(speakers));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unknownType() {
|
void unknownType() {
|
||||||
assertThrows(IOException.class, () -> Protocol.decode(new byte[]{42}, 0, 1));
|
assertThrows(IOException.class, () -> Protocol.decode(new byte[]{42}, 0, 1));
|
||||||
|
|||||||
Reference in new issue
Block a user