Discord/TS-Teilnehmer als Mitglieder in der Voicechat-Gruppenanzeige (mit Sprech-Symbol), Actionbar entfernt

This commit is contained in:
MineTec committed 2026-10-19 17:34:33 +02:00
1 parent d5c181a224
commit e2546ad982
17 files changed
+250 -150

No files matched your search

+11
View File
@@ -47,3 +47,14 @@ tasks.register('uploadPlugin') {
}.result.get()
}
}
dependencies {
testImplementation platform('org.junit:junit-bom:5.13.4')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testImplementation 'io.papermc.paper:paper-api:26.2.build.129-stable'
}
tasks.named('test') {
useJUnitPlatform()
}
@@ -1,49 +0,0 @@
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();
}
}
@@ -115,6 +115,7 @@ final class BridgeVoicechat implements VoicechatPlugin {
for (CrosstalkRoom room : rooms.values()) {
List<VoicechatConnection> members = byGroup.getOrDefault(room.groupId(), List.of());
room.setMembers(members);
room.syncExternals(plugin);
room.dropIdleSpeakers();
roster.put(room.key(), members.stream().map(c -> {
UUID id = c.getPlayer().getUuid();
@@ -139,32 +140,16 @@ final class BridgeVoicechat implements VoicechatPlugin {
.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 = speakersLine(speakers.names());
room.memberPlayers().stream().filter(ActionbarCommand::enabled).forEach(player -> player.sendActionBar(text));
case Message.Participants participants -> Bukkit.getScheduler().runTask(plugin, () -> {
CrosstalkRoom room = rooms.get(participants.room());
if (room == null) return;
room.setExternals(participants.members());
room.syncExternals(plugin);
});
case Message.Hello ignored -> {}
}
}
/** Unauffällige Actionbar: "♪ Anna DC · Bob TS", höchstens drei Namen, Rest als "+N". */
static Component speakersLine(List<String> speakers) {
int shown = Math.min(3, speakers.size());
Component line = Component.text("♪ ", NamedTextColor.DARK_GRAY);
for (int i = 0; i < shown; i++) {
String entry = speakers.get(i);
int split = entry.lastIndexOf('|');
String name = split < 0 ? entry : entry.substring(0, split);
String tag = split < 0 ? "" : " " + entry.substring(split + 1);
if (i > 0) line = line.append(Component.text(" · ", NamedTextColor.DARK_GRAY));
line = line.append(Component.text(name, NamedTextColor.GRAY)).append(Component.text(tag, NamedTextColor.DARK_GRAY));
}
if (speakers.size() > shown) line = line.append(Component.text(" +" + (speakers.size() - shown), NamedTextColor.DARK_GRAY));
return line;
}
/** Main-Thread: Gruppen an die Räume der Bridge angleichen. */
private void syncRooms(List<Message.RoomInfo> infos) {
VoicechatServerApi api = this.api;
@@ -209,6 +194,8 @@ final class BridgeVoicechat implements VoicechatPlugin {
rooms.remove(room.key());
roomsByGroup.remove(room.groupId());
room.dropAllSpeakers();
room.setExternals(List.of());
room.syncExternals(plugin);
if (api != null) api.removeGroup(room.groupId());
plugin.getLogger().info("Crosstalk-Gruppe entfernt: " + room.name());
}
@@ -8,7 +8,12 @@ import eu.mhsl.voicebridge.protocol.Message;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Set;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -20,6 +25,7 @@ import java.util.UUID;
*/
final class CrosstalkRoom {
private static final long SPEAKER_IDLE_MILLIS = 1000;
private static final long FULL_SYNC_MILLIS = 30_000;
private final VoicechatServerApi api;
private final String key;
@@ -27,6 +33,11 @@ final class CrosstalkRoom {
private final UUID groupId;
private final Map<UUID, Speaker> speakers = new HashMap<>();
/** Leute aus Discord/TeamSpeak (UUID ihres Audios → Anzeigename), laut Bridge. */
private Map<UUID, String> externals = Map.of();
/** Was jeder Spieler davon schon in seiner Voicechat-Anzeige hat. */
private final Map<UUID, Map<UUID, String>> shown = new HashMap<>();
private long lastFullSync;
private List<VoicechatConnection> members = List.of();
private volatile Message.RoomInfo info;
@@ -50,6 +61,47 @@ final class CrosstalkRoom {
return current != null && current.active();
}
synchronized void setExternals(List<Message.Member> members) {
Map<UUID, String> map = new LinkedHashMap<>();
for (Message.Member member : members) map.put(member.id(), member.name());
externals = map;
}
/**
* Main-Thread: Die Leute aus Discord/TeamSpeak als Mitglieder in die Gruppenanzeige der Spieler eintragen
* (mit Sprech-Symbol, weil ihr Audio unter derselben UUID kommt). Nur Änderungen, alle 30 s alles.
*/
synchronized void syncExternals(Plugin plugin) {
long now = System.currentTimeMillis();
boolean full = now - lastFullSync >= FULL_SYNC_MILLIS;
if (full) lastFullSync = now;
Set<UUID> viewers = new HashSet<>();
for (Player player : memberPlayers()) {
viewers.add(player.getUniqueId());
Map<UUID, String> have = shown.computeIfAbsent(player.getUniqueId(), k -> new HashMap<>());
for (Map.Entry<UUID, String> e : externals.entrySet()) {
if (full || !e.getValue().equals(have.get(e.getKey()))) {
VoicechatStates.sendState(plugin, player, e.getKey(), e.getValue(), groupId);
have.put(e.getKey(), e.getValue());
}
}
have.keySet().removeIf(id -> {
if (externals.containsKey(id)) return false;
VoicechatStates.sendRemove(plugin, player, id);
return true;
});
}
// Wer die Gruppe verlassen hat, bekommt die Einträge wieder entfernt
shown.entrySet().removeIf(e -> {
if (viewers.contains(e.getKey())) return false;
Player player = Bukkit.getPlayer(e.getKey());
if (player != null) e.getValue().keySet().forEach(id -> VoicechatStates.sendRemove(plugin, player, id));
return true;
});
}
/** 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();
@@ -22,9 +22,7 @@ public final class VoiceBridgePlugin extends JavaPlugin {
InetSocketAddress bridge = new InetSocketAddress(
getConfig().getString("bridge.host", "127.0.0.1"),
getConfig().getInt("bridge.port", 24470));
ActionbarCommand.init(this);
var command = getCommand("crosstalk");
if (command != null) command.setExecutor(new ActionbarCommand());
VoicechatStates.register(this);
voicechat = new BridgeVoicechat(this, bridge);
service.registerPlugin(voicechat);
}
@@ -0,0 +1,80 @@
package eu.mhsl.voicebridge.plugin;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
/**
* Trägt erfundene Teilnehmer (Leute aus Discord/TeamSpeak) in die Gruppenanzeige des Voicechat-Clients ein.
*
* <p>Das ist <b>kein</b> offizieller API-Weg: Wir schicken dieselben Plugin-Messages, mit denen Simple Voice Chat
* dem Client die Spielerliste mitteilt ({@code voicechat:state}, {@code voicechat:remove_state}, Format aus
* {@code PlayerState#toBytes} in Version 2.6.x). Ändert ein Voicechat-Update das Format, fehlen nur die
* Einträge, das Audio läuft weiter über die API.
*/
final class VoicechatStates {
static final String STATE_CHANNEL = "voicechat:state";
static final String REMOVE_CHANNEL = "voicechat:remove_state";
private VoicechatStates() {}
static void register(Plugin plugin) {
plugin.getServer().getMessenger().registerOutgoingPluginChannel(plugin, STATE_CHANNEL);
plugin.getServer().getMessenger().registerOutgoingPluginChannel(plugin, REMOVE_CHANNEL);
}
static void sendState(Plugin plugin, Player viewer, UUID id, String name, UUID group) {
viewer.sendPluginMessage(plugin, STATE_CHANNEL, state(id, name, group));
}
static void sendRemove(Plugin plugin, Player viewer, UUID id) {
viewer.sendPluginMessage(plugin, REMOVE_CHANNEL, uuid(id));
}
/** {@code PlayerState#toBytes}: disabled, disconnected, UUID, Name (VarInt-Länge + UTF-8), optional Gruppe. */
static byte[] state(UUID id, String name, UUID group) {
ByteArrayOutputStream bytes = new ByteArrayOutputStream(64);
try (DataOutputStream out = new DataOutputStream(bytes)) {
out.writeBoolean(false);
out.writeBoolean(false);
out.writeLong(id.getMostSignificantBits());
out.writeLong(id.getLeastSignificantBits());
byte[] utf = name.getBytes(StandardCharsets.UTF_8);
writeVarInt(out, utf.length);
out.write(utf);
out.writeBoolean(group != null);
if (group != null) {
out.writeLong(group.getMostSignificantBits());
out.writeLong(group.getLeastSignificantBits());
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return bytes.toByteArray();
}
static byte[] uuid(UUID id) {
ByteArrayOutputStream bytes = new ByteArrayOutputStream(16);
try (DataOutputStream out = new DataOutputStream(bytes)) {
out.writeLong(id.getMostSignificantBits());
out.writeLong(id.getLeastSignificantBits());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return bytes.toByteArray();
}
private static void writeVarInt(DataOutputStream out, int value) throws IOException {
while ((value & ~0x7F) != 0) {
out.writeByte((value & 0x7F) | 0x80);
value >>>= 7;
}
out.writeByte(value);
}
}
-4
View File
@@ -2,7 +2,3 @@
bridge:
host: 127.0.0.1
port: 24470
# Actionbar "♪ Anna DC · Bob TS" 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
-9
View File
@@ -6,12 +6,3 @@ depend: [ voicechat ]
authors: [ MineTec ]
description: Verbindet Simple-Voice-Chat-Gruppen mit Discord und TeamSpeak
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
@@ -0,0 +1,47 @@
package eu.mhsl.voicebridge.plugin;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/** Liest wie {@code PlayerState#fromBytes} aus Simple Voice Chat 2.6.x. */
class VoicechatStatesTest {
@Test
void stateMatchesVoicechatFormat() throws IOException {
UUID id = UUID.randomUUID();
UUID group = UUID.randomUUID();
String name = "Änna [Discord]".repeat(10); // > 127 Bytes: VarInt mit zwei Bytes
DataInputStream in = new DataInputStream(new ByteArrayInputStream(VoicechatStates.state(id, name, group)));
assertFalse(in.readBoolean());
assertFalse(in.readBoolean());
assertEquals(id, new UUID(in.readLong(), in.readLong()));
int length = 0, shift = 0, b;
do {
b = in.readUnsignedByte();
length |= (b & 0x7F) << shift;
shift += 7;
} while ((b & 0x80) != 0);
byte[] utf = new byte[length];
in.readFully(utf);
assertEquals(name, new String(utf, StandardCharsets.UTF_8));
assertTrue(in.readBoolean());
assertEquals(group, new UUID(in.readLong(), in.readLong()));
assertEquals(0, in.available());
}
@Test
void removeIsJustTheUuid() throws IOException {
UUID id = UUID.randomUUID();
DataInputStream in = new DataInputStream(new ByteArrayInputStream(VoicechatStates.uuid(id)));
assertEquals(id, new UUID(in.readLong(), in.readLong()));
assertEquals(0, in.available());
}
}