Split TeamspeakConnection into focused collaborator classes
Extract notify-event handling and the native-style server log/sound announcements into ConnectionEventHandler, and getconnectioninfo request/response + stats-snapshot building into ConnectionStatsCollector. TeamspeakConnection remains the public facade (1828 -> 1037 lines); its external API is unchanged. Pure refactor, no protocol/timing changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,590 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import com.github.manevolent.ts3j.event.*;
|
||||
import com.ts3client.sound.SoundEvent;
|
||||
import com.ts3client.sound.SoundNotifier;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The {@link TS3Listener} side of a connection: keeps {@link ServerModel} in sync with
|
||||
* incoming notify events, mirrors native TeamSpeak's server-tab log lines, and picks the
|
||||
* matching {@link SoundEvent} for each one. Registered alongside {@link TeamspeakConnection}
|
||||
* itself (which keeps only the lifecycle-critical {@code onDisconnected} callback) so this
|
||||
* class can stay focused on "event arrived, update state and tell the user" and nothing else.
|
||||
*/
|
||||
final class ConnectionEventHandler implements TS3Listener {
|
||||
|
||||
// ---- who went where: TeamSpeak's reason ids ----
|
||||
|
||||
/** {@code reasonid} of a client view/move notification. */
|
||||
private static final int REASON_SWITCHED = 0;
|
||||
private static final int REASON_MOVED = 1;
|
||||
private static final int REASON_TIMEOUT = 3;
|
||||
private static final int REASON_CHANNEL_KICK = 4;
|
||||
private static final int REASON_SERVER_KICK = 5;
|
||||
private static final int REASON_BAN = 6;
|
||||
|
||||
private final TeamspeakConnection conn;
|
||||
|
||||
ConnectionEventHandler(TeamspeakConnection conn) {
|
||||
this.conn = conn;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientJoin(ClientJoinEvent e) {
|
||||
ClientEntry c = conn.getModel().putClient(e.getClientId(), e.getClientNickname(), e.getClientTargetId());
|
||||
c.type = safeInt(e, "client_type");
|
||||
c.talkPower = e.getClientTalkPower();
|
||||
c.inputMuted = e.isClientInputMuted();
|
||||
c.outputMuted = e.isClientOutputMuted();
|
||||
c.inputHardware = e.isClientUsingHardwareInput();
|
||||
c.outputHardware = e.isClientUsingHardwareOutput();
|
||||
c.away = e.isClientAway();
|
||||
c.awayMessage = TeamspeakConnection.orEmpty(e.get("client_away_message"));
|
||||
c.uniqueId = TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier());
|
||||
c.serverGroupIds = parseIntList(e.getClientServerGroups());
|
||||
c.channelGroupId = e.getClientChannelGroupId();
|
||||
c.self = (e.getClientId() == conn.getSelfClientId());
|
||||
if (e.getClientId() != conn.getSelfClientId()) {
|
||||
announceClientEntered(e);
|
||||
logClientEntered(e);
|
||||
}
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientLeave(ClientLeaveEvent e) {
|
||||
if (e.getClientId() == conn.getSelfClientId()) {
|
||||
announceOwnRemoval(safeInt(e, "reasonid"), e);
|
||||
} else {
|
||||
ClientEntry leaving = conn.getModel().getClient(e.getClientId());
|
||||
String name = leaving != null ? leaving.nickname : "Client " + e.getClientId();
|
||||
announceClientLeft(e);
|
||||
logClientLeft(e, name);
|
||||
}
|
||||
conn.getModel().removeClient(e.getClientId());
|
||||
if (conn.getPlayback() != null) conn.getPlayback().removeClient(e.getClientId());
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientMoved(ClientMovedEvent e) {
|
||||
ClientEntry c = conn.getModel().getClient(e.getClientId());
|
||||
if (c != null) {
|
||||
int from = c.channelId;
|
||||
c.channelId = e.getTargetChannelId();
|
||||
if (e.getClientId() == conn.getSelfClientId()) {
|
||||
announceOwnMove(safeInt(e, "reasonid"), e);
|
||||
} else {
|
||||
announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId());
|
||||
logClientMoved(e, c.nickname, from, e.getTargetChannelId());
|
||||
}
|
||||
conn.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:
|
||||
conn.log(name + " appears, coming from channel \"" + conn.channelName(e.getClientFromId()) + "\"");
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
conn.log(name + " appears, was kicked from channel \"" + conn.channelName(e.getClientFromId())
|
||||
+ "\" by " + invokerName(e));
|
||||
break;
|
||||
case REASON_SWITCHED:
|
||||
conn.log(name + " switched to channel \"" + conn.channelName(e.getClientTargetId())
|
||||
+ "\", coming from channel \"" + conn.channelName(e.getClientFromId()) + "\"");
|
||||
break;
|
||||
default:
|
||||
conn.log(name + " connected to channel \"" + conn.channelName(e.getClientTargetId()) + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
/** A client stopped being visible to us. */
|
||||
private void logClientLeft(ClientLeaveEvent e, String name) {
|
||||
String reasonMsg = TeamspeakConnection.orEmpty(e.get("reasonmsg"));
|
||||
String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")";
|
||||
switch (safeInt(e, "reasonid")) {
|
||||
case REASON_TIMEOUT:
|
||||
conn.log(name + " dropped (ping timeout)");
|
||||
break;
|
||||
case REASON_SERVER_KICK:
|
||||
conn.log(name + " was kicked from the server by " + invokerName(e) + suffix);
|
||||
break;
|
||||
case REASON_BAN:
|
||||
conn.log(name + " was banned from the server by " + invokerName(e) + suffix);
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
conn.log(name + " left: was kicked to channel \"" + conn.channelName(e.getClientTargetId())
|
||||
+ "\" by " + invokerName(e) + suffix);
|
||||
break;
|
||||
case REASON_MOVED:
|
||||
conn.log(name + " left, heading to channel \"" + conn.channelName(e.getClientTargetId()) + "\"");
|
||||
break;
|
||||
case REASON_SWITCHED:
|
||||
conn.log(name + " left, switched to channel \"" + conn.channelName(e.getClientTargetId()) + "\"");
|
||||
break;
|
||||
default:
|
||||
conn.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 = TeamspeakConnection.orEmpty(e.get("reasonmsg"));
|
||||
String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")";
|
||||
switch (safeInt(e, "reasonid")) {
|
||||
case REASON_MOVED:
|
||||
conn.log(name + " was moved from channel \"" + conn.channelName(fromChannel) + "\" to \""
|
||||
+ conn.channelName(toChannel) + "\" by " + invokerName(e));
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
conn.log(name + " was kicked from channel \"" + conn.channelName(fromChannel) + "\" to \""
|
||||
+ conn.channelName(toChannel) + "\" by " + invokerName(e) + suffix);
|
||||
break;
|
||||
default:
|
||||
conn.log(name + " switched from channel \"" + conn.channelName(fromChannel) + "\" to \""
|
||||
+ conn.channelName(toChannel) + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A client became visible: either they just connected, or they moved in from a
|
||||
* channel we could not see — TeamSpeak's "appears" case.
|
||||
*/
|
||||
private void announceClientEntered(ClientJoinEvent e) {
|
||||
if (!conn.isConnected()) return;
|
||||
int clientId = e.getClientId();
|
||||
boolean current = conn.inOwnChannel(e.getClientTargetId());
|
||||
Map<String, String> vars = conn.clientVars(clientId, e.getClientNickname());
|
||||
switch (safeInt(e, "reasonid")) {
|
||||
case REASON_MOVED:
|
||||
conn.sound(current ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_APPEARS
|
||||
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_APPEARS, vars);
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
conn.sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_APPEARS
|
||||
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_APPEARS, vars);
|
||||
break;
|
||||
case REASON_SWITCHED:
|
||||
conn.sound(current ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_APPEARS
|
||||
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_APPEARS, vars);
|
||||
break;
|
||||
default:
|
||||
conn.sound(current ? SoundEvent.CLIENT_CONNECTION_CONNECTED_CURRENT_CHANNEL
|
||||
: SoundEvent.CLIENT_CONNECTION_CONNECTED_SERVER, vars);
|
||||
}
|
||||
}
|
||||
|
||||
/** A client stopped being visible: they left the server, or moved out of sight. */
|
||||
private void announceClientLeft(ClientLeaveEvent e) {
|
||||
if (!conn.isConnected()) return;
|
||||
int clientId = e.getClientId();
|
||||
boolean current = conn.inOwnChannel(e.getClientFromId());
|
||||
Map<String, String> vars = conn.clientVars(clientId, null);
|
||||
switch (safeInt(e, "reasonid")) {
|
||||
case REASON_TIMEOUT:
|
||||
conn.sound(current ? SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_CURRENT_CHANNEL
|
||||
: SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_SERVER, vars);
|
||||
break;
|
||||
case REASON_SERVER_KICK:
|
||||
conn.sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_CURRENT_CHANNEL
|
||||
: SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_SERVER, vars);
|
||||
break;
|
||||
case REASON_BAN:
|
||||
conn.sound(current ? SoundEvent.CLIENT_WAS_BANNED_CURRENT_CHANNEL
|
||||
: SoundEvent.CLIENT_WAS_BANNED_SERVER, vars);
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
conn.sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_DISAPPEARS
|
||||
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_DISAPPEARS, vars);
|
||||
break;
|
||||
case REASON_MOVED:
|
||||
conn.sound(current ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_DISAPPEARS
|
||||
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_DISAPPEARS, vars);
|
||||
break;
|
||||
case REASON_SWITCHED:
|
||||
conn.sound(current ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_DISAPPEARS
|
||||
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_DISAPPEARS, vars);
|
||||
break;
|
||||
default:
|
||||
conn.sound(current ? SoundEvent.CLIENT_CONNECTION_DISCONNECTED_CURRENT_CHANNEL
|
||||
: SoundEvent.CLIENT_CONNECTION_DISCONNECTED_SERVER, vars);
|
||||
}
|
||||
}
|
||||
|
||||
/** A client we can see moved between two channels we can see ("stays"). */
|
||||
private void announceClientMoved(int reason, int clientId, int fromChannel, int toChannel) {
|
||||
if (!conn.isConnected()) return;
|
||||
boolean toCurrent = conn.inOwnChannel(toChannel);
|
||||
boolean fromCurrent = conn.inOwnChannel(fromChannel);
|
||||
Map<String, String> vars = conn.clientVars(clientId, null);
|
||||
switch (reason) {
|
||||
case REASON_MOVED:
|
||||
conn.sound(toCurrent ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS
|
||||
: fromCurrent ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_STAYS
|
||||
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_STAYS, vars);
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
conn.sound(toCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_STAYS
|
||||
: fromCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_STAYS
|
||||
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_STAYS, vars);
|
||||
break;
|
||||
default:
|
||||
conn.sound(toCurrent ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_STAYS
|
||||
: fromCurrent ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_STAYS
|
||||
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_STAYS, vars);
|
||||
}
|
||||
}
|
||||
|
||||
/** We changed channel ourselves, or somebody changed it for us. */
|
||||
private void announceOwnMove(int reason, ClientMovedEvent e) {
|
||||
if (!conn.isConnected()) return;
|
||||
Map<String, String> vars = conn.channelVars(e.getTargetChannelId(), e.get("invokername"));
|
||||
switch (reason) {
|
||||
case REASON_MOVED:
|
||||
conn.sound(SoundEvent.YOU_WERE_MOVED_TO_DIFFERENT_CHANNEL, vars);
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
conn.sound(SoundEvent.YOU_WERE_KICKED_FROM_CHANNEL, vars);
|
||||
break;
|
||||
default:
|
||||
conn.sound(SoundEvent.YOU_SWITCHED_CHANNEL, vars);
|
||||
}
|
||||
}
|
||||
|
||||
/** We were removed from the server (kick or ban); the disconnect follows. */
|
||||
private void announceOwnRemoval(int reason, ClientLeaveEvent e) {
|
||||
if (!conn.isConnected()) return;
|
||||
Map<String, String> vars = SoundNotifier.vars(
|
||||
"servername", conn.getModel().getServerName(),
|
||||
"clientname", TeamspeakConnection.orEmpty(e.get("invokername")),
|
||||
"reason", TeamspeakConnection.orEmpty(e.get("reasonmsg")));
|
||||
if (reason == REASON_SERVER_KICK) {
|
||||
conn.sound(SoundEvent.YOU_WERE_KICKED_FROM_SERVER, vars);
|
||||
} else if (reason == REASON_BAN) {
|
||||
conn.sound(SoundEvent.YOU_WERE_BANNED, vars);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientChanged(ClientUpdatedEvent e) {
|
||||
ClientEntry c = conn.getModel().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");
|
||||
if (has(e, "client_input_hardware")) c.inputHardware = e.getBoolean("client_input_hardware");
|
||||
if (has(e, "client_output_hardware")) c.outputHardware = e.getBoolean("client_output_hardware");
|
||||
if (has(e, "client_away")) {
|
||||
c.away = e.getBoolean("client_away");
|
||||
// Both fields travel together, so an absent message here means "no message"
|
||||
// — which `has` cannot tell from "not reported".
|
||||
c.awayMessage = c.away ? TeamspeakConnection.orEmpty(e.get("client_away_message")) : "";
|
||||
} else if (has(e, "client_away_message")) {
|
||||
c.awayMessage = e.get("client_away_message");
|
||||
}
|
||||
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, oldName);
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
/** Renames, talk-power changes and recording flags all arrive as a client update. */
|
||||
private void announceClientUpdate(ClientUpdatedEvent e, ClientEntry c, boolean renamed, String oldName) {
|
||||
if (!conn.isConnected()) return;
|
||||
boolean self = c.id == conn.getSelfClientId();
|
||||
Map<String, String> vars = conn.clientVars(c.id, null);
|
||||
|
||||
if (renamed) {
|
||||
conn.sound(safeInt(e, "invokerid") == conn.getSelfClientId()
|
||||
? SoundEvent.CLIENT_RENAMED_BY_YOU : SoundEvent.CLIENT_RENAMED_BY_OTHER, vars);
|
||||
conn.log(oldName + " is now known as " + c.nickname);
|
||||
}
|
||||
if (safeInt(e, "client_talk_request") > 0 && !self) {
|
||||
conn.sound(SoundEvent.CLIENT_REQUESTED_TALK_POWER, vars);
|
||||
}
|
||||
if (self && has(e, "client_is_talker")) {
|
||||
conn.sound(e.getBoolean("client_is_talker")
|
||||
? SoundEvent.YOU_WERE_GRANTED_TALK_POWER : SoundEvent.YOU_WERE_REVOKED_TALK_POWER, vars);
|
||||
}
|
||||
if (!self && has(e, "client_is_recording")) {
|
||||
boolean recording = e.getBoolean("client_is_recording");
|
||||
if (!recording) {
|
||||
conn.sound(SoundEvent.CLIENT_RECORDING_STOP, vars);
|
||||
} else {
|
||||
conn.sound(conn.inOwnChannel(c.channelId)
|
||||
? SoundEvent.CLIENT_RECORDING_IN_CHANNEL : SoundEvent.CLIENT_RECORDING_START, vars);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
conn.getModel().relinkChannel(cid, pid, order);
|
||||
if (conn.isConnected()) {
|
||||
conn.sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER,
|
||||
SoundEvent.CHANNEL_CREATED_BY_OTHER), conn.channelVars(cid, e.get("invokername")));
|
||||
conn.log("Channel \"" + name + "\" was created by " + invokerName(e));
|
||||
}
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelDeleted(ChannelDeletedEvent e) {
|
||||
int cid = e.getChannelId();
|
||||
if (conn.isConnected()) {
|
||||
conn.sound(byInvoker(e, SoundEvent.CHANNEL_DELETED_BY_YOU, SoundEvent.CHANNEL_DELETED_BY_OTHER,
|
||||
SoundEvent.CHANNEL_DELETED_BY_SERVER), conn.channelVars(cid, e.get("invokername")));
|
||||
conn.log("Channel \"" + conn.channelName(cid) + "\" was deleted by " + invokerName(e));
|
||||
}
|
||||
conn.getModel().removeChannel(cid);
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
@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 (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 \"" + ch.name + "\" was edited by " + invokerName(e));
|
||||
}
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@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 \"" + ch.name + "\" was moved by " + invokerName(e));
|
||||
}
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelSubscribed(ChannelSubscribedEvent e) {
|
||||
setSubscribed(safeInt(e, "cid"), true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelUnsubscribed(ChannelUnsubscribedEvent e) {
|
||||
setSubscribed(safeInt(e, "cid"), false);
|
||||
}
|
||||
|
||||
private void setSubscribed(int cid, boolean subscribed) {
|
||||
ChannelNode ch = conn.getModel().getChannel(cid);
|
||||
if (ch == null || ch.subscribed == subscribed) return;
|
||||
ch.subscribed = subscribed;
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerEdit(ServerEditedEvent e) {
|
||||
if (has(e, "virtualserver_name")) conn.getModel().setServerName(e.get("virtualserver_name"));
|
||||
if (conn.isConnected()) {
|
||||
conn.sound(byInvoker(e, SoundEvent.SERVER_EDITED_BY_YOU, SoundEvent.SERVER_EDITED_BY_OTHER,
|
||||
SoundEvent.SERVER_EDITED_BY_OTHER), conn.serverVars());
|
||||
}
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerGroupClientAdded(ServerGroupClientAddedEvent e) {
|
||||
boolean self = e.getClientId() == conn.getSelfClientId();
|
||||
conn.sound(self
|
||||
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER,
|
||||
SoundEvent.YOU_SERVERGROUP_ADDED_BY_SERVER)
|
||||
: 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()));
|
||||
conn.log(clientLogName(e.getClientId()) + " was added to server group \"" + e.getName()
|
||||
+ "\" by " + invokerName(e) + ".");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerGroupClientDeleted(ServerGroupClientDeletedEvent e) {
|
||||
int clientId = safeInt(e, "clid");
|
||||
boolean self = clientId == conn.getSelfClientId();
|
||||
conn.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(clientId, e.get("name")));
|
||||
conn.log(clientLogName(clientId) + " was removed from server group \"" + TeamspeakConnection.orEmpty(e.get("name"))
|
||||
+ "\" by " + invokerName(e) + ".");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientChannelGroupChanged(ClientChannelGroupChangedEvent e) {
|
||||
ClientEntry c = conn.getModel().getClient(e.getClientId());
|
||||
if (c != null) c.channelGroupId = e.getChannelGroupId();
|
||||
boolean self = e.getClientId() == conn.getSelfClientId();
|
||||
String groupName = conn.getModel().channelGroupName(e.getChannelGroupId());
|
||||
conn.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(), groupName));
|
||||
conn.log("Channel group \"" + TeamspeakConnection.orEmpty(groupName) + "\" was assigned to "
|
||||
+ clientLogName(e.getClientId()) + " by " + invokerName(e) + ".");
|
||||
conn.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 = conn.getModel().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");
|
||||
if (invoker == conn.getSelfClientId() && invoker != 0) return byYou;
|
||||
return invoker == 0 ? byServer : byOther;
|
||||
}
|
||||
|
||||
private Map<String, String> groupVars(int clientId, String groupName) {
|
||||
Map<String, String> vars = conn.clientVars(clientId, null);
|
||||
vars.put("groupname", TeamspeakConnection.orEmpty(groupName));
|
||||
return vars;
|
||||
}
|
||||
|
||||
@Override
|
||||
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;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerGroupList(ServerGroupListEvent e) {
|
||||
int id = safeInt(e, "sgid");
|
||||
if (id > 0) conn.getModel().putServerGroup(toGroup(e, id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelGroupList(ChannelGroupListEvent e) {
|
||||
int id = safeInt(e, "cgid");
|
||||
if (id > 0) conn.getModel().putChannelGroup(toGroup(e, id));
|
||||
}
|
||||
|
||||
private static Group toGroup(BaseEvent e, int id) {
|
||||
return new Group(id, e.get("name"), TeamspeakConnection.safeLong(e, "iconid"), safeInt(e, "sortid"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextMessage(TextMessageEvent e) {
|
||||
if (e.getInvokerId() == conn.getSelfClientId()) return; // don't echo our own
|
||||
ConnectionListener.ChatScope scope;
|
||||
SoundEvent notification;
|
||||
switch (e.getTargetMode()) {
|
||||
case CLIENT:
|
||||
scope = ConnectionListener.ChatScope.PRIVATE;
|
||||
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CLIENT;
|
||||
break;
|
||||
case CHANNEL:
|
||||
scope = ConnectionListener.ChatScope.CHANNEL;
|
||||
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CHANNEL;
|
||||
break;
|
||||
default:
|
||||
scope = ConnectionListener.ChatScope.SERVER;
|
||||
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_SERVER;
|
||||
break;
|
||||
}
|
||||
conn.sound(notification, conn.clientVars(e.getInvokerId(), e.getInvokerName()));
|
||||
conn.ui.onChat(scope, e.getInvokerId(), e.getInvokerName(), e.getMessage());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientPoke(ClientPokeEvent e) {
|
||||
conn.sound(SoundEvent.OTHER_RECEIVED_POKE, conn.clientVars(e.getInvokerId(), e.getInvokerName()));
|
||||
conn.ui.onPoke(TeamspeakConnection.orEmpty(e.getInvokerName()), TeamspeakConnection.orEmpty(e.get("msg")));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnknownEvent(UnknownTeamspeakEvent e) {
|
||||
if ("notifyconnectioninfo".equals(e.getCommand())) conn.stats.onReport(e);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static String invokerName(BaseEvent e) {
|
||||
String name = TeamspeakConnection.orEmpty(e.get("invokername"));
|
||||
return name.isEmpty() ? "the server" : name;
|
||||
}
|
||||
|
||||
private static int safeInt(BaseEvent e, String key) {
|
||||
try {
|
||||
String v = e.get(key);
|
||||
return v == null ? 0 : Integer.parseInt(v.trim());
|
||||
} catch (Exception ex) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the event actually carries a field. ts3j answers a missing key with an
|
||||
* empty string rather than null, so a plain null check is always true — and a
|
||||
* partial update (say, someone muting) would otherwise look like it reported
|
||||
* every other field as well.
|
||||
*/
|
||||
private static boolean has(BaseEvent e, String key) {
|
||||
String value = e.get(key);
|
||||
return value != null && !value.isEmpty();
|
||||
}
|
||||
|
||||
/** Parses a comma-separated id list (e.g. server groups "6,12,15"). */
|
||||
private static int[] parseIntList(String csv) {
|
||||
if (csv == null || csv.isEmpty()) return new int[0];
|
||||
String[] parts = csv.split(",");
|
||||
int[] out = new int[parts.length];
|
||||
int n = 0;
|
||||
for (String p : parts) {
|
||||
try {
|
||||
out[n++] = Integer.parseInt(p.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
return n == parts.length ? out : java.util.Arrays.copyOf(out, n);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import com.github.manevolent.ts3j.api.Client;
|
||||
import com.github.manevolent.ts3j.command.SingleCommand;
|
||||
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
|
||||
import com.github.manevolent.ts3j.event.UnknownTeamspeakEvent;
|
||||
import com.github.manevolent.ts3j.protocol.PacketKind;
|
||||
import com.github.manevolent.ts3j.protocol.ProtocolRole;
|
||||
import com.github.manevolent.ts3j.protocol.packet.statistics.PacketStatistics;
|
||||
import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket;
|
||||
import com.github.manevolent.ts3j.util.Pair;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Builds {@link ConnectionStats} snapshots for the info dialog. The local client's own
|
||||
* figures are read straight from its live packet counters; a remote client's come from
|
||||
* {@code getconnectioninfo}, whose {@code notifyconnectioninfo} report arrives
|
||||
* asynchronously via {@link #onReport} and is matched back up by client id.
|
||||
*/
|
||||
final class ConnectionStatsCollector {
|
||||
|
||||
private final TeamspeakConnection conn;
|
||||
|
||||
/** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */
|
||||
private final Map<Integer, PendingConnInfo> pending = new ConcurrentHashMap<>();
|
||||
|
||||
ConnectionStatsCollector(TeamspeakConnection conn) {
|
||||
this.conn = conn;
|
||||
}
|
||||
|
||||
/** Drops any requests left over from a connection that just went away. */
|
||||
void clear() {
|
||||
pending.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches connection statistics for a client and delivers them to
|
||||
* {@code callback} (invoked off the Swing EDT — the callback is responsible
|
||||
* for marshalling). May be called repeatedly to poll.
|
||||
*/
|
||||
void request(int clientId, Consumer<ConnectionStats> callback) {
|
||||
if (clientId == conn.getSelfClientId()) {
|
||||
new Thread(() -> callback.accept(buildLocalStats()), "ts3j-conninfo-self").start();
|
||||
} else {
|
||||
new Thread(() -> requestRemote(clientId, callback), "ts3j-conninfo").start();
|
||||
}
|
||||
}
|
||||
|
||||
/** Matches an asynchronously arriving {@code notifyconnectioninfo} report to its request. */
|
||||
void onReport(UnknownTeamspeakEvent e) {
|
||||
int clid = safeInt(e.get("clid"));
|
||||
PendingConnInfo p = pending.remove(clid);
|
||||
if (p == null) return;
|
||||
applyConnectionFields(p.stats, e.getMap());
|
||||
p.callback.accept(p.stats);
|
||||
}
|
||||
|
||||
/** Builds a live snapshot of the local client's connection from its own counters. */
|
||||
private ConnectionStats buildLocalStats() {
|
||||
ConnectionStats s = new ConnectionStats();
|
||||
s.clientId = conn.getSelfClientId();
|
||||
s.self = true;
|
||||
s.live = true;
|
||||
s.packetLoss = 0; // the local client has no server->client loss figure
|
||||
|
||||
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
|
||||
if (self != null) {
|
||||
s.nickname = self.nickname;
|
||||
s.version = self.version;
|
||||
s.platform = self.platform;
|
||||
s.idleTimeMs = self.idleTimeMs;
|
||||
}
|
||||
long connectedAt = conn.connectedAtMs;
|
||||
if (connectedAt > 0) s.connectedTimeMs = System.currentTimeMillis() - connectedAt;
|
||||
|
||||
LocalTeamspeakClientSocket c = conn.client;
|
||||
if (c == null) return s;
|
||||
|
||||
try {
|
||||
Pair<Double, Double> ping = c.getPing();
|
||||
s.pingMs = ping.getKey() * 1000.0;
|
||||
s.pingDeviationMs = ping.getValue() * 1000.0;
|
||||
} catch (Exception ignored) {
|
||||
// ping unavailable; leave as unknown
|
||||
}
|
||||
|
||||
long pSent = 0, pRecv = 0, bSent = 0, bRecv = 0;
|
||||
long bwSs = 0, bwRs = 0, bwSm = 0, bwRm = 0;
|
||||
for (PacketKind kind : PacketKind.values()) {
|
||||
PacketStatistics st = c.getStatistics(kind);
|
||||
ConnectionStats.KindStats ks = new ConnectionStats.KindStats(mapKind(kind));
|
||||
ks.packetLoss = 0; // the local client cannot measure its own server->client loss
|
||||
ks.packetsSent = st.getSentPackets();
|
||||
ks.packetsReceived = st.getReceivedPackets();
|
||||
ks.bytesSent = st.getSentBytes();
|
||||
ks.bytesReceived = st.getReceivedBytes();
|
||||
ks.bandwidthSentLastSecond = st.getSentBytesLastSecond();
|
||||
ks.bandwidthReceivedLastSecond = st.getReceivedBytesLastSecond();
|
||||
ks.bandwidthSentLastMinute = st.getSentBytesLastMinute();
|
||||
ks.bandwidthReceivedLastMinute = st.getReceivedBytesLastMinute();
|
||||
s.perKind.add(ks);
|
||||
|
||||
pSent += ks.packetsSent;
|
||||
pRecv += ks.packetsReceived;
|
||||
bSent += ks.bytesSent;
|
||||
bRecv += ks.bytesReceived;
|
||||
bwSs += ks.bandwidthSentLastSecond;
|
||||
bwRs += ks.bandwidthReceivedLastSecond;
|
||||
bwSm += ks.bandwidthSentLastMinute;
|
||||
bwRm += ks.bandwidthReceivedLastMinute;
|
||||
}
|
||||
s.packetsSentTotal = pSent;
|
||||
s.packetsReceivedTotal = pRecv;
|
||||
s.bytesSentTotal = bSent;
|
||||
s.bytesReceivedTotal = bRecv;
|
||||
s.bandwidthSentLastSecond = bwSs;
|
||||
s.bandwidthReceivedLastSecond = bwRs;
|
||||
s.bandwidthSentLastMinute = bwSm;
|
||||
s.bandwidthReceivedLastMinute = bwRm;
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a remote client's connection info. First loads {@code clientinfo}
|
||||
* for the stable fields, then issues {@code getconnectioninfo} whose
|
||||
* {@code notifyconnectioninfo} report arrives asynchronously via
|
||||
* {@link #onReport}. If the report does not arrive shortly (e.g. the
|
||||
* server withholds it), the clientinfo-only snapshot is delivered instead.
|
||||
*/
|
||||
private void requestRemote(int clientId, Consumer<ConnectionStats> callback) {
|
||||
ConnectionStats s = new ConnectionStats();
|
||||
s.clientId = clientId;
|
||||
ClientEntry entry = conn.getModel().getClient(clientId);
|
||||
if (entry != null) s.nickname = entry.nickname;
|
||||
|
||||
LocalTeamspeakClientSocket client = conn.client;
|
||||
try {
|
||||
Client c = client.getClientInfo(clientId);
|
||||
if (c != null) {
|
||||
s.version = TeamspeakConnection.orEmpty(c.getVersion());
|
||||
s.platform = TeamspeakConnection.orEmpty(c.getPlatform());
|
||||
s.ip = TeamspeakConnection.orEmpty(c.getIp());
|
||||
s.idleTimeMs = c.getIdleTime();
|
||||
if (entry == null) s.nickname = TeamspeakConnection.orEmpty(c.getNickname());
|
||||
applyConnectionFields(s, c.getMap());
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// clientinfo may be permission-restricted; continue with what we have
|
||||
}
|
||||
|
||||
PendingConnInfo p = new PendingConnInfo(callback, s);
|
||||
pending.put(clientId, p);
|
||||
|
||||
boolean sent = false;
|
||||
try {
|
||||
SingleCommand cmd = new SingleCommand("getconnectioninfo", ProtocolRole.CLIENT,
|
||||
new CommandSingleParameter("clid", Integer.toString(clientId)));
|
||||
client.executeCommand(cmd).complete();
|
||||
sent = true;
|
||||
} catch (Exception ignored) {
|
||||
// command failed; fall back to the clientinfo snapshot below
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
try {
|
||||
Thread.sleep(700); // give notifyconnectioninfo a chance to arrive
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
// If the report already arrived, onReport removed and delivered it.
|
||||
if (pending.remove(clientId, p)) {
|
||||
callback.accept(s);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses TeamSpeak {@code connection_*} fields from a command map into {@code s}. */
|
||||
private static void applyConnectionFields(ConnectionStats s, Map<String, String> m) {
|
||||
double ping = parseDouble(m.get("connection_ping"));
|
||||
if (ping >= 0) s.pingMs = ping;
|
||||
double dev = parseDouble(m.get("connection_ping_deviation"));
|
||||
if (dev >= 0) s.pingDeviationMs = dev;
|
||||
double loss = parseDouble(m.get("connection_packetloss_total"));
|
||||
if (loss < 0) loss = parseDouble(m.get("connection_server2client_packetloss_total"));
|
||||
if (loss >= 0) s.packetLoss = loss;
|
||||
|
||||
long connected = parseLong(m.get("connection_connected_time"));
|
||||
if (connected >= 0) s.connectedTimeMs = connected;
|
||||
String ip = m.get("connection_client_ip");
|
||||
if (ip != null && !ip.isEmpty()) s.ip = ip;
|
||||
|
||||
s.packetsSentTotal = pick(s.packetsSentTotal, m.get("connection_packets_sent_total"));
|
||||
s.packetsReceivedTotal = pick(s.packetsReceivedTotal, m.get("connection_packets_received_total"));
|
||||
s.bytesSentTotal = pick(s.bytesSentTotal, m.get("connection_bytes_sent_total"));
|
||||
s.bytesReceivedTotal = pick(s.bytesReceivedTotal, m.get("connection_bytes_received_total"));
|
||||
s.bandwidthSentLastSecond =
|
||||
pick(s.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_total"));
|
||||
s.bandwidthReceivedLastSecond =
|
||||
pick(s.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_total"));
|
||||
s.bandwidthSentLastMinute =
|
||||
pick(s.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_total"));
|
||||
s.bandwidthReceivedLastMinute =
|
||||
pick(s.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_total"));
|
||||
s.filetransferBandwidthSent =
|
||||
pick(s.filetransferBandwidthSent, m.get("connection_filetransfer_bandwidth_sent"));
|
||||
s.filetransferBandwidthReceived =
|
||||
pick(s.filetransferBandwidthReceived, m.get("connection_filetransfer_bandwidth_received"));
|
||||
|
||||
applyPerKindFields(s, m);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the per-category {@code connection_*_<kind>} fields (as sent in a
|
||||
* {@code notifyconnectioninfo} report) into {@code s}, merging into any
|
||||
* existing rows. Categories absent from the map are left untouched.
|
||||
*/
|
||||
private static void applyPerKindFields(ConnectionStats s, Map<String, String> m) {
|
||||
for (ConnectionStats.Kind kind : ConnectionStats.Kind.values()) {
|
||||
String suffix = kind.name().toLowerCase(java.util.Locale.ROOT); // keepalive/control/speech
|
||||
String probe = m.get("connection_packets_sent_" + suffix);
|
||||
String probe2 = m.get("connection_server2client_packetloss_" + suffix);
|
||||
if (probe == null && probe2 == null) continue; // this category not reported
|
||||
|
||||
ConnectionStats.KindStats ks = s.getOrCreateKind(kind);
|
||||
ks.packetsSent = pick(ks.packetsSent, m.get("connection_packets_sent_" + suffix));
|
||||
ks.packetsReceived = pick(ks.packetsReceived, m.get("connection_packets_received_" + suffix));
|
||||
ks.bytesSent = pick(ks.bytesSent, m.get("connection_bytes_sent_" + suffix));
|
||||
ks.bytesReceived = pick(ks.bytesReceived, m.get("connection_bytes_received_" + suffix));
|
||||
ks.bandwidthSentLastSecond =
|
||||
pick(ks.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_" + suffix));
|
||||
ks.bandwidthReceivedLastSecond =
|
||||
pick(ks.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_" + suffix));
|
||||
ks.bandwidthSentLastMinute =
|
||||
pick(ks.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_" + suffix));
|
||||
ks.bandwidthReceivedLastMinute =
|
||||
pick(ks.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_" + suffix));
|
||||
double kloss = parseDouble(m.get("connection_server2client_packetloss_" + suffix));
|
||||
if (kloss >= 0) ks.packetLoss = kloss;
|
||||
}
|
||||
}
|
||||
|
||||
private static long pick(long current, String value) {
|
||||
long v = parseLong(value);
|
||||
return v >= 0 ? v : current;
|
||||
}
|
||||
|
||||
private static ConnectionStats.Kind mapKind(PacketKind kind) {
|
||||
switch (kind) {
|
||||
case KEEPALIVE:
|
||||
return ConnectionStats.Kind.KEEPALIVE;
|
||||
case SPEECH:
|
||||
return ConnectionStats.Kind.SPEECH;
|
||||
default:
|
||||
return ConnectionStats.Kind.CONTROL;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses a long, returning -1 for null/blank/non-numeric input. */
|
||||
private static long parseLong(String s) {
|
||||
if (s == null || s.isEmpty()) return -1;
|
||||
try {
|
||||
return Long.parseLong(s.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses a double, returning -1 for null/blank/non-numeric input. */
|
||||
private static double parseDouble(String s) {
|
||||
if (s == null || s.isEmpty()) return -1;
|
||||
try {
|
||||
return Double.parseDouble(s.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return the parsed value, or 0 when it is absent or not a number */
|
||||
private static int safeInt(String value) {
|
||||
try {
|
||||
return value == null ? 0 : Integer.parseInt(value.trim());
|
||||
} catch (NumberFormatException ex) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Callback + accumulating snapshot for an in-flight {@code getconnectioninfo}. */
|
||||
private static final class PendingConnInfo {
|
||||
final Consumer<ConnectionStats> callback;
|
||||
final ConnectionStats stats;
|
||||
|
||||
PendingConnInfo(Consumer<ConnectionStats> callback, ConnectionStats stats) {
|
||||
this.callback = callback;
|
||||
this.stats = stats;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,8 @@ import com.github.manevolent.ts3j.command.SingleCommand;
|
||||
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
|
||||
import com.github.manevolent.ts3j.event.*;
|
||||
import com.github.manevolent.ts3j.identity.LocalIdentity;
|
||||
import com.github.manevolent.ts3j.protocol.PacketKind;
|
||||
import com.github.manevolent.ts3j.protocol.ProtocolRole;
|
||||
import com.github.manevolent.ts3j.protocol.packet.statistics.PacketStatistics;
|
||||
import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket;
|
||||
import com.github.manevolent.ts3j.util.Pair;
|
||||
import com.github.manevolent.ts3j.util.Ts3Crypt;
|
||||
import com.ts3client.audio.AudioBackend;
|
||||
import com.ts3client.audio.VoiceInput;
|
||||
@@ -52,11 +49,18 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
private final Settings settings;
|
||||
private final AudioBackend audio;
|
||||
private final ServerModel model = new ServerModel();
|
||||
private final ConnectionListener ui;
|
||||
/** Package-private: read directly by {@link ConnectionEventHandler}. */
|
||||
final ConnectionListener ui;
|
||||
private final IconRepository icons;
|
||||
private final SoundNotifier sounds;
|
||||
/** Package-private: read directly by {@link ConnectionEventHandler}. */
|
||||
final SoundNotifier sounds;
|
||||
/** The {@link TS3Listener} side of this connection; see its class doc. */
|
||||
private final ConnectionEventHandler events = new ConnectionEventHandler(this);
|
||||
/** Builds {@code getconnectioninfo} snapshots; also package-private for {@link ConnectionEventHandler}. */
|
||||
final ConnectionStatsCollector stats = new ConnectionStatsCollector(this);
|
||||
|
||||
private LocalTeamspeakClientSocket client;
|
||||
/** Package-private: read directly by {@link ConnectionStatsCollector}. */
|
||||
LocalTeamspeakClientSocket client;
|
||||
private VoiceInput microphone;
|
||||
private VoiceOutput playback;
|
||||
private LocalIdentity identity;
|
||||
@@ -64,7 +68,8 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
|
||||
private volatile boolean connected;
|
||||
private volatile int selfClientId = -1;
|
||||
private volatile long connectedAtMs;
|
||||
/** Package-private: read directly by {@link ConnectionStatsCollector}. */
|
||||
volatile long connectedAtMs;
|
||||
private volatile String serverHost;
|
||||
private volatile int serverPort;
|
||||
|
||||
@@ -83,9 +88,6 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
private volatile boolean disconnectAnnounced = true;
|
||||
private volatile long lastMutedTalkNanos;
|
||||
|
||||
/** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */
|
||||
private final Map<Integer, PendingConnInfo> pendingConnInfo = new ConcurrentHashMap<>();
|
||||
|
||||
public TeamspeakConnection(Settings settings, AudioBackend audio, ConnectionListener ui,
|
||||
SoundNotifier sounds) {
|
||||
this.settings = settings;
|
||||
@@ -205,7 +207,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
|
||||
model.clear();
|
||||
playback = audio.createOutput(settings);
|
||||
playback.setMasterVolume(settings.outputVolume);
|
||||
playback.setMasterVolume(settings.effectiveOutputVolume());
|
||||
playback.setTalkListener((clientId, talking) -> {
|
||||
ClientEntry c = model.getClient(clientId);
|
||||
if (c != null) c.talking = talking;
|
||||
@@ -233,6 +235,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
client.setNickname(nickname);
|
||||
client.setHWID("ts3jswing-" + Integer.toHexString(nickname.hashCode()));
|
||||
client.addListener(this);
|
||||
client.addListener(events);
|
||||
client.setVoiceHandler(playback::handleVoice);
|
||||
client.setWhisperHandler(playback::handleWhisper);
|
||||
client.setExceptionHandler(t -> {
|
||||
@@ -486,7 +489,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
client = null;
|
||||
fileTransfers = null;
|
||||
selfClientId = -1;
|
||||
pendingConnInfo.clear();
|
||||
stats.clear();
|
||||
|
||||
if (ft != null) {
|
||||
try {
|
||||
@@ -774,499 +777,6 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
}, "ts3j-rename").start();
|
||||
}
|
||||
|
||||
// ---- TS3Listener: keep model in sync and notify UI ----
|
||||
|
||||
@Override
|
||||
public void onClientJoin(ClientJoinEvent e) {
|
||||
ClientEntry c = model.putClient(e.getClientId(), e.getClientNickname(), e.getClientTargetId());
|
||||
c.type = safeInt(e, "client_type");
|
||||
c.talkPower = e.getClientTalkPower();
|
||||
c.inputMuted = e.isClientInputMuted();
|
||||
c.outputMuted = e.isClientOutputMuted();
|
||||
c.inputHardware = e.isClientUsingHardwareInput();
|
||||
c.outputHardware = e.isClientUsingHardwareOutput();
|
||||
c.away = e.isClientAway();
|
||||
c.awayMessage = orEmpty(e.get("client_away_message"));
|
||||
c.uniqueId = orEmpty(e.getUniqueClientIdentifier());
|
||||
c.serverGroupIds = parseIntList(e.getClientServerGroups());
|
||||
c.channelGroupId = e.getClientChannelGroupId();
|
||||
c.self = (e.getClientId() == selfClientId);
|
||||
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 {
|
||||
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();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientMoved(ClientMovedEvent e) {
|
||||
ClientEntry c = model.getClient(e.getClientId());
|
||||
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());
|
||||
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. */
|
||||
private static final int REASON_SWITCHED = 0;
|
||||
private static final int REASON_MOVED = 1;
|
||||
private static final int REASON_TIMEOUT = 3;
|
||||
private static final int REASON_CHANNEL_KICK = 4;
|
||||
private static final int REASON_SERVER_KICK = 5;
|
||||
private static final int REASON_BAN = 6;
|
||||
|
||||
/**
|
||||
* A client became visible: either they just connected, or they moved in from a
|
||||
* channel we could not see — TeamSpeak's "appears" case.
|
||||
*/
|
||||
private void announceClientEntered(ClientJoinEvent e) {
|
||||
if (!connected) return;
|
||||
int clientId = e.getClientId();
|
||||
boolean current = inOwnChannel(e.getClientTargetId());
|
||||
Map<String, String> vars = clientVars(clientId, e.getClientNickname());
|
||||
switch (safeInt(e, "reasonid")) {
|
||||
case REASON_MOVED:
|
||||
sound(current ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_APPEARS
|
||||
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_APPEARS, vars);
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_APPEARS
|
||||
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_APPEARS, vars);
|
||||
break;
|
||||
case REASON_SWITCHED:
|
||||
sound(current ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_APPEARS
|
||||
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_APPEARS, vars);
|
||||
break;
|
||||
default:
|
||||
sound(current ? SoundEvent.CLIENT_CONNECTION_CONNECTED_CURRENT_CHANNEL
|
||||
: SoundEvent.CLIENT_CONNECTION_CONNECTED_SERVER, vars);
|
||||
}
|
||||
}
|
||||
|
||||
/** A client stopped being visible: they left the server, or moved out of sight. */
|
||||
private void announceClientLeft(ClientLeaveEvent e) {
|
||||
if (!connected) return;
|
||||
int clientId = e.getClientId();
|
||||
boolean current = inOwnChannel(e.getClientFromId());
|
||||
Map<String, String> vars = clientVars(clientId, null);
|
||||
switch (safeInt(e, "reasonid")) {
|
||||
case REASON_TIMEOUT:
|
||||
sound(current ? SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_CURRENT_CHANNEL
|
||||
: SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_SERVER, vars);
|
||||
break;
|
||||
case REASON_SERVER_KICK:
|
||||
sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_CURRENT_CHANNEL
|
||||
: SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_SERVER, vars);
|
||||
break;
|
||||
case REASON_BAN:
|
||||
sound(current ? SoundEvent.CLIENT_WAS_BANNED_CURRENT_CHANNEL
|
||||
: SoundEvent.CLIENT_WAS_BANNED_SERVER, vars);
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_DISAPPEARS
|
||||
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_DISAPPEARS, vars);
|
||||
break;
|
||||
case REASON_MOVED:
|
||||
sound(current ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_DISAPPEARS
|
||||
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_DISAPPEARS, vars);
|
||||
break;
|
||||
case REASON_SWITCHED:
|
||||
sound(current ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_DISAPPEARS
|
||||
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_DISAPPEARS, vars);
|
||||
break;
|
||||
default:
|
||||
sound(current ? SoundEvent.CLIENT_CONNECTION_DISCONNECTED_CURRENT_CHANNEL
|
||||
: SoundEvent.CLIENT_CONNECTION_DISCONNECTED_SERVER, vars);
|
||||
}
|
||||
}
|
||||
|
||||
/** A client we can see moved between two channels we can see ("stays"). */
|
||||
private void announceClientMoved(int reason, int clientId, int fromChannel, int toChannel) {
|
||||
if (!connected) return;
|
||||
boolean toCurrent = inOwnChannel(toChannel);
|
||||
boolean fromCurrent = inOwnChannel(fromChannel);
|
||||
Map<String, String> vars = clientVars(clientId, null);
|
||||
switch (reason) {
|
||||
case REASON_MOVED:
|
||||
sound(toCurrent ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS
|
||||
: fromCurrent ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_STAYS
|
||||
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_STAYS, vars);
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
sound(toCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_STAYS
|
||||
: fromCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_STAYS
|
||||
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_STAYS, vars);
|
||||
break;
|
||||
default:
|
||||
sound(toCurrent ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_STAYS
|
||||
: fromCurrent ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_STAYS
|
||||
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_STAYS, vars);
|
||||
}
|
||||
}
|
||||
|
||||
/** We changed channel ourselves, or somebody changed it for us. */
|
||||
private void announceOwnMove(int reason, ClientMovedEvent e) {
|
||||
if (!connected) return;
|
||||
Map<String, String> vars = channelVars(e.getTargetChannelId(), e.get("invokername"));
|
||||
switch (reason) {
|
||||
case REASON_MOVED:
|
||||
sound(SoundEvent.YOU_WERE_MOVED_TO_DIFFERENT_CHANNEL, vars);
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
sound(SoundEvent.YOU_WERE_KICKED_FROM_CHANNEL, vars);
|
||||
break;
|
||||
default:
|
||||
sound(SoundEvent.YOU_SWITCHED_CHANNEL, vars);
|
||||
}
|
||||
}
|
||||
|
||||
/** We were removed from the server (kick or ban); the disconnect follows. */
|
||||
private void announceOwnRemoval(int reason, ClientLeaveEvent e) {
|
||||
if (!connected) return;
|
||||
Map<String, String> vars = SoundNotifier.vars(
|
||||
"servername", model.getServerName(),
|
||||
"clientname", orEmpty(e.get("invokername")),
|
||||
"reason", orEmpty(e.get("reasonmsg")));
|
||||
if (reason == REASON_SERVER_KICK) {
|
||||
sound(SoundEvent.YOU_WERE_KICKED_FROM_SERVER, vars);
|
||||
} else if (reason == REASON_BAN) {
|
||||
sound(SoundEvent.YOU_WERE_BANNED, vars);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientChanged(ClientUpdatedEvent e) {
|
||||
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");
|
||||
if (has(e, "client_input_hardware")) c.inputHardware = e.getBoolean("client_input_hardware");
|
||||
if (has(e, "client_output_hardware")) c.outputHardware = e.getBoolean("client_output_hardware");
|
||||
if (has(e, "client_away")) {
|
||||
c.away = e.getBoolean("client_away");
|
||||
// Both fields travel together, so an absent message here means "no message"
|
||||
// — which `has` cannot tell from "not reported".
|
||||
c.awayMessage = c.away ? orEmpty(e.get("client_away_message")) : "";
|
||||
} else if (has(e, "client_away_message")) {
|
||||
c.awayMessage = e.get("client_away_message");
|
||||
}
|
||||
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, 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, String oldName) {
|
||||
if (!connected) return;
|
||||
boolean self = c.id == selfClientId;
|
||||
Map<String, String> vars = clientVars(c.id, null);
|
||||
|
||||
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);
|
||||
}
|
||||
if (self && has(e, "client_is_talker")) {
|
||||
sound(e.getBoolean("client_is_talker")
|
||||
? SoundEvent.YOU_WERE_GRANTED_TALK_POWER : SoundEvent.YOU_WERE_REVOKED_TALK_POWER, vars);
|
||||
}
|
||||
if (!self && has(e, "client_is_recording")) {
|
||||
boolean recording = e.getBoolean("client_is_recording");
|
||||
if (!recording) {
|
||||
sound(SoundEvent.CLIENT_RECORDING_STOP, vars);
|
||||
} else {
|
||||
sound(inOwnChannel(c.channelId)
|
||||
? SoundEvent.CLIENT_RECORDING_IN_CHANNEL : SoundEvent.CLIENT_RECORDING_START, vars);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 = model.putChannel(cid, name, pid, order);
|
||||
long icon = safeLong(e, "channel_icon_id");
|
||||
if (icon != 0) node.iconId = icon;
|
||||
model.relinkChannel(cid, pid, order);
|
||||
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();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelDeleted(ChannelDeletedEvent e) {
|
||||
int cid = e.getChannelId();
|
||||
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();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelEdit(ChannelEditedEvent e) {
|
||||
ChannelNode ch = model.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 = safeLong(e, "channel_icon_id");
|
||||
if (connected) {
|
||||
boolean current = inOwnChannel(ch.id);
|
||||
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),
|
||||
channelVars(ch.id, e.get("invokername")));
|
||||
log("Channel \"" + ch.name + "\" was edited by " + invokerName(e));
|
||||
}
|
||||
ui.onModelChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelMoved(ChannelMovedEvent e) {
|
||||
ChannelNode ch = model.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;
|
||||
model.relinkChannel(ch.id, parent, order);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelSubscribed(ChannelSubscribedEvent e) {
|
||||
setSubscribed(safeInt(e, "cid"), true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelUnsubscribed(ChannelUnsubscribedEvent e) {
|
||||
setSubscribed(safeInt(e, "cid"), false);
|
||||
}
|
||||
|
||||
private void setSubscribed(int cid, boolean subscribed) {
|
||||
ChannelNode ch = model.getChannel(cid);
|
||||
if (ch == null || ch.subscribed == subscribed) return;
|
||||
ch.subscribed = subscribed;
|
||||
ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerEdit(ServerEditedEvent e) {
|
||||
if (has(e, "virtualserver_name")) model.setServerName(e.get("virtualserver_name"));
|
||||
if (connected) {
|
||||
sound(byInvoker(e, SoundEvent.SERVER_EDITED_BY_YOU, SoundEvent.SERVER_EDITED_BY_OTHER,
|
||||
SoundEvent.SERVER_EDITED_BY_OTHER), serverVars());
|
||||
}
|
||||
ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerGroupClientAdded(ServerGroupClientAddedEvent e) {
|
||||
boolean self = e.getClientId() == selfClientId;
|
||||
sound(self
|
||||
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER,
|
||||
SoundEvent.YOU_SERVERGROUP_ADDED_BY_SERVER)
|
||||
: 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) {
|
||||
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(clientId, e.get("name")));
|
||||
log(clientLogName(clientId) + " was removed from server group \"" + orEmpty(e.get("name"))
|
||||
+ "\" by " + invokerName(e) + ".");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientChannelGroupChanged(ClientChannelGroupChangedEvent e) {
|
||||
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(), 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");
|
||||
if (invoker == selfClientId && invoker != 0) return byYou;
|
||||
return invoker == 0 ? byServer : byOther;
|
||||
}
|
||||
|
||||
private Map<String, String> groupVars(int clientId, String groupName) {
|
||||
Map<String, String> vars = clientVars(clientId, null);
|
||||
vars.put("groupname", orEmpty(groupName));
|
||||
return vars;
|
||||
}
|
||||
|
||||
@Override
|
||||
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 = model.putChannel(cid, name, pid, order);
|
||||
long icon = safeLong(e, "channel_icon_id");
|
||||
if (icon != 0) node.iconId = icon;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerGroupList(ServerGroupListEvent e) {
|
||||
int id = safeInt(e, "sgid");
|
||||
if (id > 0) model.putServerGroup(toGroup(e, id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelGroupList(ChannelGroupListEvent e) {
|
||||
int id = safeInt(e, "cgid");
|
||||
if (id > 0) model.putChannelGroup(toGroup(e, id));
|
||||
}
|
||||
|
||||
private static Group toGroup(BaseEvent e, int id) {
|
||||
return new Group(id, e.get("name"), safeLong(e, "iconid"), safeInt(e, "sortid"));
|
||||
}
|
||||
|
||||
/** Fetches a channel's description (and topic) on demand, then notifies the UI. */
|
||||
public void requestChannelInfo(int channelId) {
|
||||
new Thread(() -> {
|
||||
@@ -1405,262 +915,17 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
*/
|
||||
public void requestConnectionInfo(int clientId, Consumer<ConnectionStats> callback) {
|
||||
if (client == null || !connected) return;
|
||||
if (clientId == selfClientId) {
|
||||
new Thread(() -> callback.accept(buildLocalStats()), "ts3j-conninfo-self").start();
|
||||
} else {
|
||||
new Thread(() -> requestRemoteConnInfo(clientId, callback), "ts3j-conninfo").start();
|
||||
}
|
||||
stats.request(clientId, callback);
|
||||
}
|
||||
|
||||
/** Builds a live snapshot of the local client's connection from its own counters. */
|
||||
private ConnectionStats buildLocalStats() {
|
||||
ConnectionStats s = new ConnectionStats();
|
||||
s.clientId = selfClientId;
|
||||
s.self = true;
|
||||
s.live = true;
|
||||
s.packetLoss = 0; // the local client has no server->client loss figure
|
||||
|
||||
ClientEntry self = model.getClient(selfClientId);
|
||||
if (self != null) {
|
||||
s.nickname = self.nickname;
|
||||
s.version = self.version;
|
||||
s.platform = self.platform;
|
||||
s.idleTimeMs = self.idleTimeMs;
|
||||
}
|
||||
long connectedAt = connectedAtMs;
|
||||
if (connectedAt > 0) s.connectedTimeMs = System.currentTimeMillis() - connectedAt;
|
||||
|
||||
LocalTeamspeakClientSocket c = client;
|
||||
if (c == null) return s;
|
||||
|
||||
try {
|
||||
Pair<Double, Double> ping = c.getPing();
|
||||
s.pingMs = ping.getKey() * 1000.0;
|
||||
s.pingDeviationMs = ping.getValue() * 1000.0;
|
||||
} catch (Exception ignored) {
|
||||
// ping unavailable; leave as unknown
|
||||
}
|
||||
|
||||
long pSent = 0, pRecv = 0, bSent = 0, bRecv = 0;
|
||||
long bwSs = 0, bwRs = 0, bwSm = 0, bwRm = 0;
|
||||
for (PacketKind kind : PacketKind.values()) {
|
||||
PacketStatistics st = c.getStatistics(kind);
|
||||
ConnectionStats.KindStats ks = new ConnectionStats.KindStats(mapKind(kind));
|
||||
ks.packetLoss = 0; // the local client cannot measure its own server->client loss
|
||||
ks.packetsSent = st.getSentPackets();
|
||||
ks.packetsReceived = st.getReceivedPackets();
|
||||
ks.bytesSent = st.getSentBytes();
|
||||
ks.bytesReceived = st.getReceivedBytes();
|
||||
ks.bandwidthSentLastSecond = st.getSentBytesLastSecond();
|
||||
ks.bandwidthReceivedLastSecond = st.getReceivedBytesLastSecond();
|
||||
ks.bandwidthSentLastMinute = st.getSentBytesLastMinute();
|
||||
ks.bandwidthReceivedLastMinute = st.getReceivedBytesLastMinute();
|
||||
s.perKind.add(ks);
|
||||
|
||||
pSent += ks.packetsSent;
|
||||
pRecv += ks.packetsReceived;
|
||||
bSent += ks.bytesSent;
|
||||
bRecv += ks.bytesReceived;
|
||||
bwSs += ks.bandwidthSentLastSecond;
|
||||
bwRs += ks.bandwidthReceivedLastSecond;
|
||||
bwSm += ks.bandwidthSentLastMinute;
|
||||
bwRm += ks.bandwidthReceivedLastMinute;
|
||||
}
|
||||
s.packetsSentTotal = pSent;
|
||||
s.packetsReceivedTotal = pRecv;
|
||||
s.bytesSentTotal = bSent;
|
||||
s.bytesReceivedTotal = bRecv;
|
||||
s.bandwidthSentLastSecond = bwSs;
|
||||
s.bandwidthReceivedLastSecond = bwRs;
|
||||
s.bandwidthSentLastMinute = bwSm;
|
||||
s.bandwidthReceivedLastMinute = bwRm;
|
||||
return s;
|
||||
}
|
||||
// ---- events not handled by ConnectionEventHandler ----
|
||||
|
||||
/**
|
||||
* Requests a remote client's connection info. First loads {@code clientinfo}
|
||||
* for the stable fields, then issues {@code getconnectioninfo} whose
|
||||
* {@code notifyconnectioninfo} report arrives asynchronously via
|
||||
* {@link #onUnknownEvent}. If the report does not arrive shortly (e.g. the
|
||||
* server withholds it), the clientinfo-only snapshot is delivered instead.
|
||||
* Kept here rather than in {@link ConnectionEventHandler}: teardown needs
|
||||
* {@link #safeCleanup()} and the {@code connected}/announced flags this class
|
||||
* already owns, so routing it through the event handler would only add an
|
||||
* extra hop back into private lifecycle state.
|
||||
*/
|
||||
private void requestRemoteConnInfo(int clientId, Consumer<ConnectionStats> callback) {
|
||||
ConnectionStats s = new ConnectionStats();
|
||||
s.clientId = clientId;
|
||||
ClientEntry entry = model.getClient(clientId);
|
||||
if (entry != null) s.nickname = entry.nickname;
|
||||
|
||||
try {
|
||||
Client c = client.getClientInfo(clientId);
|
||||
if (c != null) {
|
||||
s.version = orEmpty(c.getVersion());
|
||||
s.platform = orEmpty(c.getPlatform());
|
||||
s.ip = orEmpty(c.getIp());
|
||||
s.idleTimeMs = c.getIdleTime();
|
||||
if (entry == null) s.nickname = orEmpty(c.getNickname());
|
||||
applyConnectionFields(s, c.getMap());
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// clientinfo may be permission-restricted; continue with what we have
|
||||
}
|
||||
|
||||
PendingConnInfo pending = new PendingConnInfo(callback, s);
|
||||
pendingConnInfo.put(clientId, pending);
|
||||
|
||||
boolean sent = false;
|
||||
try {
|
||||
SingleCommand cmd = new SingleCommand("getconnectioninfo", ProtocolRole.CLIENT,
|
||||
new CommandSingleParameter("clid", Integer.toString(clientId)));
|
||||
client.executeCommand(cmd).complete();
|
||||
sent = true;
|
||||
} catch (Exception ignored) {
|
||||
// command failed; fall back to the clientinfo snapshot below
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
try {
|
||||
Thread.sleep(700); // give notifyconnectioninfo a chance to arrive
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
// If the report already arrived, onUnknownEvent removed and delivered it.
|
||||
if (pendingConnInfo.remove(clientId, pending)) {
|
||||
callback.accept(s);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses TeamSpeak {@code connection_*} fields from a command map into {@code s}. */
|
||||
private static void applyConnectionFields(ConnectionStats s, Map<String, String> m) {
|
||||
double ping = parseDouble(m.get("connection_ping"));
|
||||
if (ping >= 0) s.pingMs = ping;
|
||||
double dev = parseDouble(m.get("connection_ping_deviation"));
|
||||
if (dev >= 0) s.pingDeviationMs = dev;
|
||||
double loss = parseDouble(m.get("connection_packetloss_total"));
|
||||
if (loss < 0) loss = parseDouble(m.get("connection_server2client_packetloss_total"));
|
||||
if (loss >= 0) s.packetLoss = loss;
|
||||
|
||||
long connected = parseLong(m.get("connection_connected_time"));
|
||||
if (connected >= 0) s.connectedTimeMs = connected;
|
||||
String ip = m.get("connection_client_ip");
|
||||
if (ip != null && !ip.isEmpty()) s.ip = ip;
|
||||
|
||||
s.packetsSentTotal = pick(s.packetsSentTotal, m.get("connection_packets_sent_total"));
|
||||
s.packetsReceivedTotal = pick(s.packetsReceivedTotal, m.get("connection_packets_received_total"));
|
||||
s.bytesSentTotal = pick(s.bytesSentTotal, m.get("connection_bytes_sent_total"));
|
||||
s.bytesReceivedTotal = pick(s.bytesReceivedTotal, m.get("connection_bytes_received_total"));
|
||||
s.bandwidthSentLastSecond =
|
||||
pick(s.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_total"));
|
||||
s.bandwidthReceivedLastSecond =
|
||||
pick(s.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_total"));
|
||||
s.bandwidthSentLastMinute =
|
||||
pick(s.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_total"));
|
||||
s.bandwidthReceivedLastMinute =
|
||||
pick(s.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_total"));
|
||||
s.filetransferBandwidthSent =
|
||||
pick(s.filetransferBandwidthSent, m.get("connection_filetransfer_bandwidth_sent"));
|
||||
s.filetransferBandwidthReceived =
|
||||
pick(s.filetransferBandwidthReceived, m.get("connection_filetransfer_bandwidth_received"));
|
||||
|
||||
applyPerKindFields(s, m);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the per-category {@code connection_*_<kind>} fields (as sent in a
|
||||
* {@code notifyconnectioninfo} report) into {@code s}, merging into any
|
||||
* existing rows. Categories absent from the map are left untouched.
|
||||
*/
|
||||
private static void applyPerKindFields(ConnectionStats s, Map<String, String> m) {
|
||||
for (ConnectionStats.Kind kind : ConnectionStats.Kind.values()) {
|
||||
String suffix = kind.name().toLowerCase(java.util.Locale.ROOT); // keepalive/control/speech
|
||||
String probe = m.get("connection_packets_sent_" + suffix);
|
||||
String probe2 = m.get("connection_server2client_packetloss_" + suffix);
|
||||
if (probe == null && probe2 == null) continue; // this category not reported
|
||||
|
||||
ConnectionStats.KindStats ks = s.getOrCreateKind(kind);
|
||||
ks.packetsSent = pick(ks.packetsSent, m.get("connection_packets_sent_" + suffix));
|
||||
ks.packetsReceived = pick(ks.packetsReceived, m.get("connection_packets_received_" + suffix));
|
||||
ks.bytesSent = pick(ks.bytesSent, m.get("connection_bytes_sent_" + suffix));
|
||||
ks.bytesReceived = pick(ks.bytesReceived, m.get("connection_bytes_received_" + suffix));
|
||||
ks.bandwidthSentLastSecond =
|
||||
pick(ks.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_" + suffix));
|
||||
ks.bandwidthReceivedLastSecond =
|
||||
pick(ks.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_" + suffix));
|
||||
ks.bandwidthSentLastMinute =
|
||||
pick(ks.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_" + suffix));
|
||||
ks.bandwidthReceivedLastMinute =
|
||||
pick(ks.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_" + suffix));
|
||||
double kloss = parseDouble(m.get("connection_server2client_packetloss_" + suffix));
|
||||
if (kloss >= 0) ks.packetLoss = kloss;
|
||||
}
|
||||
}
|
||||
|
||||
private static long pick(long current, String value) {
|
||||
long v = parseLong(value);
|
||||
return v >= 0 ? v : current;
|
||||
}
|
||||
|
||||
private static ConnectionStats.Kind mapKind(PacketKind kind) {
|
||||
switch (kind) {
|
||||
case KEEPALIVE:
|
||||
return ConnectionStats.Kind.KEEPALIVE;
|
||||
case SPEECH:
|
||||
return ConnectionStats.Kind.SPEECH;
|
||||
default:
|
||||
return ConnectionStats.Kind.CONTROL;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnknownEvent(UnknownTeamspeakEvent e) {
|
||||
if (!"notifyconnectioninfo".equals(e.getCommand())) return;
|
||||
int clid = safeInt(e, "clid");
|
||||
PendingConnInfo pending = pendingConnInfo.remove(clid);
|
||||
if (pending == null) return;
|
||||
applyConnectionFields(pending.stats, e.getMap());
|
||||
pending.callback.accept(pending.stats);
|
||||
}
|
||||
|
||||
/** Callback + accumulating snapshot for an in-flight {@code getconnectioninfo}. */
|
||||
private static final class PendingConnInfo {
|
||||
final Consumer<ConnectionStats> callback;
|
||||
final ConnectionStats stats;
|
||||
|
||||
PendingConnInfo(Consumer<ConnectionStats> callback, ConnectionStats stats) {
|
||||
this.callback = callback;
|
||||
this.stats = stats;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextMessage(TextMessageEvent e) {
|
||||
if (e.getInvokerId() == selfClientId) return; // don't echo our own
|
||||
ConnectionListener.ChatScope scope;
|
||||
SoundEvent notification;
|
||||
switch (e.getTargetMode()) {
|
||||
case CLIENT:
|
||||
scope = ConnectionListener.ChatScope.PRIVATE;
|
||||
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CLIENT;
|
||||
break;
|
||||
case CHANNEL:
|
||||
scope = ConnectionListener.ChatScope.CHANNEL;
|
||||
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CHANNEL;
|
||||
break;
|
||||
default:
|
||||
scope = ConnectionListener.ChatScope.SERVER;
|
||||
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_SERVER;
|
||||
break;
|
||||
}
|
||||
sound(notification, clientVars(e.getInvokerId(), e.getInvokerName()));
|
||||
ui.onChat(scope, e.getInvokerId(), e.getInvokerName(), e.getMessage());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientPoke(ClientPokeEvent e) {
|
||||
sound(SoundEvent.OTHER_RECEIVED_POKE, clientVars(e.getInvokerId(), e.getInvokerName()));
|
||||
ui.onPoke(orEmpty(e.getInvokerName()), orEmpty(e.get("msg")));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnected(DisconnectedEvent e) {
|
||||
connected = false;
|
||||
@@ -1674,33 +939,29 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
|
||||
/**
|
||||
* Plays the sound pack's entry for an action. Muting is passed along so the
|
||||
* notifier can drop everything the user did not mark as important.
|
||||
* notifier can drop everything the user did not mark as important. Package-private:
|
||||
* also called by {@link ConnectionEventHandler}.
|
||||
*/
|
||||
private void sound(SoundEvent event) {
|
||||
void sound(SoundEvent event) {
|
||||
sounds.fire(event, deafened);
|
||||
}
|
||||
|
||||
private void sound(SoundEvent event, Map<String, String> variables) {
|
||||
void sound(SoundEvent event, Map<String, String> variables) {
|
||||
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) {
|
||||
void log(String message) {
|
||||
if (connected) ui.onServerLog(message);
|
||||
}
|
||||
|
||||
private String channelName(int channelId) {
|
||||
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) {
|
||||
Map<String, String> clientVars(int clientId, String fallbackName) {
|
||||
ClientEntry c = model.getClient(clientId);
|
||||
Map<String, String> vars = SoundNotifier.vars(
|
||||
"servername", model.getServerName(),
|
||||
@@ -1713,7 +974,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
return vars;
|
||||
}
|
||||
|
||||
private Map<String, String> channelVars(int channelId, String invokerName) {
|
||||
Map<String, String> channelVars(int channelId, String invokerName) {
|
||||
ChannelNode ch = model.getChannel(channelId);
|
||||
return SoundNotifier.vars(
|
||||
"servername", model.getServerName(),
|
||||
@@ -1721,7 +982,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
"clientname", orEmpty(invokerName));
|
||||
}
|
||||
|
||||
private Map<String, String> serverVars() {
|
||||
Map<String, String> serverVars() {
|
||||
return SoundNotifier.vars("servername", model.getServerName());
|
||||
}
|
||||
|
||||
@@ -1739,15 +1000,17 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
return m.contains("insufficient") || m.contains("permission");
|
||||
}
|
||||
|
||||
/** Whether the client is in the channel we are in ourselves. */
|
||||
private boolean inOwnChannel(int channelId) {
|
||||
/** Whether the client is in the channel we are in ourselves. Package-private: also
|
||||
* used by {@link ConnectionEventHandler}. */
|
||||
boolean inOwnChannel(int channelId) {
|
||||
ClientEntry self = model.getClient(selfClientId);
|
||||
return self != null && self.channelId == channelId;
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static long safeLong(BaseEvent e, String key) {
|
||||
/** Package-private: also used by {@link ConnectionEventHandler}. */
|
||||
static long safeLong(BaseEvent e, String key) {
|
||||
return safeLong(e.get(key));
|
||||
}
|
||||
|
||||
@@ -1760,65 +1023,11 @@ public final class TeamspeakConnection implements TS3Listener {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the event actually carries a field. ts3j answers a missing key with an
|
||||
* empty string rather than null, so a plain null check is always true — and a
|
||||
* partial update (say, someone muting) would otherwise look like it reported
|
||||
* every other field as well.
|
||||
*/
|
||||
private static boolean has(BaseEvent e, String key) {
|
||||
String value = e.get(key);
|
||||
return value != null && !value.isEmpty();
|
||||
}
|
||||
|
||||
private static int safeInt(BaseEvent e, String key) {
|
||||
try {
|
||||
String v = e.get(key);
|
||||
return v == null ? 0 : Integer.parseInt(v.trim());
|
||||
} catch (Exception ex) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static String orEmpty(String s) {
|
||||
/** Package-private: also used by {@link ConnectionEventHandler} and {@link ConnectionStatsCollector}. */
|
||||
static String orEmpty(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
|
||||
/** Parses a long, returning -1 for null/blank/non-numeric input. */
|
||||
private static long parseLong(String s) {
|
||||
if (s == null || s.isEmpty()) return -1;
|
||||
try {
|
||||
return Long.parseLong(s.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses a double, returning -1 for null/blank/non-numeric input. */
|
||||
private static double parseDouble(String s) {
|
||||
if (s == null || s.isEmpty()) return -1;
|
||||
try {
|
||||
return Double.parseDouble(s.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses a comma-separated id list (e.g. server groups "6,12,15"). */
|
||||
private static int[] parseIntList(String csv) {
|
||||
if (csv == null || csv.isEmpty()) return new int[0];
|
||||
String[] parts = csv.split(",");
|
||||
int[] out = new int[parts.length];
|
||||
int n = 0;
|
||||
for (String p : parts) {
|
||||
try {
|
||||
out[n++] = Integer.parseInt(p.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
return n == parts.length ? out : java.util.Arrays.copyOf(out, n);
|
||||
}
|
||||
|
||||
private static String rootMessage(Throwable t) {
|
||||
Throwable r = t;
|
||||
while (r.getCause() != null && r.getCause() != r) r = r.getCause();
|
||||
|
||||
@@ -9,7 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
/**
|
||||
* Pins the ts3j behaviour {@code TeamspeakConnection.has(...)} exists for: a field
|
||||
* Pins the ts3j behaviour {@code ConnectionEventHandler.has(...)} exists for: a field
|
||||
* the event never carried reads back as an empty string, so a null check would treat
|
||||
* every partial update (someone muting, say) as if it reported every other field too.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user