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:
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
* the speakers are on or the action is marked <em>important</em> — the muted case
|
||||
* 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 {
|
||||
|
||||
/** 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<SoundPack> available = List.of();
|
||||
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) {
|
||||
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<String, String> withDefaults(Map<String, String> variables) {
|
||||
Map<String, String> vars = variables == null ? new HashMap<>() : new HashMap<>(variables);
|
||||
vars.putIfAbsent("clientType", DEFAULT_CLIENT_TYPE);
|
||||
|
||||
Reference in New Issue
Block a user