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 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 15:06:53 +00:00
parent 4de3ad4cab
commit 3f65e95afd
3 changed files with 83 additions and 40 deletions

View File

@@ -359,13 +359,11 @@ final class ConnectionEventHandler implements TS3Listener {
@Override @Override
public void onChannelCreate(ChannelCreateEvent e) { public void onChannelCreate(ChannelCreateEvent e) {
int cid = e.getChannelId(); int cid = e.getChannelId();
String name = e.get("channel_name");
int pid = safeInt(e, "cpid"); int pid = safeInt(e, "cpid");
if (pid == 0) pid = safeInt(e, "pid"); if (pid == 0) pid = safeInt(e, "pid");
int order = safeInt(e, "channel_order"); int order = safeInt(e, "channel_order");
ChannelNode node = conn.getModel().putChannel(cid, name, pid, order); ChannelNode node = conn.getModel().putChannel(cid, e.get("channel_name"), pid, order);
long icon = TeamspeakConnection.safeLong(e, "channel_icon_id"); applyChannelProperties(node, e);
if (icon != 0) node.iconId = icon;
conn.getModel().relinkChannel(cid, pid, order); conn.getModel().relinkChannel(cid, pid, order);
if (conn.isConnected()) { if (conn.isConnected()) {
conn.sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER, conn.sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER,
@@ -390,11 +388,10 @@ final class ConnectionEventHandler implements TS3Listener {
@Override @Override
public void onChannelEdit(ChannelEditedEvent e) { public void onChannelEdit(ChannelEditedEvent e) {
ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid")); ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid"));
if (ch != null) { if (ch == null) return;
if (has(e, "channel_name")) ch.name = e.get("channel_name"); applyChannelProperties(ch, e);
if (has(e, "channel_order")) ch.order = e.getInt("channel_order"); // Sorting among the same siblings arrives here too, and TS3 announces it as an edit.
if (has(e, "channel_icon_id")) ch.iconId = TeamspeakConnection.safeLong(e, "channel_icon_id"); if (has(e, "channel_order")) conn.getModel().relinkChannel(ch.id, ch.parentId, e.getInt("channel_order"));
if (has(e, "channel_topic")) ch.topic = e.get("channel_topic");
if (conn.isConnected()) { if (conn.isConnected()) {
boolean current = conn.inOwnChannel(ch.id); boolean current = conn.inOwnChannel(ch.id);
conn.sound(current conn.sound(current
@@ -407,6 +404,20 @@ final class ConnectionEventHandler implements TS3Listener {
} }
conn.ui.onModelChanged(); 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,18 +436,19 @@ final class ConnectionEventHandler implements TS3Listener {
@Override @Override
public void onChannelMoved(ChannelMovedEvent e) { public void onChannelMoved(ChannelMovedEvent e) {
ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid")); ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid"));
if (ch != null) { if (ch == null) return;
int parent = has(e, "cpid") ? e.getInt("cpid") : ch.parentId; int parent = has(e, "cpid") ? e.getInt("cpid") : ch.parentId;
int order = has(e, "order") ? e.getInt("order") : ch.order; int order = has(e, "order") ? e.getInt("order") : ch.order;
conn.getModel().relinkChannel(ch.id, parent, order); conn.getModel().relinkChannel(ch.id, parent, order);
if (conn.isConnected()) { if (conn.isConnected()) {
conn.sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER, 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"))); SoundEvent.CHANNEL_MOVED_BY_OTHER), conn.channelVars(ch.id, e.get("invokername")));
conn.log("Channel \"" + conn.channelLink(ch.id) + "\" was moved by " + invokerName(e)); 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(); conn.ui.onModelChanged();
} }
}
@Override @Override
public void onChannelSubscribed(ChannelSubscribedEvent e) { public void onChannelSubscribed(ChannelSubscribedEvent e) {
@@ -537,12 +549,9 @@ final class ConnectionEventHandler implements TS3Listener {
public void onChannelList(ChannelListEvent e) { public void onChannelList(ChannelListEvent e) {
// Incremental channel arriving during connect. // Incremental channel arriving during connect.
int cid = e.getChannelId(); int cid = e.getChannelId();
String name = e.get("channel_name"); ChannelNode node = conn.getModel().putChannel(cid, e.get("channel_name"),
int pid = safeInt(e, "cpid"); safeInt(e, "cpid"), safeInt(e, "channel_order"));
int order = safeInt(e, "channel_order"); applyChannelProperties(node, e);
ChannelNode node = conn.getModel().putChannel(cid, name, pid, order);
long icon = TeamspeakConnection.safeLong(e, "channel_icon_id");
if (icon != 0) node.iconId = icon;
} }
@Override @Override

View File

@@ -708,12 +708,23 @@ public final class TeamspeakConnection implements TS3Listener {
* siblings, or 0 to place it first * siblings, or 0 to place it first
*/ */
public void moveChannel(int channelId, int newParentId, int orderPredecessorId) { 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(() -> { new Thread(() -> {
try { try {
SingleCommand cmd = new SingleCommand("channelmove", ProtocolRole.CLIENT); 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("cid", Integer.toString(channelId)));
cmd.add(new CommandSingleParameter("cpid", Integer.toString(newParentId))); cmd.add(new CommandSingleParameter("cpid", Integer.toString(newParentId)));
cmd.add(new CommandSingleParameter("order", Integer.toString(orderPredecessorId))); cmd.add(new CommandSingleParameter("order", Integer.toString(orderPredecessorId)));
}
client.executeCommand(cmd).complete(); client.executeCommand(cmd).complete();
} catch (Exception e) { } catch (Exception e) {
error("Could not move channel: " + rootMessage(e)); error("Could not move channel: " + rootMessage(e));

View File

@@ -14,17 +14,27 @@ import java.util.Map;
* <p>An action is heard when the pack maps it, the user left it enabled and either * <p>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 <em>important</em> — the muted case * the speakers are on or the action is marked <em>important</em> — the muted case
* is what the important flag exists for. * is what the important flag exists for.
*
* <p>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 { public final class SoundNotifier {
/** Contact class in a pack's {@code ${clientType}} placeholder: friend, neutral or blocked. */ /** Contact class in a pack's {@code ${clientType}} placeholder: friend, neutral or blocked. */
private static final String DEFAULT_CLIENT_TYPE = "neutral"; 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 final Settings settings;
private volatile SoundPlayer player; private volatile SoundPlayer player;
private volatile SoundPack pack; private volatile SoundPack pack;
private volatile List<SoundPack> available = List.of(); private volatile List<SoundPack> available = List.of();
private volatile boolean soundsMuted; private volatile boolean soundsMuted;
/** When each resolved sound last started, for {@link #COALESCE_NANOS}. */
private final Map<String, Long> lastStarted = new HashMap<>();
public SoundNotifier(Settings settings) { public SoundNotifier(Settings settings) {
this.settings = settings; this.settings = settings;
@@ -115,6 +125,7 @@ public final class SoundNotifier {
String resolved = script.resolve(withDefaults(variables)); String resolved = script.resolve(withDefaults(variables));
double volume = Math.max(0, Math.min(1.0, settings.effectiveSoundVolume())); double volume = Math.max(0, Math.min(1.0, settings.effectiveSoundVolume()));
if (volume <= 0) return; if (volume <= 0) return;
if (!claimStart(script.kind() + ":" + resolved)) return;
if (script.kind() == SoundScript.Kind.SAY) { if (script.kind() == SoundScript.Kind.SAY) {
out.say(resolved, volume); out.say(resolved, volume);
@@ -124,6 +135,18 @@ public final class SoundNotifier {
if (file != null) out.play(file, volume); 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<String, String> withDefaults(Map<String, String> variables) { private static Map<String, String> withDefaults(Map<String, String> variables) {
Map<String, String> vars = variables == null ? new HashMap<>() : new HashMap<>(variables); Map<String, String> vars = variables == null ? new HashMap<>() : new HashMap<>(variables);
vars.putIfAbsent("clientType", DEFAULT_CLIENT_TYPE); vars.putIfAbsent("clientType", DEFAULT_CLIENT_TYPE);