Log server activity, scrollable chat tabs, and move info panel to a chat tab

Native TS3 logs client joins/leaves/moves, group changes and channel edits
to the server tab; minor local-only notices (mic mute, tray) no longer
spam the log. Chat tabs scroll by mouse wheel like server tabs. The info
panel can now be moved into a persistent, generically-labelled chat tab
that tracks tree selection and collapses the panel's space entirely; a
tree rebuild no longer briefly fires a null selection that reset this.
Added "Find Client in Channel Tree" to the client context menu, and tree
selection now survives model rebuilds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 21:47:12 +00:00
parent 047c89404c
commit 71084ea309
9 changed files with 439 additions and 35 deletions

View File

@@ -33,4 +33,11 @@ public interface ConnectionListener {
/** Someone poked the local client. */
void onPoke(String fromName, String message);
/**
* Something happened on the server that native TS3 records in the server
* log: a client connecting/disconnecting/moving, a channel being edited,
* a group being (un)assigned, and so on.
*/
void onServerLog(String message);
}

View File

@@ -791,14 +791,23 @@ public final class TeamspeakConnection implements TS3Listener {
c.serverGroupIds = parseIntList(e.getClientServerGroups());
c.channelGroupId = e.getClientChannelGroupId();
c.self = (e.getClientId() == selfClientId);
if (e.getClientId() != selfClientId) announceClientEntered(e);
if (e.getClientId() != selfClientId) {
announceClientEntered(e);
logClientEntered(e);
}
ui.onModelChanged();
}
@Override
public void onClientLeave(ClientLeaveEvent e) {
if (e.getClientId() == selfClientId) announceOwnRemoval(safeInt(e, "reasonid"), e);
else announceClientLeft(e);
if (e.getClientId() == selfClientId) {
announceOwnRemoval(safeInt(e, "reasonid"), e);
} else {
ClientEntry leaving = model.getClient(e.getClientId());
String name = leaving != null ? leaving.nickname : "Client " + e.getClientId();
announceClientLeft(e);
logClientLeft(e, name);
}
model.removeClient(e.getClientId());
if (playback != null) playback.removeClient(e.getClientId());
ui.onModelChanged();
@@ -810,12 +819,84 @@ public final class TeamspeakConnection implements TS3Listener {
if (c != null) {
int from = c.channelId;
c.channelId = e.getTargetChannelId();
if (e.getClientId() == selfClientId) announceOwnMove(safeInt(e, "reasonid"), e);
else announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId());
if (e.getClientId() == selfClientId) {
announceOwnMove(safeInt(e, "reasonid"), e);
} else {
announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId());
logClientMoved(e, c.nickname, from, e.getTargetChannelId());
}
ui.onModelChanged();
}
}
/** A client became visible to us, logged the way native TS3's server tab does. */
private void logClientEntered(ClientJoinEvent e) {
String name = e.getClientNickname();
switch (safeInt(e, "reasonid")) {
case REASON_MOVED:
log(name + " appears, coming from channel \"" + channelName(e.getClientFromId()) + "\"");
break;
case REASON_CHANNEL_KICK:
log(name + " appears, was kicked from channel \"" + channelName(e.getClientFromId())
+ "\" by " + invokerName(e));
break;
case REASON_SWITCHED:
log(name + " switched to channel \"" + channelName(e.getClientTargetId())
+ "\", coming from channel \"" + channelName(e.getClientFromId()) + "\"");
break;
default:
log(name + " connected to channel \"" + channelName(e.getClientTargetId()) + "\"");
}
}
/** A client stopped being visible to us. */
private void logClientLeft(ClientLeaveEvent e, String name) {
String reasonMsg = orEmpty(e.get("reasonmsg"));
String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")";
switch (safeInt(e, "reasonid")) {
case REASON_TIMEOUT:
log(name + " dropped (ping timeout)");
break;
case REASON_SERVER_KICK:
log(name + " was kicked from the server by " + invokerName(e) + suffix);
break;
case REASON_BAN:
log(name + " was banned from the server by " + invokerName(e) + suffix);
break;
case REASON_CHANNEL_KICK:
log(name + " left: was kicked to channel \"" + channelName(e.getClientTargetId())
+ "\" by " + invokerName(e) + suffix);
break;
case REASON_MOVED:
log(name + " left, heading to channel \"" + channelName(e.getClientTargetId()) + "\"");
break;
case REASON_SWITCHED:
log(name + " left, switched to channel \"" + channelName(e.getClientTargetId()) + "\"");
break;
default:
log(name + " disconnected");
}
}
/** A client we can see moved between two channels we can see. */
private void logClientMoved(ClientMovedEvent e, String name, int fromChannel, int toChannel) {
String reasonMsg = orEmpty(e.get("reasonmsg"));
String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")";
switch (safeInt(e, "reasonid")) {
case REASON_MOVED:
log(name + " was moved from channel \"" + channelName(fromChannel) + "\" to \""
+ channelName(toChannel) + "\" by " + invokerName(e));
break;
case REASON_CHANNEL_KICK:
log(name + " was kicked from channel \"" + channelName(fromChannel) + "\" to \""
+ channelName(toChannel) + "\" by " + invokerName(e) + suffix);
break;
default:
log(name + " switched from channel \"" + channelName(fromChannel) + "\" to \""
+ channelName(toChannel) + "\"");
}
}
// ---- who went where: TeamSpeak's reason ids ----
/** {@code reasonid} of a client view/move notification. */
@@ -950,6 +1031,7 @@ public final class TeamspeakConnection implements TS3Listener {
ClientEntry c = model.getClient(e.getClientId());
if (c == null) return;
boolean renamed = has(e, "client_nickname") && !e.get("client_nickname").equals(c.nickname);
String oldName = c.nickname;
if (renamed) c.nickname = e.get("client_nickname");
if (has(e, "client_input_muted")) c.inputMuted = e.getBoolean("client_input_muted");
if (has(e, "client_output_muted")) c.outputMuted = e.getBoolean("client_output_muted");
@@ -966,12 +1048,12 @@ public final class TeamspeakConnection implements TS3Listener {
if (has(e, "client_talk_power")) c.talkPower = e.getInt("client_talk_power");
if (has(e, "client_is_channel_commander"))
c.channelCommander = e.getBoolean("client_is_channel_commander");
announceClientUpdate(e, c, renamed);
announceClientUpdate(e, c, renamed, oldName);
ui.onModelChanged();
}
/** Renames, talk-power changes and recording flags all arrive as a client update. */
private void announceClientUpdate(ClientUpdatedEvent e, ClientEntry c, boolean renamed) {
private void announceClientUpdate(ClientUpdatedEvent e, ClientEntry c, boolean renamed, String oldName) {
if (!connected) return;
boolean self = c.id == selfClientId;
Map<String, String> vars = clientVars(c.id, null);
@@ -979,6 +1061,7 @@ public final class TeamspeakConnection implements TS3Listener {
if (renamed) {
sound(safeInt(e, "invokerid") == selfClientId
? SoundEvent.CLIENT_RENAMED_BY_YOU : SoundEvent.CLIENT_RENAMED_BY_OTHER, vars);
log(oldName + " is now known as " + c.nickname);
}
if (safeInt(e, "client_talk_request") > 0 && !self) {
sound(SoundEvent.CLIENT_REQUESTED_TALK_POWER, vars);
@@ -1012,6 +1095,7 @@ public final class TeamspeakConnection implements TS3Listener {
if (connected) {
sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER,
SoundEvent.CHANNEL_CREATED_BY_OTHER), channelVars(cid, e.get("invokername")));
log("Channel \"" + name + "\" was created by " + invokerName(e));
}
ui.onModelChanged();
}
@@ -1022,6 +1106,7 @@ public final class TeamspeakConnection implements TS3Listener {
if (connected) {
sound(byInvoker(e, SoundEvent.CHANNEL_DELETED_BY_YOU, SoundEvent.CHANNEL_DELETED_BY_OTHER,
SoundEvent.CHANNEL_DELETED_BY_SERVER), channelVars(cid, e.get("invokername")));
log("Channel \"" + channelName(cid) + "\" was deleted by " + invokerName(e));
}
model.removeChannel(cid);
ui.onModelChanged();
@@ -1042,6 +1127,7 @@ public final class TeamspeakConnection implements TS3Listener {
: byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU,
SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER),
channelVars(ch.id, e.get("invokername")));
log("Channel \"" + ch.name + "\" was edited by " + invokerName(e));
}
ui.onModelChanged();
}
@@ -1057,6 +1143,7 @@ public final class TeamspeakConnection implements TS3Listener {
if (connected) {
sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER,
SoundEvent.CHANNEL_MOVED_BY_OTHER), channelVars(ch.id, e.get("invokername")));
log("Channel \"" + ch.name + "\" was moved by " + invokerName(e));
}
ui.onModelChanged();
}
@@ -1098,17 +1185,22 @@ public final class TeamspeakConnection implements TS3Listener {
: byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER,
SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_SERVER),
groupVars(e.getClientId(), e.getName()));
log(clientLogName(e.getClientId()) + " was added to server group \"" + e.getName()
+ "\" by " + invokerName(e) + ".");
}
@Override
public void onServerGroupClientDeleted(ServerGroupClientDeletedEvent e) {
boolean self = safeInt(e, "clid") == selfClientId;
int clientId = safeInt(e, "clid");
boolean self = clientId == selfClientId;
sound(self
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER,
SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_SERVER)
: byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER,
SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_SERVER),
groupVars(safeInt(e, "clid"), e.get("name")));
groupVars(clientId, e.get("name")));
log(clientLogName(clientId) + " was removed from server group \"" + orEmpty(e.get("name"))
+ "\" by " + invokerName(e) + ".");
}
@Override
@@ -1116,15 +1208,24 @@ public final class TeamspeakConnection implements TS3Listener {
ClientEntry c = model.getClient(e.getClientId());
if (c != null) c.channelGroupId = e.getChannelGroupId();
boolean self = e.getClientId() == selfClientId;
String groupName = model.channelGroupName(e.getChannelGroupId());
sound(self
? byInvoker(e, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER,
SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_SERVER)
: byInvoker(e, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER,
SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_SERVER),
groupVars(e.getClientId(), model.channelGroupName(e.getChannelGroupId())));
groupVars(e.getClientId(), groupName));
log("Channel group \"" + orEmpty(groupName) + "\" was assigned to " + clientLogName(e.getClientId())
+ " by " + invokerName(e) + ".");
ui.onModelChanged();
}
/** A client's nickname for a log line, falling back to its id once it has left. */
private String clientLogName(int clientId) {
ClientEntry c = model.getClient(clientId);
return c != null ? c.nickname : "Client " + clientId;
}
/** Picks the event variant matching who caused the change: us, another client, or the server. */
private SoundEvent byInvoker(BaseEvent e, SoundEvent byYou, SoundEvent byOther, SoundEvent byServer) {
int invoker = safeInt(e, "invokerid");
@@ -1583,6 +1684,21 @@ public final class TeamspeakConnection implements TS3Listener {
sounds.fire(event, deafened, variables);
}
/** Records a line in the server tab's log, the way native TS3 reports server activity. */
private void log(String message) {
if (connected) ui.onServerLog(message);
}
private String channelName(int channelId) {
ChannelNode ch = model.getChannel(channelId);
return ch != null ? ch.name : "channel #" + channelId;
}
private static String invokerName(BaseEvent e) {
String name = orEmpty(e.get("invokername"));
return name.isEmpty() ? "the server" : name;
}
/** The placeholder values a pack may reference for an action involving a client. */
private Map<String, String> clientVars(int clientId, String fallbackName) {
ClientEntry c = model.getClient(clientId);