From 3f65e95afdeb06433298bfd8038f1ae1c20b7210 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 24 Sep 2026 15:06:53 +0000 Subject: [PATCH] Fix channel reordering and keep the channel tree in sync - Sort a channel among its current siblings with channeledit channel_order, as the official client does; the server refuses channelmove within the same parent ("already member of channel"). - Relink siblings when a channel_order edit arrives: the server never re-announces the neighbours, so the tree drifted out of order after any reorder. - Apply password, permanence and max-clients flags from channel list, create and edit events, and let an emptied topic clear the old one. - Log moves like TS3: "..., new parent channel is X". - Play identical sounds that start within 250 ms only once, so bursts (a channel deleted with its subchannels) don't stack. Co-Authored-By: Claude Opus 5.5 --- .../ts3client/net/ConnectionEventHandler.java | 81 ++++++++++--------- .../ts3client/net/TeamspeakConnection.java | 19 ++++- .../com/ts3client/sound/SoundNotifier.java | 23 ++++++ 3 files changed, 83 insertions(+), 40 deletions(-) diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java index 10e0106..aa4f96d 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java @@ -359,13 +359,11 @@ final class ConnectionEventHandler implements TS3Listener { @Override public void onChannelCreate(ChannelCreateEvent e) { int cid = e.getChannelId(); - String name = e.get("channel_name"); int pid = safeInt(e, "cpid"); if (pid == 0) pid = safeInt(e, "pid"); int order = safeInt(e, "channel_order"); - ChannelNode node = conn.getModel().putChannel(cid, name, pid, order); - long icon = TeamspeakConnection.safeLong(e, "channel_icon_id"); - if (icon != 0) node.iconId = icon; + ChannelNode node = conn.getModel().putChannel(cid, e.get("channel_name"), pid, order); + applyChannelProperties(node, e); conn.getModel().relinkChannel(cid, pid, order); if (conn.isConnected()) { conn.sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER, @@ -390,22 +388,35 @@ final class ConnectionEventHandler implements TS3Listener { @Override public void onChannelEdit(ChannelEditedEvent e) { ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid")); - if (ch != null) { - if (has(e, "channel_name")) ch.name = e.get("channel_name"); - if (has(e, "channel_order")) ch.order = e.getInt("channel_order"); - if (has(e, "channel_icon_id")) ch.iconId = TeamspeakConnection.safeLong(e, "channel_icon_id"); - if (has(e, "channel_topic")) ch.topic = e.get("channel_topic"); - if (conn.isConnected()) { - boolean current = conn.inOwnChannel(ch.id); - conn.sound(current - ? byInvoker(e, SoundEvent.CHANNEL_EDITED_CURRENT_BY_YOU, - SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER, SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER) - : byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU, - SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER), - conn.channelVars(ch.id, e.get("invokername"))); - conn.log("Channel \"" + conn.channelLink(ch.id) + "\" was edited by " + invokerName(e)); - } - conn.ui.onModelChanged(); + if (ch == null) return; + applyChannelProperties(ch, e); + // Sorting among the same siblings arrives here too, and TS3 announces it as an edit. + if (has(e, "channel_order")) conn.getModel().relinkChannel(ch.id, ch.parentId, e.getInt("channel_order")); + if (conn.isConnected()) { + boolean current = conn.inOwnChannel(ch.id); + conn.sound(current + ? byInvoker(e, SoundEvent.CHANNEL_EDITED_CURRENT_BY_YOU, + SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER, SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER) + : byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU, + SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER), + conn.channelVars(ch.id, e.get("invokername"))); + conn.log("Channel \"" + conn.channelLink(ch.id) + "\" was edited by " + invokerName(e)); + } + conn.ui.onModelChanged(); + } + + /** The properties a channel event carries that the model keeps; each only when present. */ + private static void applyChannelProperties(ChannelNode ch, BaseEvent e) { + if (has(e, "channel_name")) ch.name = e.get("channel_name"); + // An emptied topic arrives as a bare key, which has() would skip. + if (e.getMap().containsKey("channel_topic")) ch.topic = TeamspeakConnection.orEmpty(e.get("channel_topic")); + if (has(e, "channel_icon_id")) ch.iconId = TeamspeakConnection.safeLong(e, "channel_icon_id"); + if (has(e, "channel_flag_password")) ch.hasPassword = e.getBoolean("channel_flag_password"); + if (has(e, "channel_flag_permanent")) ch.permanent = e.getBoolean("channel_flag_permanent"); + if (has(e, "channel_flag_maxclients_unlimited") && e.getBoolean("channel_flag_maxclients_unlimited")) { + ch.maxClients = -1; + } else if (has(e, "channel_maxclients")) { + ch.maxClients = safeInt(e, "channel_maxclients"); } } @@ -425,17 +436,18 @@ final class ConnectionEventHandler implements TS3Listener { @Override public void onChannelMoved(ChannelMovedEvent e) { ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid")); - if (ch != null) { - int parent = has(e, "cpid") ? e.getInt("cpid") : ch.parentId; - int order = has(e, "order") ? e.getInt("order") : ch.order; - conn.getModel().relinkChannel(ch.id, parent, order); - if (conn.isConnected()) { - conn.sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER, - SoundEvent.CHANNEL_MOVED_BY_OTHER), conn.channelVars(ch.id, e.get("invokername"))); - conn.log("Channel \"" + conn.channelLink(ch.id) + "\" was moved by " + invokerName(e)); - } - conn.ui.onModelChanged(); + if (ch == null) return; + int parent = has(e, "cpid") ? e.getInt("cpid") : ch.parentId; + int order = has(e, "order") ? e.getInt("order") : ch.order; + conn.getModel().relinkChannel(ch.id, parent, order); + if (conn.isConnected()) { + conn.sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER, + SoundEvent.CHANNEL_MOVED_BY_OTHER), conn.channelVars(ch.id, e.get("invokername"))); + String newParent = parent == 0 ? conn.getModel().getServerName() : conn.channelLink(parent); + conn.log("Channel \"" + conn.channelLink(ch.id) + "\" was moved by " + invokerName(e) + + ", new parent channel is \"" + newParent + "\""); } + conn.ui.onModelChanged(); } @Override @@ -537,12 +549,9 @@ final class ConnectionEventHandler implements TS3Listener { public void onChannelList(ChannelListEvent e) { // Incremental channel arriving during connect. int cid = e.getChannelId(); - String name = e.get("channel_name"); - int pid = safeInt(e, "cpid"); - int order = safeInt(e, "channel_order"); - ChannelNode node = conn.getModel().putChannel(cid, name, pid, order); - long icon = TeamspeakConnection.safeLong(e, "channel_icon_id"); - if (icon != 0) node.iconId = icon; + ChannelNode node = conn.getModel().putChannel(cid, e.get("channel_name"), + safeInt(e, "cpid"), safeInt(e, "channel_order")); + applyChannelProperties(node, e); } @Override diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index a3054a5..dfd191b 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -708,12 +708,23 @@ public final class TeamspeakConnection implements TS3Listener { * siblings, or 0 to place it first */ public void moveChannel(int channelId, int newParentId, int orderPredecessorId) { + ChannelNode channel = model.getChannel(channelId); + // The server refuses channelmove within the same parent ("already member of + // channel"); sorting among the same siblings is an edit of channel_order. + boolean reorder = channel != null && channel.parentId == newParentId; new Thread(() -> { try { - SingleCommand cmd = new SingleCommand("channelmove", ProtocolRole.CLIENT); - cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId))); - cmd.add(new CommandSingleParameter("cpid", Integer.toString(newParentId))); - cmd.add(new CommandSingleParameter("order", Integer.toString(orderPredecessorId))); + SingleCommand cmd; + if (reorder) { + cmd = new SingleCommand("channeledit", ProtocolRole.CLIENT); + cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId))); + cmd.add(new CommandSingleParameter("channel_order", Integer.toString(orderPredecessorId))); + } else { + cmd = new SingleCommand("channelmove", ProtocolRole.CLIENT); + cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId))); + cmd.add(new CommandSingleParameter("cpid", Integer.toString(newParentId))); + cmd.add(new CommandSingleParameter("order", Integer.toString(orderPredecessorId))); + } client.executeCommand(cmd).complete(); } catch (Exception e) { error("Could not move channel: " + rootMessage(e)); diff --git a/ts3-client/core/src/main/java/com/ts3client/sound/SoundNotifier.java b/ts3-client/core/src/main/java/com/ts3client/sound/SoundNotifier.java index ca8c4ed..83aa69a 100644 --- a/ts3-client/core/src/main/java/com/ts3client/sound/SoundNotifier.java +++ b/ts3-client/core/src/main/java/com/ts3client/sound/SoundNotifier.java @@ -14,17 +14,27 @@ import java.util.Map; *

An action is heard when the pack maps it, the user left it enabled and either * the speakers are on or the action is marked important — the muted case * is what the important flag exists for. + * + *

A sound that is already starting is not started again: one server action often + * arrives as a burst of events (a channel deleted with its subchannels, every tab on + * the same server hearing the same move), and stacking identical copies of a sound + * only makes it louder and garbled. */ public final class SoundNotifier { /** Contact class in a pack's {@code ${clientType}} placeholder: friend, neutral or blocked. */ private static final String DEFAULT_CLIENT_TYPE = "neutral"; + /** How close together two plays of the same sound count as one. */ + private static final long COALESCE_NANOS = 250_000_000L; + private final Settings settings; private volatile SoundPlayer player; private volatile SoundPack pack; private volatile List available = List.of(); private volatile boolean soundsMuted; + /** When each resolved sound last started, for {@link #COALESCE_NANOS}. */ + private final Map lastStarted = new HashMap<>(); public SoundNotifier(Settings settings) { this.settings = settings; @@ -115,6 +125,7 @@ public final class SoundNotifier { String resolved = script.resolve(withDefaults(variables)); double volume = Math.max(0, Math.min(1.0, settings.effectiveSoundVolume())); if (volume <= 0) return; + if (!claimStart(script.kind() + ":" + resolved)) return; if (script.kind() == SoundScript.Kind.SAY) { out.say(resolved, volume); @@ -124,6 +135,18 @@ public final class SoundNotifier { if (file != null) out.play(file, volume); } + /** @return false when the same sound already started within {@link #COALESCE_NANOS} */ + private boolean claimStart(String sound) { + long now = System.nanoTime(); + synchronized (lastStarted) { + Long last = lastStarted.get(sound); + if (last != null && now - last < COALESCE_NANOS) return false; + lastStarted.put(sound, now); + if (lastStarted.size() > 64) lastStarted.values().removeIf(t -> now - t >= COALESCE_NANOS); + } + return true; + } + private static Map withDefaults(Map variables) { Map vars = variables == null ? new HashMap<>() : new HashMap<>(variables); vars.putIfAbsent("clientType", DEFAULT_CLIENT_TYPE);