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
|
@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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user