Discord/TS-Teilnehmer als Mitglieder in der Voicechat-Gruppenanzeige (mit Sprech-Symbol), Actionbar entfernt
This commit is contained in:
1 parent
d5c181a224
commit
e2546ad982
17 files changed
+250
-150
No files matched your search
@@ -37,8 +37,6 @@ public final class Bridge {
|
|||||||
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;
|
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();
|
||||||
|
|
||||||
@@ -63,14 +61,6 @@ 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);
|
this.announcer = new Announcer(plugin, teamspeak, ticker);
|
||||||
roomInfos = buildInfos();
|
roomInfos = buildInfos();
|
||||||
}
|
}
|
||||||
@@ -82,7 +72,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);
|
ticker.scheduleAtFixedRate(this::sendParticipants, 2, 2, TimeUnit.SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void safeTick() {
|
private void safeTick() {
|
||||||
@@ -154,16 +144,27 @@ public final class Bridge {
|
|||||||
return roster;
|
return roster;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Actionbar im Spiel: wer aus Discord/TeamSpeak gerade spricht. */
|
/**
|
||||||
private void sendSpeakers() {
|
* Wer aus Discord/TeamSpeak im Raum ist, ans Plugin: Es trägt sie in die Gruppenanzeige des Voicechats ein.
|
||||||
|
* Alle 2 s komplett, damit ein verlorenes UDP-Paket nichts ausmacht.
|
||||||
|
*/
|
||||||
|
private void sendParticipants() {
|
||||||
try {
|
try {
|
||||||
for (Room room : rooms.values()) {
|
for (Room room : rooms.values()) {
|
||||||
if (!room.active()) continue;
|
DiscordRoomBot bot = discordBots.get(room.key());
|
||||||
List<String> speakers = room.externalSpeakers(SPEAKING_MILLIS);
|
List<Message.Member> members = new ArrayList<>();
|
||||||
if (!speakers.isEmpty()) plugin.sendSpeakers(room.key(), speakers);
|
if (teamspeak != null && teamspeak.ready()) {
|
||||||
|
teamspeak.participants(room).forEach((id, name) ->
|
||||||
|
members.add(new Message.Member(room.sourceUuid("ts:" + id), name + " [TeamSpeak]")));
|
||||||
|
}
|
||||||
|
if (bot != null && bot.ready()) {
|
||||||
|
bot.participants().forEach((id, name) ->
|
||||||
|
members.add(new Message.Member(room.sourceUuid("dc:" + id), name + " [Discord]")));
|
||||||
|
}
|
||||||
|
plugin.sendParticipants(room.key(), members);
|
||||||
}
|
}
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
LOG.warn("Sprecher-Anzeige fehlgeschlagen", e);
|
LOG.warn("Teilnehmer-Abgleich fehlgeschlagen", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ final class PluginLink {
|
|||||||
if (target != null && alive()) send(new Message.Notice(room, text), target);
|
if (target != null && alive()) send(new Message.Notice(room, text), target);
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendSpeakers(String room, List<String> names) {
|
void sendParticipants(String room, List<Message.Member> members) {
|
||||||
SocketAddress target = plugin;
|
SocketAddress target = plugin;
|
||||||
if (target != null && alive()) send(new Message.Speakers(room, names), target);
|
if (target != null && alive()) send(new Message.Participants(room, members), target);
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendAudio(String room, UUID source, byte[] opus) {
|
void sendAudio(String room, UUID source, byte[] opus) {
|
||||||
@@ -121,7 +121,7 @@ final class PluginLink {
|
|||||||
}
|
}
|
||||||
case Message.Rooms ignored -> {}
|
case Message.Rooms ignored -> {}
|
||||||
case Message.Notice ignored -> {}
|
case Message.Notice ignored -> {}
|
||||||
case Message.Speakers ignored -> {}
|
case Message.Participants ignored -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ 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;
|
||||||
|
|
||||||
@@ -36,9 +35,6 @@ 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) -> {};
|
||||||
|
|
||||||
@@ -59,13 +55,12 @@ 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) {
|
||||||
this.side = side;
|
this.side = side;
|
||||||
this.key = key;
|
this.key = key;
|
||||||
this.uuid = UUID.nameUUIDFromBytes(("voicebridge:" + config.key() + ":" + key).getBytes(StandardCharsets.UTF_8));
|
this.uuid = sourceUuid(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,15 +78,13 @@ 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|Kürzel". */
|
/**
|
||||||
public List<String> externalSpeakers(long millis) {
|
* Feste UUID eines Sprechers, z. B. {@code ts:5} oder {@code dc:1234}. Unter ihr läuft sein Audio im Spiel,
|
||||||
long since = System.currentTimeMillis() - millis;
|
* und unter ihr erscheint er auch in der Gruppenanzeige des Voicechats.
|
||||||
return sources.values().stream()
|
*/
|
||||||
.filter(s -> s.side != Side.MINECRAFT && s.lastSeen >= since)
|
public UUID sourceUuid(String key) {
|
||||||
.map(s -> s.name + "|" + (s.side == Side.DISCORD ? "DC" : "TS"))
|
return UUID.nameUUIDFromBytes(("voicebridge:" + config.key() + ":" + key).getBytes(StandardCharsets.UTF_8));
|
||||||
.sorted().toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String label(Side side) {
|
public static String label(Side side) {
|
||||||
@@ -132,7 +125,6 @@ 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 {
|
||||||
|
|||||||
@@ -152,13 +152,6 @@ public final class DiscordRoomBot implements AudioSendHandler, AudioReceiveHandl
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Anzeigename eines Users, für die Sprecher-Anzeige. */
|
|
||||||
public String name(long userId) {
|
|
||||||
Guild guild = this.guild;
|
|
||||||
Member member = guild == null ? null : guild.getMemberById(userId);
|
|
||||||
return member == null ? "?" : member.getEffectiveName();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ob der Bot eingeloggt ist und seinen Channel kennt. */
|
/** Ob der Bot eingeloggt ist und seinen Channel kennt. */
|
||||||
public boolean ready() {
|
public boolean ready() {
|
||||||
return guild != null && channelId != 0;
|
return guild != null && channelId != 0;
|
||||||
|
|||||||
@@ -89,12 +89,6 @@ public final class TeamspeakSide {
|
|||||||
return result;
|
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. */
|
/** Ob der Raum-Client gerade im Channel ist. */
|
||||||
public boolean roomConnected(Room room) {
|
public boolean roomConnected(Room room) {
|
||||||
TeamspeakRoomClient client = roomClients.get(room.key());
|
TeamspeakRoomClient client = roomClients.get(room.key());
|
||||||
|
|||||||
@@ -47,3 +47,14 @@ tasks.register('uploadPlugin') {
|
|||||||
}.result.get()
|
}.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()) {
|
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.syncExternals(plugin);
|
||||||
room.dropIdleSpeakers();
|
room.dropIdleSpeakers();
|
||||||
roster.put(room.key(), members.stream().map(c -> {
|
roster.put(room.key(), members.stream().map(c -> {
|
||||||
UUID id = c.getPlayer().getUuid();
|
UUID id = c.getPlayer().getUuid();
|
||||||
@@ -139,32 +140,16 @@ final class BridgeVoicechat implements VoicechatPlugin {
|
|||||||
.append(Component.text(notice.text(), NamedTextColor.GRAY));
|
.append(Component.text(notice.text(), NamedTextColor.GRAY));
|
||||||
room.memberPlayers().forEach(player -> player.sendMessage(text));
|
room.memberPlayers().forEach(player -> player.sendMessage(text));
|
||||||
});
|
});
|
||||||
case Message.Speakers speakers -> Bukkit.getScheduler().runTask(plugin, () -> {
|
case Message.Participants participants -> Bukkit.getScheduler().runTask(plugin, () -> {
|
||||||
CrosstalkRoom room = rooms.get(speakers.room());
|
CrosstalkRoom room = rooms.get(participants.room());
|
||||||
if (room == null || speakers.names().isEmpty() || !plugin.getConfig().getBoolean("actionbar", true)) return;
|
if (room == null) return;
|
||||||
Component text = speakersLine(speakers.names());
|
room.setExternals(participants.members());
|
||||||
room.memberPlayers().stream().filter(ActionbarCommand::enabled).forEach(player -> player.sendActionBar(text));
|
room.syncExternals(plugin);
|
||||||
});
|
});
|
||||||
case Message.Hello ignored -> {}
|
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. */
|
/** Main-Thread: Gruppen an die Räume der Bridge angleichen. */
|
||||||
private void syncRooms(List<Message.RoomInfo> infos) {
|
private void syncRooms(List<Message.RoomInfo> infos) {
|
||||||
VoicechatServerApi api = this.api;
|
VoicechatServerApi api = this.api;
|
||||||
@@ -209,6 +194,8 @@ final class BridgeVoicechat implements VoicechatPlugin {
|
|||||||
rooms.remove(room.key());
|
rooms.remove(room.key());
|
||||||
roomsByGroup.remove(room.groupId());
|
roomsByGroup.remove(room.groupId());
|
||||||
room.dropAllSpeakers();
|
room.dropAllSpeakers();
|
||||||
|
room.setExternals(List.of());
|
||||||
|
room.syncExternals(plugin);
|
||||||
if (api != null) api.removeGroup(room.groupId());
|
if (api != null) api.removeGroup(room.groupId());
|
||||||
plugin.getLogger().info("Crosstalk-Gruppe entfernt: " + room.name());
|
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.Bukkit;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -20,6 +25,7 @@ import java.util.UUID;
|
|||||||
*/
|
*/
|
||||||
final class CrosstalkRoom {
|
final class CrosstalkRoom {
|
||||||
private static final long SPEAKER_IDLE_MILLIS = 1000;
|
private static final long SPEAKER_IDLE_MILLIS = 1000;
|
||||||
|
private static final long FULL_SYNC_MILLIS = 30_000;
|
||||||
|
|
||||||
private final VoicechatServerApi api;
|
private final VoicechatServerApi api;
|
||||||
private final String key;
|
private final String key;
|
||||||
@@ -27,6 +33,11 @@ final class CrosstalkRoom {
|
|||||||
private final UUID groupId;
|
private final UUID groupId;
|
||||||
|
|
||||||
private final Map<UUID, Speaker> speakers = new HashMap<>();
|
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 List<VoicechatConnection> members = List.of();
|
||||||
private volatile Message.RoomInfo info;
|
private volatile Message.RoomInfo info;
|
||||||
|
|
||||||
@@ -50,6 +61,47 @@ final class CrosstalkRoom {
|
|||||||
return current != null && current.active();
|
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. */
|
/** Main-Thread: die Spieler in der Gruppe. */
|
||||||
synchronized List<Player> memberPlayers() {
|
synchronized List<Player> memberPlayers() {
|
||||||
return members.stream().map(c -> Bukkit.getPlayer(c.getPlayer().getUuid())).filter(Objects::nonNull).toList();
|
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(
|
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);
|
VoicechatStates.register(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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,3 @@
|
|||||||
bridge:
|
bridge:
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
port: 24470
|
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
|
|
||||||
@@ -6,12 +6,3 @@ 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
|
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,9 @@ public sealed interface Message {
|
|||||||
/** Bridge → Plugin: Chat-Hinweis an alle in der Gruppe, z. B. wer dazugekommen ist. */
|
/** Bridge → Plugin: Chat-Hinweis an alle in der Gruppe, z. B. wer dazugekommen ist. */
|
||||||
record Notice(String room, String text) implements Message {}
|
record Notice(String room, String text) implements Message {}
|
||||||
|
|
||||||
/** Bridge → Plugin: wer aus Discord/TeamSpeak gerade spricht (für die Actionbar), je "Name|Kürzel". */
|
/**
|
||||||
record Speakers(String room, List<String> names) implements Message {}
|
* Bridge → Plugin: wer aus Discord/TeamSpeak im Raum ist. {@code id} ist dieselbe UUID wie die
|
||||||
|
* {@code source} seines Audios, damit der Voicechat-Client das Sprech-Symbol zuordnen kann.
|
||||||
|
*/
|
||||||
|
record Participants(String room, List<Member> members) implements Message {}
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ 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 = 2;
|
public static final int VERSION = 3;
|
||||||
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;
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ public final class Protocol {
|
|||||||
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 NOTICE = 4;
|
||||||
private static final byte SPEAKERS = 5;
|
private static final byte PARTICIPANTS = 5;
|
||||||
|
|
||||||
private Protocol() {}
|
private Protocol() {}
|
||||||
|
|
||||||
@@ -66,11 +66,14 @@ public final class Protocol {
|
|||||||
out.writeUTF(notice.room());
|
out.writeUTF(notice.room());
|
||||||
out.writeUTF(notice.text());
|
out.writeUTF(notice.text());
|
||||||
}
|
}
|
||||||
case Message.Speakers speakers -> {
|
case Message.Participants participants -> {
|
||||||
out.writeByte(SPEAKERS);
|
out.writeByte(PARTICIPANTS);
|
||||||
out.writeUTF(speakers.room());
|
out.writeUTF(participants.room());
|
||||||
out.writeShort(speakers.names().size());
|
out.writeShort(participants.members().size());
|
||||||
for (String name : speakers.names()) out.writeUTF(name);
|
for (Message.Member member : participants.members()) {
|
||||||
|
writeUuid(out, member.id());
|
||||||
|
out.writeUTF(member.name());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
@@ -107,12 +110,12 @@ public final class Protocol {
|
|||||||
}
|
}
|
||||||
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 NOTICE -> new Message.Notice(in.readUTF(), in.readUTF());
|
||||||
case SPEAKERS -> {
|
case PARTICIPANTS -> {
|
||||||
String room = in.readUTF();
|
String room = in.readUTF();
|
||||||
int size = in.readUnsignedShort();
|
int size = in.readUnsignedShort();
|
||||||
List<String> names = new ArrayList<>(size);
|
List<Message.Member> members = new ArrayList<>(size);
|
||||||
for (int i = 0; i < size; i++) names.add(in.readUTF());
|
for (int i = 0; i < size; i++) members.add(new Message.Member(readUuid(in), in.readUTF()));
|
||||||
yield new Message.Speakers(room, names);
|
yield new Message.Participants(room, members);
|
||||||
}
|
}
|
||||||
default -> throw new IOException("Unbekannter Nachrichtentyp " + type);
|
default -> throw new IOException("Unbekannter Nachrichtentyp " + type);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,11 +41,12 @@ class ProtocolTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void noticeAndSpeakers() throws IOException {
|
void noticeAndParticipants() throws IOException {
|
||||||
Message.Notice notice = new Message.Notice("crosstalk-1", "Anna (Discord) ist dazugekommen.");
|
Message.Notice notice = new Message.Notice("crosstalk-1", "Anna (Discord) ist dazugekommen.");
|
||||||
assertEquals(notice, roundTrip(notice));
|
assertEquals(notice, roundTrip(notice));
|
||||||
Message.Speakers speakers = new Message.Speakers("crosstalk-1", List.of("Anna (Discord)", "Bob (TeamSpeak)"));
|
Message.Participants participants = new Message.Participants("crosstalk-1",
|
||||||
assertEquals(speakers, roundTrip(speakers));
|
List.of(new Message.Member(UUID.randomUUID(), "Anna [Discord]")));
|
||||||
|
assertEquals(participants, roundTrip(participants));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in new issue
Block a user