Compare commits

...

7 Commits

Author SHA1 Message Date
e5b607fc29 Split ServerTab into focused collaborator classes
Extract mic/speaker/away/commander state handling into
ServerTabSelfState, ServerTreePanel.Actions delegation (context
menus, drag-drop, link clicks) into ServerTabTreeActions, and
ConnectionListener event marshaling onto the EDT into
ServerTabConnectionEvents. ServerTab keeps its full public API
(636 -> 377 lines); no behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 13:37:58 +00:00
d722377508 Split SettingsDialog into focused tab panels
Extract the Playback/Capture and Voice Activation tabs (device pickers,
gain/volume sliders, noise reduction, VAD/PTT tuning, microphone test)
into DevicesPanel and VoiceActivationPanel, alongside the existing
NotificationsPanel/HotkeysPanel/IconPackPanel/ClientVersionPanel. A new
FormPanel base holds the shared GridBagLayout scaffolding. SettingsDialog
is now a thin coordinator (753 -> 187 lines); its public API and all
settings read/write behavior are unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 13:37:51 +00:00
b694845381 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>
2026-08-18 13:37:43 +00:00
cf5ba94092 Add Master Volume slider, status bar toggle, and toolbar customization menu
The toolbar now has a right-click menu to hide/show the status bar and
a short Master Volume slider (drag or scroll) that multiplies both
voice and notification volume via new Settings.effectiveOutputVolume()/
effectiveSoundVolume() helpers.

Also splits MainFrame's toolbar, menu bar and status bar construction
into their own MainToolbar/MainMenuBar/StatusBar classes, each talking
back to MainFrame only through a Listener interface.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 13:37:36 +00:00
e5fb87a796 Split ServerTreePanel into focused collaborator classes
Extract cell rendering, right-aligned group-icon-strip painting, the
drop-indicator JTree subclass, and drag-and-drop handling out of
ServerTreePanel into ServerTreeCellRenderer, DropIndicatorTree, and
ServerTreeDragAndDrop, leaving ServerTreePanel as the coordinator
(706 -> 284 lines). Pure refactor, no behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 13:35:42 +00:00
de02f88f1c Add browser-style drag-to-reorder for chat and server tabs
Dragging a tab shows animated snapshots on the glass pane and swaps
with whichever neighbour it reaches the midpoint of; the real tab
model is only touched once, on release, so an in-flight mouse grab
never gets pulled out from under a rebuilt tab component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 12:49:26 +00:00
71084ea309 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>
2026-08-17 21:47:12 +00:00
29 changed files with 3873 additions and 2275 deletions

View File

@@ -95,6 +95,12 @@ public final class Settings {
public boolean music = false; public boolean music = false;
/** Master playback gain, 0..1 (may exceed 1 for boost up to 2). */ /** Master playback gain, 0..1 (may exceed 1 for boost up to 2). */
public double outputVolume = 1.0; public double outputVolume = 1.0;
/**
* Global multiplier on top of {@link #outputVolume} and {@link #soundVolume},
* controlled from the toolbar's Master Volume slider so both can be ridden
* with one control. 0..2, same boost headroom as the other volumes.
*/
public double masterVolume = 1.0;
/** Microphone input gain multiplier applied before VAD/encode. */ /** Microphone input gain multiplier applied before VAD/encode. */
public double inputVolume = 1.0; public double inputVolume = 1.0;
/** Remove steady background noise from the microphone (spectral denoise). */ /** Remove steady background noise from the microphone (spectral denoise). */
@@ -119,6 +125,12 @@ public final class Settings {
/** Extra folder to look for icon packs in, on top of the well-known locations. */ /** Extra folder to look for icon packs in, on top of the well-known locations. */
public String iconPackDir = ""; public String iconPackDir = "";
// ---- window chrome ----
/** Whether the status bar at the bottom of the window is shown. */
public boolean showStatusBar = true;
/** Whether the Master Volume slider is shown on the toolbar. */
public boolean showMasterVolumeSlider = true;
/** Which actions make a sound, and which ones are important enough to survive muting. */ /** Which actions make a sound, and which ones are important enough to survive muting. */
public final NotificationSettings notifications = new NotificationSettings(); public final NotificationSettings notifications = new NotificationSettings();
@@ -155,6 +167,16 @@ public final class Settings {
return new File(identityFile); return new File(identityFile);
} }
/** {@link #outputVolume} scaled by the Master Volume slider. */
public double effectiveOutputVolume() {
return outputVolume * masterVolume;
}
/** {@link #soundVolume} scaled by the Master Volume slider. */
public double effectiveSoundVolume() {
return soundVolume * masterVolume;
}
/** The directory holding all persistent client state (settings, identities, caches). */ /** The directory holding all persistent client state (settings, identities, caches). */
public static File configDir() { public static File configDir() {
return DIR; return DIR;
@@ -185,6 +207,7 @@ public final class Settings {
packetLoss = parseI(props.getProperty("packetLoss"), packetLoss); packetLoss = parseI(props.getProperty("packetLoss"), packetLoss);
music = parseB(props.getProperty("music"), music); music = parseB(props.getProperty("music"), music);
outputVolume = parseD(props.getProperty("outputVolume"), outputVolume); outputVolume = parseD(props.getProperty("outputVolume"), outputVolume);
masterVolume = parseD(props.getProperty("masterVolume"), masterVolume);
inputVolume = parseD(props.getProperty("inputVolume"), inputVolume); inputVolume = parseD(props.getProperty("inputVolume"), inputVolume);
denoise = parseB(props.getProperty("denoise"), denoise); denoise = parseB(props.getProperty("denoise"), denoise);
denoiserLevel = parseD(props.getProperty("denoiserLevel"), denoiserLevel); denoiserLevel = parseD(props.getProperty("denoiserLevel"), denoiserLevel);
@@ -195,6 +218,8 @@ public final class Settings {
soundPackDir = props.getProperty("soundPackDir", soundPackDir); soundPackDir = props.getProperty("soundPackDir", soundPackDir);
iconPack = props.getProperty("iconPack", iconPack); iconPack = props.getProperty("iconPack", iconPack);
iconPackDir = props.getProperty("iconPackDir", iconPackDir); iconPackDir = props.getProperty("iconPackDir", iconPackDir);
showStatusBar = parseB(props.getProperty("showStatusBar"), showStatusBar);
showMasterVolumeSlider = parseB(props.getProperty("showMasterVolumeSlider"), showMasterVolumeSlider);
notifications.load(props); notifications.load(props);
} }
@@ -223,6 +248,7 @@ public final class Settings {
props.setProperty("packetLoss", Integer.toString(packetLoss)); props.setProperty("packetLoss", Integer.toString(packetLoss));
props.setProperty("music", Boolean.toString(music)); props.setProperty("music", Boolean.toString(music));
props.setProperty("outputVolume", Double.toString(outputVolume)); props.setProperty("outputVolume", Double.toString(outputVolume));
props.setProperty("masterVolume", Double.toString(masterVolume));
props.setProperty("inputVolume", Double.toString(inputVolume)); props.setProperty("inputVolume", Double.toString(inputVolume));
props.setProperty("denoise", Boolean.toString(denoise)); props.setProperty("denoise", Boolean.toString(denoise));
props.setProperty("denoiserLevel", Double.toString(denoiserLevel)); props.setProperty("denoiserLevel", Double.toString(denoiserLevel));
@@ -233,6 +259,8 @@ public final class Settings {
props.setProperty("soundPackDir", soundPackDir); props.setProperty("soundPackDir", soundPackDir);
props.setProperty("iconPack", iconPack); props.setProperty("iconPack", iconPack);
props.setProperty("iconPackDir", iconPackDir); props.setProperty("iconPackDir", iconPackDir);
props.setProperty("showStatusBar", Boolean.toString(showStatusBar));
props.setProperty("showMasterVolumeSlider", Boolean.toString(showMasterVolumeSlider));
notifications.store(props); notifications.store(props);
} }

View File

@@ -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);
}
}

View File

@@ -33,4 +33,11 @@ public interface ConnectionListener {
/** Someone poked the local client. */ /** Someone poked the local client. */
void onPoke(String fromName, String message); 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

@@ -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;
}
}
}

View File

@@ -7,11 +7,8 @@ import com.github.manevolent.ts3j.command.SingleCommand;
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter; import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
import com.github.manevolent.ts3j.event.*; import com.github.manevolent.ts3j.event.*;
import com.github.manevolent.ts3j.identity.LocalIdentity; 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.ProtocolRole;
import com.github.manevolent.ts3j.protocol.packet.statistics.PacketStatistics;
import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket; 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.github.manevolent.ts3j.util.Ts3Crypt;
import com.ts3client.audio.AudioBackend; import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.VoiceInput; import com.ts3client.audio.VoiceInput;
@@ -52,11 +49,18 @@ public final class TeamspeakConnection implements TS3Listener {
private final Settings settings; private final Settings settings;
private final AudioBackend audio; private final AudioBackend audio;
private final ServerModel model = new ServerModel(); 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 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 VoiceInput microphone;
private VoiceOutput playback; private VoiceOutput playback;
private LocalIdentity identity; private LocalIdentity identity;
@@ -64,7 +68,8 @@ public final class TeamspeakConnection implements TS3Listener {
private volatile boolean connected; private volatile boolean connected;
private volatile int selfClientId = -1; 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 String serverHost;
private volatile int serverPort; private volatile int serverPort;
@@ -83,9 +88,6 @@ public final class TeamspeakConnection implements TS3Listener {
private volatile boolean disconnectAnnounced = true; private volatile boolean disconnectAnnounced = true;
private volatile long lastMutedTalkNanos; 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, public TeamspeakConnection(Settings settings, AudioBackend audio, ConnectionListener ui,
SoundNotifier sounds) { SoundNotifier sounds) {
this.settings = settings; this.settings = settings;
@@ -205,7 +207,7 @@ public final class TeamspeakConnection implements TS3Listener {
model.clear(); model.clear();
playback = audio.createOutput(settings); playback = audio.createOutput(settings);
playback.setMasterVolume(settings.outputVolume); playback.setMasterVolume(settings.effectiveOutputVolume());
playback.setTalkListener((clientId, talking) -> { playback.setTalkListener((clientId, talking) -> {
ClientEntry c = model.getClient(clientId); ClientEntry c = model.getClient(clientId);
if (c != null) c.talking = talking; if (c != null) c.talking = talking;
@@ -233,6 +235,7 @@ public final class TeamspeakConnection implements TS3Listener {
client.setNickname(nickname); client.setNickname(nickname);
client.setHWID("ts3jswing-" + Integer.toHexString(nickname.hashCode())); client.setHWID("ts3jswing-" + Integer.toHexString(nickname.hashCode()));
client.addListener(this); client.addListener(this);
client.addListener(events);
client.setVoiceHandler(playback::handleVoice); client.setVoiceHandler(playback::handleVoice);
client.setWhisperHandler(playback::handleWhisper); client.setWhisperHandler(playback::handleWhisper);
client.setExceptionHandler(t -> { client.setExceptionHandler(t -> {
@@ -486,7 +489,7 @@ public final class TeamspeakConnection implements TS3Listener {
client = null; client = null;
fileTransfers = null; fileTransfers = null;
selfClientId = -1; selfClientId = -1;
pendingConnInfo.clear(); stats.clear();
if (ft != null) { if (ft != null) {
try { try {
@@ -774,398 +777,6 @@ public final class TeamspeakConnection implements TS3Listener {
}, "ts3j-rename").start(); }, "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);
ui.onModelChanged();
}
@Override
public void onClientLeave(ClientLeaveEvent e) {
if (e.getClientId() == selfClientId) announceOwnRemoval(safeInt(e, "reasonid"), e);
else announceClientLeft(e);
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());
ui.onModelChanged();
}
}
// ---- 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);
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);
ui.onModelChanged();
}
/** Renames, talk-power changes and recording flags all arrive as a client update. */
private void announceClientUpdate(ClientUpdatedEvent e, ClientEntry c, boolean renamed) {
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);
}
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")));
}
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")));
}
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")));
}
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")));
}
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()));
}
@Override
public void onServerGroupClientDeleted(ServerGroupClientDeletedEvent e) {
boolean self = safeInt(e, "clid") == 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")));
}
@Override
public void onClientChannelGroupChanged(ClientChannelGroupChangedEvent e) {
ClientEntry c = model.getClient(e.getClientId());
if (c != null) c.channelGroupId = e.getChannelGroupId();
boolean self = e.getClientId() == selfClientId;
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())));
ui.onModelChanged();
}
/** 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. */ /** Fetches a channel's description (and topic) on demand, then notifies the UI. */
public void requestChannelInfo(int channelId) { public void requestChannelInfo(int channelId) {
new Thread(() -> { new Thread(() -> {
@@ -1304,262 +915,17 @@ public final class TeamspeakConnection implements TS3Listener {
*/ */
public void requestConnectionInfo(int clientId, Consumer<ConnectionStats> callback) { public void requestConnectionInfo(int clientId, Consumer<ConnectionStats> callback) {
if (client == null || !connected) return; if (client == null || !connected) return;
if (clientId == selfClientId) { stats.request(clientId, callback);
new Thread(() -> callback.accept(buildLocalStats()), "ts3j-conninfo-self").start();
} else {
new Thread(() -> requestRemoteConnInfo(clientId, callback), "ts3j-conninfo").start();
}
} }
/** Builds a live snapshot of the local client's connection from its own counters. */ // ---- events not handled by ConnectionEventHandler ----
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;
}
/** /**
* Requests a remote client's connection info. First loads {@code clientinfo} * Kept here rather than in {@link ConnectionEventHandler}: teardown needs
* for the stable fields, then issues {@code getconnectioninfo} whose * {@link #safeCleanup()} and the {@code connected}/announced flags this class
* {@code notifyconnectioninfo} report arrives asynchronously via * already owns, so routing it through the event handler would only add an
* {@link #onUnknownEvent}. If the report does not arrive shortly (e.g. the * extra hop back into private lifecycle state.
* server withholds it), the clientinfo-only snapshot is delivered instead.
*/ */
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 @Override
public void onDisconnected(DisconnectedEvent e) { public void onDisconnected(DisconnectedEvent e) {
connected = false; connected = false;
@@ -1573,18 +939,29 @@ public final class TeamspeakConnection implements TS3Listener {
/** /**
* Plays the sound pack's entry for an action. Muting is passed along so the * 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); 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); sounds.fire(event, deafened, variables);
} }
/** Records a line in the server tab's log, the way native TS3 reports server activity. */
void log(String message) {
if (connected) ui.onServerLog(message);
}
String channelName(int channelId) {
ChannelNode ch = model.getChannel(channelId);
return ch != null ? ch.name : "channel #" + channelId;
}
/** The placeholder values a pack may reference for an action involving a client. */ /** 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); ClientEntry c = model.getClient(clientId);
Map<String, String> vars = SoundNotifier.vars( Map<String, String> vars = SoundNotifier.vars(
"servername", model.getServerName(), "servername", model.getServerName(),
@@ -1597,7 +974,7 @@ public final class TeamspeakConnection implements TS3Listener {
return vars; return vars;
} }
private Map<String, String> channelVars(int channelId, String invokerName) { Map<String, String> channelVars(int channelId, String invokerName) {
ChannelNode ch = model.getChannel(channelId); ChannelNode ch = model.getChannel(channelId);
return SoundNotifier.vars( return SoundNotifier.vars(
"servername", model.getServerName(), "servername", model.getServerName(),
@@ -1605,7 +982,7 @@ public final class TeamspeakConnection implements TS3Listener {
"clientname", orEmpty(invokerName)); "clientname", orEmpty(invokerName));
} }
private Map<String, String> serverVars() { Map<String, String> serverVars() {
return SoundNotifier.vars("servername", model.getServerName()); return SoundNotifier.vars("servername", model.getServerName());
} }
@@ -1623,15 +1000,17 @@ public final class TeamspeakConnection implements TS3Listener {
return m.contains("insufficient") || m.contains("permission"); return m.contains("insufficient") || m.contains("permission");
} }
/** Whether the client is in the channel we are in ourselves. */ /** Whether the client is in the channel we are in ourselves. Package-private: also
private boolean inOwnChannel(int channelId) { * used by {@link ConnectionEventHandler}. */
boolean inOwnChannel(int channelId) {
ClientEntry self = model.getClient(selfClientId); ClientEntry self = model.getClient(selfClientId);
return self != null && self.channelId == channelId; return self != null && self.channelId == channelId;
} }
// ---- helpers ---- // ---- 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)); return safeLong(e.get(key));
} }
@@ -1644,65 +1023,11 @@ public final class TeamspeakConnection implements TS3Listener {
} }
} }
/** /** Package-private: also used by {@link ConnectionEventHandler} and {@link ConnectionStatsCollector}. */
* Whether the event actually carries a field. ts3j answers a missing key with an static String orEmpty(String s) {
* 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) {
return s == null ? "" : 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) { private static String rootMessage(Throwable t) {
Throwable r = t; Throwable r = t;
while (r.getCause() != null && r.getCause() != r) r = r.getCause(); while (r.getCause() != null && r.getCause() != r) r = r.getCause();

View File

@@ -113,7 +113,7 @@ public final class SoundNotifier {
if (script == null) return; if (script == null) return;
String resolved = script.resolve(withDefaults(variables)); String resolved = script.resolve(withDefaults(variables));
double volume = Math.max(0, Math.min(1.0, settings.soundVolume)); double volume = Math.max(0, Math.min(1.0, settings.effectiveSoundVolume()));
if (volume <= 0) return; if (volume <= 0) return;
if (script.kind() == SoundScript.Kind.SAY) { if (script.kind() == SoundScript.Kind.SAY) {

View File

@@ -9,7 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; 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 * 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. * every partial update (someone muting, say) as if it reported every other field too.
*/ */

View File

@@ -4,6 +4,7 @@ import com.ts3client.text.BBCode;
import com.ts3client.text.TsLink; import com.ts3client.text.TsLink;
import javax.swing.BorderFactory; import javax.swing.BorderFactory;
import javax.swing.Icon;
import javax.swing.JButton; import javax.swing.JButton;
import javax.swing.JEditorPane; import javax.swing.JEditorPane;
import javax.swing.JLabel; import javax.swing.JLabel;
@@ -25,6 +26,7 @@ import java.awt.FlowLayout;
import java.awt.Point; import java.awt.Point;
import java.awt.event.MouseAdapter; import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent; import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;
import java.io.IOException; import java.io.IOException;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.Date; import java.util.Date;
@@ -62,6 +64,10 @@ public final class ChatPanel extends JPanel {
private final Tab channelTab = new Tab(Target.CHANNEL, 0, "Channel"); private final Tab channelTab = new Tab(Target.CHANNEL, 0, "Channel");
/** Private chats keyed by the peer's client id. */ /** Private chats keyed by the peer's client id. */
private final Map<Integer, Tab> privateTabs = new LinkedHashMap<>(); private final Map<Integer, Tab> privateTabs = new LinkedHashMap<>();
/** Static-content tabs (e.g. a moved-out description), keyed by caller-chosen id. */
private final Map<String, Tab> noteTabs = new LinkedHashMap<>();
private final TabDragReorder dragReorder = new TabDragReorder(tabs, this::moveTab);
private SendHandler sendHandler; private SendHandler sendHandler;
private LinkHandler linkHandler; private LinkHandler linkHandler;
@@ -70,6 +76,7 @@ public final class ChatPanel extends JPanel {
super(new BorderLayout()); super(new BorderLayout());
tabs.setFont(Theme.UI_FONT); tabs.setFont(Theme.UI_FONT);
tabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT);
addTab(serverTab, false); addTab(serverTab, false);
addTab(channelTab, false); addTab(channelTab, false);
tabs.setSelectedIndex(0); tabs.setSelectedIndex(0);
@@ -77,6 +84,8 @@ public final class ChatPanel extends JPanel {
Tab t = selectedTab(); Tab t = selectedTab();
if (t != null) t.setUnread(false); if (t != null) t.setUnread(false);
}); });
tabs.addMouseWheelListener(this::onWheel);
dragReorder.attach(tabs);
add(tabs, BorderLayout.CENTER); add(tabs, BorderLayout.CENTER);
JPanel bottom = new JPanel(new BorderLayout(4, 0)); JPanel bottom = new JPanel(new BorderLayout(4, 0));
@@ -130,6 +139,40 @@ public final class ChatPanel extends JPanel {
}); });
} }
/** Drops every static-content tab (moved-out descriptions), e.g. after disconnecting. */
public void closeNoteTabs() {
edt(() -> {
for (Tab t : noteTabs.values()) tabs.remove(t.scroll);
noteTabs.clear();
});
}
/** Closes a single static-content tab by its key, if open; does not run its {@code onClose}. */
public void closeNoteTab(String key) {
edt(() -> {
Tab tab = noteTabs.remove(key);
if (tab != null) tabs.remove(tab.scroll);
});
}
/**
* Opens (or replaces the content of, and focuses) a static-content tab, e.g. a
* channel/client description moved out of the info panel. {@code onClose} runs
* when the tab is closed, so the caller can un-hide the description again.
*/
public void openDescriptionTab(String key, Icon icon, String title, String htmlBody, Runnable onClose) {
edt(() -> {
Tab tab = noteTabs.get(key);
if (tab == null) {
tab = new Tab(key, icon, title, onClose);
noteTabs.put(key, tab);
addTab(tab, true);
}
tab.setStaticContent(htmlBody);
tabs.setSelectedComponent(tab.scroll);
});
}
private void fireSend() { private void fireSend() {
String text = input.getText().trim(); String text = input.getText().trim();
if (text.isEmpty() || sendHandler == null) return; if (text.isEmpty() || sendHandler == null) return;
@@ -147,6 +190,12 @@ public final class ChatPanel extends JPanel {
+ BBCode.escape(text) + "</span>")); + BBCode.escape(text) + "</span>"));
} }
/** Server-side events (client joins/leaves/moves, group changes, channel edits, …). */
public void appendServerLog(String text) {
edt(() -> serverTab.appendLine("<span style=\"color:" + hex(Theme.CHANNEL_TEXT) + "\">" + stamp()
+ BBCode.escape(text) + "</span>"));
}
public void appendServerMessage(int fromId, String from, String text) { public void appendServerMessage(int fromId, String from, String text) {
edt(() -> serverTab.appendMessage(fromId, from, text)); edt(() -> serverTab.appendMessage(fromId, from, text));
} }
@@ -180,10 +229,40 @@ public final class ChatPanel extends JPanel {
} }
private void closeTab(Tab tab) { private void closeTab(Tab tab) {
if (tab.noteKey != null) {
noteTabs.remove(tab.noteKey);
if (tab.onClose != null) tab.onClose.run();
} else {
privateTabs.remove(tab.clientId); privateTabs.remove(tab.clientId);
}
tabs.remove(tab.scroll); tabs.remove(tab.scroll);
} }
/** Drags a tab from one position to another, keeping the current selection on screen. */
private void moveTab(int from, int to) {
if (from < 0 || to < 0 || from >= tabs.getTabCount() || to >= tabs.getTabCount() || from == to) return;
Component content = tabs.getComponentAt(from);
String title = tabs.getTitleAt(from);
Icon icon = tabs.getIconAt(from);
String tip = tabs.getToolTipTextAt(from);
Component header = tabs.getTabComponentAt(from);
boolean wasSelected = tabs.getSelectedIndex() == from;
tabs.removeTabAt(from);
tabs.insertTab(title, icon, content, tip, to);
tabs.setTabComponentAt(to, header);
if (wasSelected) tabs.setSelectedIndex(to);
}
private void onWheel(MouseWheelEvent e) {
Component content = tabs.getSelectedComponent();
if (content != null && content.getBounds().contains(e.getPoint())) return;
int steps = e.getWheelRotation();
if (steps == 0) return;
int target = Math.max(0, Math.min(tabs.getTabCount() - 1, tabs.getSelectedIndex() + steps));
if (target != tabs.getSelectedIndex()) tabs.setSelectedIndex(target);
e.consume();
}
private Tab selectedTab() { private Tab selectedTab() {
Component c = tabs.getSelectedComponent(); Component c = tabs.getSelectedComponent();
if (c == serverTab.scroll) return serverTab; if (c == serverTab.scroll) return serverTab;
@@ -207,13 +286,29 @@ public final class ChatPanel extends JPanel {
final String title; final String title;
final JEditorPane log = new JEditorPane(); final JEditorPane log = new JEditorPane();
final JScrollPane scroll; final JScrollPane scroll;
/** Set for a static-content tab (e.g. a moved-out description); null for a conversation. */
final String noteKey;
final Runnable onClose;
private final Icon icon;
private final HTMLDocument doc; private final HTMLDocument doc;
private JLabel titleLabel; private JLabel titleLabel;
Tab(Target target, int clientId, String title) { Tab(Target target, int clientId, String title) {
this(target, clientId, title, null, null, null);
}
/** A static-content tab: no send target, closing it runs {@code onClose}. */
Tab(String noteKey, Icon icon, String title, Runnable onClose) {
this(null, 0, title, icon, noteKey, onClose);
}
private Tab(Target target, int clientId, String title, Icon icon, String noteKey, Runnable onClose) {
this.target = target; this.target = target;
this.clientId = clientId; this.clientId = clientId;
this.title = title; this.title = title;
this.icon = icon;
this.noteKey = noteKey;
this.onClose = onClose;
HTMLEditorKit kit = new HTMLEditorKit(); HTMLEditorKit kit = new HTMLEditorKit();
StyleSheet css = new StyleSheet(); StyleSheet css = new StyleSheet();
@@ -259,9 +354,11 @@ public final class ChatPanel extends JPanel {
Component header(boolean closable) { Component header(boolean closable) {
JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0)); JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
p.setOpaque(false); p.setOpaque(false);
titleLabel = new JLabel(title); titleLabel = new JLabel(title, icon, JLabel.LEADING);
titleLabel.setFont(Theme.UI_FONT); titleLabel.setFont(Theme.UI_FONT);
p.add(titleLabel); p.add(titleLabel);
dragReorder.attach(p);
dragReorder.attach(titleLabel);
if (closable) { if (closable) {
JLabel close = new JLabel("×"); JLabel close = new JLabel("×");
close.setFont(Theme.UI_BOLD); close.setFont(Theme.UI_BOLD);
@@ -303,6 +400,12 @@ public final class ChatPanel extends JPanel {
+ sender + ": " + BBCode.toHtml(text)); + sender + ": " + BBCode.toHtml(text));
} }
/** Replaces the whole log with fixed content; used by static-content tabs. */
void setStaticContent(String html) {
log.setText("<html><body><div id=\"chatlog\">" + html + "</div></body></html>");
log.setCaretPosition(0);
}
void appendLine(String html) { void appendLine(String html) {
try { try {
doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>"); doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>");

View File

@@ -44,6 +44,9 @@ final class ClientMenu {
menu.add(me); menu.add(me);
} }
menu.addSeparator(); menu.addSeparator();
JMenuItem findInTree = new JMenuItem("Find Client in Channel Tree", Icons.of("PLAYER_ON"));
findInTree.addActionListener(a -> actions.findClientInTree(client));
menu.add(findInTree);
JMenuItem info = new JMenuItem("Connection Info", Icons.of("INFO")); JMenuItem info = new JMenuItem("Connection Info", Icons.of("INFO"));
info.addActionListener(a -> actions.showConnectionInfo(client)); info.addActionListener(a -> actions.showConnectionInfo(client));
menu.add(info); menu.add(info);

View File

@@ -0,0 +1,160 @@
package com.ts3client.ui;
import com.ts3client.audio.VoiceInput;
import com.ts3client.audio.VoiceOutput;
import com.ts3client.audio.desktop.AudioDevices;
import com.ts3client.config.Settings;
import javax.swing.Box;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JSlider;
import java.awt.GridBagConstraints;
import java.awt.Insets;
import java.util.List;
import java.util.function.Consumer;
/**
* Options dialog's "Playback / Capture" tab: device pickers, gain/volume, and the
* noise-reduction pre-processing options. Gain and pre-processing changes are pushed
* live via {@code applyLive}, which reaches both the connected microphone and the
* dialog's own microphone test.
*/
final class DevicesPanel extends FormPanel {
private final JComboBox<AudioDevices.Device> inputCombo;
private final JComboBox<AudioDevices.Device> outputCombo;
private final JSlider inputGain;
private final JSlider outputVol;
private final JCheckBox denoiseCheck;
private final JSlider denoiseLevel;
private final JCheckBox typingCheck;
private final JCheckBox agcCheck;
DevicesPanel(Settings settings, VoiceOutput livePlayback,
Consumer<Consumer<VoiceInput>> applyLive,
Runnable onInputDeviceChanged, Consumer<String> onOutputDeviceChanged) {
GridBagConstraints c = gbc();
List<AudioDevices.Device> ins = AudioDevices.inputDevices();
List<AudioDevices.Device> outs = AudioDevices.outputDevices();
inputCombo = new JComboBox<>(ins.toArray(new AudioDevices.Device[0]));
outputCombo = new JComboBox<>(outs.toArray(new AudioDevices.Device[0]));
selectOrDefault(inputCombo, settings.inputDevice);
selectOrDefault(outputCombo, settings.outputDevice);
String deviceHint = "<html>Named devices are PipeWire's, and are routed through it "
+ "(so per-application volume and rerouting keep working).<br>"
+ "<b>ALSA:</b> entries talk to the sound card directly, taking it exclusively.</html>";
inputCombo.setToolTipText(deviceHint);
outputCombo.setToolTipText(deviceHint);
limitWidth(inputCombo, FIELD_WIDTH);
limitWidth(outputCombo, FIELD_WIDTH);
int row = 0;
addRow(this, c, row++, new JLabel("Capture device (microphone):"), inputCombo);
addRow(this, c, row++, new JLabel("Playback device (speakers):"), outputCombo);
inputGain = new JSlider(0, 200, (int) Math.round(settings.inputVolume * 100));
outputVol = new JSlider(0, 200, (int) Math.round(settings.outputVolume * 100));
limitWidth(inputGain, SLIDER_WIDTH);
limitWidth(outputVol, SLIDER_WIDTH);
addRow(this, c, row++, new JLabel("Microphone gain:"), inputGain);
addRow(this, c, row++, new JLabel("Playback volume:"), outputVol);
outputVol.addChangeListener(e -> {
if (livePlayback != null) livePlayback.setMasterVolume(outputVol.getValue() / 100.0);
});
inputGain.addChangeListener(e ->
applyLive.accept(m -> m.setInputGain(inputGain.getValue() / 100.0)));
inputCombo.addActionListener(e -> onInputDeviceChanged.run());
outputCombo.addActionListener(e -> onOutputDeviceChanged.accept(comboValue(outputCombo)));
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
c.insets = new Insets(14, 4, 2, 4);
add(new JLabel("Noise reduction"), c);
c.insets = new Insets(4, 4, 4, 4);
c.gridwidth = 1;
denoiseCheck = new JCheckBox("Remove background noise", settings.denoise);
denoiseCheck.setToolTipText("Attempt to filter out background noises.");
denoiseLevel = new JSlider(0, 100, (int) Math.round(settings.denoiserLevel * 100));
limitWidth(denoiseLevel, SLIDER_WIDTH);
denoiseLevel.setToolTipText("Higher = more aggressive noise removal.");
typingCheck = new JCheckBox("Typing attenuation", settings.typingAttenuation);
typingCheck.setToolTipText("<html><b>Typing attenuation</b> tries to detect and "
+ "reduce the sounds made by typing.</html>");
agcCheck = new JCheckBox("Automatic gain control (AGC)", settings.agc);
agcCheck.setToolTipText("<html><b>Automatic gain control</b> normalises your "
+ "microphone loudness to a target level, boosting quiet mics and taming "
+ "loud ones.</html>");
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
add(denoiseCheck, c);
c.gridwidth = 1;
addRow(this, c, row++, new JLabel("Noise removal level:"), denoiseLevel);
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
add(typingCheck, c);
c.gridy = row++;
add(agcCheck, c);
c.gridwidth = 1;
Runnable syncNoise = () -> {
denoiseLevel.setEnabled(denoiseCheck.isSelected());
applyLive.accept(m -> {
m.setNoiseSuppression(denoiseCheck.isSelected());
m.setDenoiserLevel(denoiseLevel.getValue() / 100.0);
m.setTypingAttenuation(typingCheck.isSelected());
m.setAgc(agcCheck.isSelected());
});
};
denoiseCheck.addActionListener(e -> syncNoise.run());
typingCheck.addActionListener(e -> syncNoise.run());
agcCheck.addActionListener(e -> syncNoise.run());
denoiseLevel.addChangeListener(e ->
applyLive.accept(m -> m.setDenoiserLevel(denoiseLevel.getValue() / 100.0)));
syncNoise.run();
c.gridx = 0;
c.gridy = row;
c.weighty = 1;
add(Box.createGlue(), c);
}
/** Copies the form into {@code target}, without touching anything else. */
void writeInto(Settings target) {
target.inputDevice = comboValue(inputCombo);
target.outputDevice = comboValue(outputCombo);
target.inputVolume = inputGain.getValue() / 100.0;
target.outputVolume = outputVol.getValue() / 100.0;
target.denoise = denoiseCheck.isSelected();
target.denoiserLevel = denoiseLevel.getValue() / 100.0;
target.typingAttenuation = typingCheck.isSelected();
target.agc = agcCheck.isSelected();
}
private static void selectOrDefault(JComboBox<AudioDevices.Device> combo, String deviceId) {
if (deviceId != null && !deviceId.isEmpty()) {
for (int i = 0; i < combo.getItemCount(); i++) {
if (deviceId.equals(combo.getItemAt(i).id())) {
combo.setSelectedIndex(i);
return;
}
}
}
combo.setSelectedIndex(0);
}
private static String comboValue(JComboBox<AudioDevices.Device> combo) {
AudioDevices.Device d = (AudioDevices.Device) combo.getSelectedItem();
return d == null ? "" : d.id();
}
}

View File

@@ -0,0 +1,165 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.ServerModel;
import javax.swing.Icon;
import javax.swing.JTree;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.TreeModel;
import javax.swing.tree.TreePath;
import java.awt.BasicStroke;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.util.List;
/**
* The {@link ServerTreePanel} tree, extended to draw where a drag-and-drop would
* land: an insertion line between rows, or an outline around the row that will
* receive the dragged node. Also keeps the server row permanently expanded and
* paints the right-aligned group icon strip, both of which need to hook into
* this component's own paint cycle.
*/
final class DropIndicatorTree extends JTree {
/** Gap kept between a row's label and the right-aligned icon strip. */
private static final int BADGE_GAP = 8;
/** Inset of the strip from the visible right edge. */
private static final int BADGE_MARGIN = 4;
private final ServerModel model;
private final GroupIcons groupIcons;
/** Set while a drop would move a client into this channel row. */
private TreePath highlight;
DropIndicatorTree(TreeModel treeModel, ServerModel model, GroupIcons groupIcons) {
super(treeModel);
this.model = model;
this.groupIcons = groupIcons;
}
/** Keeps the server row permanently open; collapsing it would hide everything. */
@Override
public void setExpandedState(TreePath path, boolean state) {
if (!state && path.getPathCount() == 1) return;
super.setExpandedState(path, state);
}
/**
* Widens every repaint request to the full visible width. Swing only asks for
* the row rectangle, which stops short of the right-aligned icon strip and would
* leave it behind when a row's label changes width.
*/
@Override
public void repaint(long tm, int x, int y, int width, int height) {
Rectangle visible = getVisibleRect();
super.repaint(tm, visible.x, y, visible.width, height);
}
void highlightChannel(TreePath path) {
if (path == highlight || (path != null && path.equals(highlight))) return;
highlight = path;
repaint();
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
paintBadges(g);
JTree.DropLocation loc = getDropLocation();
if (loc == null || loc.getPath() == null) return;
Graphics2D g2 = (Graphics2D) g.create();
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(Theme.ACCENT);
if (highlight != null || loc.getChildIndex() < 0) {
Rectangle r = getPathBounds(highlight != null ? highlight : loc.getPath());
if (r != null) {
g2.setStroke(new BasicStroke(2f));
g2.drawRoundRect(r.x, r.y + 1, r.width - 1, r.height - 3, 4, 4);
}
} else {
Rectangle line = insertLine(loc);
if (line != null) {
g2.fillRect(line.x, line.y - 1, line.width, 2);
g2.fillOval(line.x - 3, line.y - 4, 7, 7);
}
}
g2.dispose();
}
/** The 1px-tall strip where the insertion line goes, in tree coordinates. */
private Rectangle insertLine(JTree.DropLocation loc) {
DefaultMutableTreeNode parent = (DefaultMutableTreeNode) loc.getPath().getLastPathComponent();
int index = loc.getChildIndex();
if (index < parent.getChildCount()) {
Rectangle r = getPathBounds(loc.getPath().pathByAddingChild(parent.getChildAt(index)));
return r == null ? null : new Rectangle(r.x, r.y, getWidth() - r.x, 2);
}
if (parent.getChildCount() == 0) {
Rectangle r = getPathBounds(loc.getPath());
if (r == null) return null;
int x = r.x + getRowHeight();
return new Rectangle(x, r.y + r.height, getWidth() - x, 2);
}
// Past the last child: below that child's whole (expanded) subtree.
TreePath lastChild = loc.getPath().pathByAddingChild(parent.getChildAt(parent.getChildCount() - 1));
Rectangle head = getPathBounds(lastChild);
Rectangle tail = getPathBounds(lastVisibleRow(lastChild));
if (head == null || tail == null) return null;
return new Rectangle(head.x, tail.y + tail.height, getWidth() - head.x, 2);
}
private TreePath lastVisibleRow(TreePath path) {
int row = getRowForPath(path);
if (row < 0) return path;
for (int i = row + 1; i < getRowCount(); i++) {
if (!path.isDescendant(getPathForRow(i))) break;
row = i;
}
return getPathForRow(row);
}
/**
* Paints the icons of every visible row — a client's group icons, a channel's
* own icon — flush with the right edge of the viewport, the way TeamSpeak lines
* them up. Drawing them separately from the cell renderer keeps the rows'
* measured widths (and thus the selection highlight) tied to the label alone.
*/
private void paintBadges(Graphics g) {
Rectangle visible = getVisibleRect();
int right = visible.x + visible.width - BADGE_MARGIN;
for (int row = 0; row < getRowCount(); row++) {
Rectangle bounds = getRowBounds(row);
if (bounds == null || bounds.y + bounds.height < visible.y) continue;
if (bounds.y > visible.y + visible.height) break;
TreePath path = getPathForRow(row);
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
List<Icon> icons = badgesOf(obj);
if (icons.isEmpty()) continue;
GroupIcons.Row strip = new GroupIcons.Row(icons);
int x = Math.max(bounds.x + bounds.width + BADGE_GAP, right - strip.getIconWidth());
strip.paintIcon(this, g, x, bounds.y + (bounds.height - strip.getIconHeight()) / 2);
}
}
/** The icon strip a row shows on its right, empty when it has none (yet). */
private List<Icon> badgesOf(Object node) {
if (node instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) node;
return groupIcons.iconsOf(
model.serverGroupsOf(cl.serverGroupIds), model.channelGroup(cl.channelGroupId));
}
if (node instanceof ChannelNode) {
ChannelNode ch = (ChannelNode) node;
Icon icon = Spacers.isSpacer(ch.name) ? null : groupIcons.icon(ch.iconId);
if (icon != null) return List.of(icon);
}
return List.of();
}
}

View File

@@ -0,0 +1,104 @@
package com.ts3client.ui;
import javax.swing.BorderFactory;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSlider;
import javax.swing.Scrollable;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Rectangle;
/**
* Base for a settings tab's field grid: follows the scroll pane's width instead of
* demanding its own preferred one, so rows stay inside the dialog instead of scrolling
* sideways. Also holds the small GridBagLayout helpers every such tab needs.
*/
class FormPanel extends JPanel implements Scrollable {
static final int FIELD_WIDTH = 240;
static final int SLIDER_WIDTH = 200;
static final int MIN_FIELD_WIDTH = 60;
FormPanel() {
super(new GridBagLayout());
setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
}
@Override
public Dimension getPreferredScrollableViewportSize() {
return getPreferredSize();
}
@Override
public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) {
return 16;
}
@Override
public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) {
return visible.height;
}
@Override
public boolean getScrollableTracksViewportWidth() {
return true;
}
@Override
public boolean getScrollableTracksViewportHeight() {
return false;
}
static GridBagConstraints gbc() {
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(4, 4, 4, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
return c;
}
static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, Component field) {
c.gridx = 0;
c.gridy = row;
c.weightx = 0;
c.gridwidth = 1;
p.add(label, c);
c.gridx = 1;
c.weightx = 1;
p.add(field, c);
}
/**
* Caps a field's preferred width and lets it shrink: long device names and wide sliders
* would otherwise force the form past the dialog's edge, where the scroll pane (which
* never scrolls horizontally) simply clips them.
*/
static void limitWidth(JComponent comp, int preferredWidth) {
int height = comp.getPreferredSize().height;
comp.setPreferredSize(new Dimension(preferredWidth, height));
comp.setMinimumSize(new Dimension(MIN_FIELD_WIDTH, height));
}
static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel) {
return sliderWithLabel(slider, valueLabel, 48);
}
/**
* Pairs a slider with its value readout. The readout gets a fixed width wide enough for
* the longest value so the slider does not jump around as it is dragged.
*/
static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel, int labelWidth) {
JPanel panel = new JPanel(new BorderLayout(6, 0));
limitWidth(slider, SLIDER_WIDTH);
panel.add(slider, BorderLayout.CENTER);
valueLabel.setPreferredSize(new Dimension(labelWidth, valueLabel.getPreferredSize().height));
panel.add(valueLabel, BorderLayout.EAST);
return panel;
}
}

View File

@@ -243,6 +243,31 @@ public final class Icons {
return themed("ACTIVATE_MICROPHONE", Icons::paintMicActive); return themed("ACTIVATE_MICROPHONE", Icons::paintMicActive);
} }
/**
* A fixed-width "channel icon / edit icon" label icon, used where a tab must not
* change width as the object it shows changes (e.g. the info-panel-in-chat tab).
*/
public static ImageIcon channelClientPair() {
ImageIcon channel = channel(true);
ImageIcon edit = themed("EDIT", Icons::paintEdit);
int gap = 8;
int width = channel.getIconWidth() + gap + edit.getIconWidth();
int height = Math.max(channel.getIconHeight(), edit.getIconHeight());
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.drawImage(channel.getImage(), 0, (height - channel.getIconHeight()) / 2, null);
g.setColor(Theme.IDLE_CLIENT);
g.setFont(Theme.UI_FONT);
java.awt.FontMetrics fm = g.getFontMetrics();
int slashX = channel.getIconWidth() + (gap - fm.stringWidth("/")) / 2;
g.drawString("/", slashX, (height + fm.getAscent()) / 2 - 1);
g.drawImage(edit.getImage(), channel.getIconWidth() + gap,
(height - edit.getIconHeight()) / 2, null);
g.dispose();
return new ImageIcon(img);
}
/** The toolbar's away marker. */ /** The toolbar's away marker. */
public static ImageIcon away() { public static ImageIcon away() {
return themed("AWAY", IconTheme.TOOLBAR_SIZE, g -> paintPerson(g, Theme.AWAY)); return themed("AWAY", IconTheme.TOOLBAR_SIZE, g -> paintPerson(g, Theme.AWAY));
@@ -398,6 +423,18 @@ public final class Icons {
g.drawLine(8, 11, 8, 13); g.drawLine(8, 11, 8, 13);
} }
/** A pencil glyph, drawn when no icon pack ships an "EDIT" icon. */
private static void paintEdit(Graphics2D g) {
g.setColor(new Color(0x37474F));
g.setStroke(new BasicStroke(1.4f));
g.drawLine(3, 13, 11, 5);
g.drawLine(3, 13, 4, 10);
g.drawLine(4, 10, 11, 3);
g.drawLine(11, 3, 13, 5);
g.drawLine(13, 5, 11, 7);
g.drawLine(11, 5, 13, 7);
}
private static void paintSettings(Graphics2D g) { private static void paintSettings(Graphics2D g) {
g.setColor(new Color(0x37474F)); g.setColor(new Color(0x37474F));
g.setStroke(new BasicStroke(2f)); g.setStroke(new BasicStroke(2f));

View File

@@ -20,7 +20,27 @@ import java.util.List;
*/ */
public final class InfoPanel extends JScrollPane { public final class InfoPanel extends JScrollPane {
/** Moves the whole info panel's content out to a persistent chat tab. */
public interface DescriptionHandler {
/** Shows/updates {@code html} in the info chat tab, creating it if needed. */
void showInfoTab(String html, Runnable onClose);
/** Closes the info chat tab (the user chose to show details in the panel again). */
void closeInfoTab();
/** Hides (or restores) the panel itself, freeing its space, while its content lives in the chat tab. */
void setPanelHidden(boolean hidden);
}
private static final String TOGGLE_HREF = "app:toggle-info-tab";
private final JEditorPane pane = new JEditorPane(); private final JEditorPane pane = new JEditorPane();
private DescriptionHandler descriptionHandler;
private ChannelNode shownChannel;
private ClientEntry shownClient;
private ServerModel shownModel;
private IconRepository shownIcons;
private boolean inChatTab;
public InfoPanel() { public InfoPanel() {
javax.swing.text.html.HTMLEditorKit kit = new javax.swing.text.html.HTMLEditorKit(); javax.swing.text.html.HTMLEditorKit kit = new javax.swing.text.html.HTMLEditorKit();
@@ -39,7 +59,12 @@ public final class InfoPanel extends JScrollPane {
pane.setBackground(Theme.CHAT_BG); pane.setBackground(Theme.CHAT_BG);
pane.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8)); pane.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
pane.addHyperlinkListener(e -> { pane.addHyperlinkListener(e -> {
if (e.getEventType() == javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) { if (e.getEventType() != javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) return;
if (TOGGLE_HREF.equals(e.getDescription())) {
inChatTab = !inChatTab;
if (!inChatTab && descriptionHandler != null) descriptionHandler.closeInfoTab();
render();
} else {
Links.open(e.getDescription(), this); Links.open(e.getDescription(), this);
} }
}); });
@@ -49,10 +74,54 @@ public final class InfoPanel extends JScrollPane {
} }
public void clear() { public void clear() {
setHtml("<i style='color:#8a8a8a'>Select a channel or client to see details.</i>"); shownChannel = null;
shownClient = null;
inChatTab = false;
render();
}
public void setDescriptionHandler(DescriptionHandler handler) {
this.descriptionHandler = handler;
}
/** Runs when the user closes the info chat tab; shows details in the panel again. */
private void onInfoTabClosed() {
inChatTab = false;
render();
} }
public void showChannel(ChannelNode ch) { public void showChannel(ChannelNode ch) {
shownChannel = ch;
shownClient = null;
render();
}
public void showClient(ClientEntry cl, ServerModel model, IconRepository icons) {
shownClient = cl;
shownChannel = null;
shownModel = model;
shownIcons = icons;
render();
}
private void render() {
boolean selected = shownChannel != null || shownClient != null;
boolean hide = inChatTab && selected;
if (descriptionHandler != null) descriptionHandler.setPanelHidden(hide);
if (hide) {
descriptionHandler.showInfoTab(body(), this::onInfoTabClosed);
} else {
setHtml(body() + (selected ? toggleLink(true) : ""));
}
}
private String body() {
if (shownChannel != null) return channelBody(shownChannel);
if (shownClient != null) return clientBody(shownClient, shownModel, shownIcons);
return "<i style='color:#8a8a8a'>Select a channel or client to see details.</i>";
}
private static String channelBody(ChannelNode ch) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append(heading(esc(ch.name))); sb.append(heading(esc(ch.name)));
row(sb, "Type", ch.permanent ? "Permanent" : "Temporary"); row(sb, "Type", ch.permanent ? "Permanent" : "Temporary");
@@ -67,10 +136,10 @@ public final class InfoPanel extends JScrollPane {
} else { } else {
sb.append("<i style='color:#8a8a8a'>Loading description…</i>"); sb.append("<i style='color:#8a8a8a'>Loading description…</i>");
} }
setHtml(sb.toString()); return sb.toString();
} }
public void showClient(ClientEntry cl, ServerModel model, IconRepository icons) { private static String clientBody(ClientEntry cl, ServerModel model, IconRepository icons) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append(heading(esc(cl.nickname) + (cl.self ? " <span style='color:#8a8a8a'>(you)</span>" : ""))); sb.append(heading(esc(cl.nickname) + (cl.self ? " <span style='color:#8a8a8a'>(you)</span>" : "")));
@@ -105,7 +174,13 @@ public final class InfoPanel extends JScrollPane {
if (cl.description != null && !cl.description.isEmpty()) { if (cl.description != null && !cl.description.isEmpty()) {
sb.append("<hr><div>").append(multiline(cl.description)).append("</div>"); sb.append("<hr><div>").append(multiline(cl.description)).append("</div>");
} }
setHtml(sb.toString()); return sb.toString();
}
/** The link that toggles between showing details here and in a chat tab. */
private static String toggleLink(boolean toChat) {
return "<div style='margin-top:8px'><a href='" + TOGGLE_HREF + "' style='font-size:10px'>"
+ (toChat ? "Show in chat tab" : "Show here") + "</a></div>";
} }
private void setHtml(String body) { private void setHtml(String body) {

View File

@@ -12,22 +12,15 @@ import com.ts3client.sound.SoundNotifier;
import com.ts3client.sound.SoundPlayer; import com.ts3client.sound.SoundPlayer;
import javax.swing.BorderFactory; import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.JButton;
import javax.swing.JCheckBox; import javax.swing.JCheckBox;
import javax.swing.JCheckBoxMenuItem; import javax.swing.JCheckBoxMenuItem;
import javax.swing.JFrame; import javax.swing.JFrame;
import javax.swing.JLabel; import javax.swing.JLabel;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem; import javax.swing.JMenuItem;
import javax.swing.JOptionPane; import javax.swing.JOptionPane;
import javax.swing.JPanel; import javax.swing.JPanel;
import javax.swing.JPopupMenu; import javax.swing.JPopupMenu;
import javax.swing.JTextField; import javax.swing.JTextField;
import javax.swing.JToggleButton;
import javax.swing.JToolBar;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.Dimension; import java.awt.Dimension;
@@ -65,25 +58,14 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
/** The tab that owns the capture device; null when nobody is capturing. */ /** The tab that owns the capture device; null when nobody is capturing. */
private ServerTab micTab; private ServerTab micTab;
private JMenu bookmarksMenu; private MainMenuBar menuBar;
private JCheckBoxMenuItem awayItem;
private JMenuItem awayStatusItem;
private JCheckBoxMenuItem commanderItem;
private javax.swing.Timer statusTimer; private javax.swing.Timer statusTimer;
private final JLabel statusLabel = new JLabel("Not connected");
private final JLabel codecLabel = new JLabel();
/** Mirrors the active server's own client state next to the clock. */ /** Mirrors the active server's own client state next to the clock. */
private TrayController tray; private TrayController tray;
private JToolBar toolbar; private MainToolbar toolbar;
private JButton connectButton; private StatusBar statusBar;
private JButton disconnectButton;
private JToggleButton activeButton;
private JToggleButton micButton;
private JToggleButton speakerButton;
private DropDownToggleButton awayButton;
private boolean pttPressed; private boolean pttPressed;
@@ -117,22 +99,24 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
setMinimumSize(new Dimension(720, 480)); setMinimumSize(new Dimension(720, 480));
tray = new TrayController(this, this::quit); tray = new TrayController(this, this::quit);
setJMenuBar(buildMenuBar()); menuBar = buildMenuBar();
setJMenuBar(menuBar);
toolbar = buildToolbar(); toolbar = buildToolbar();
add(toolbar, BorderLayout.NORTH); add(toolbar, BorderLayout.NORTH);
// Switching the icon pack in the options dialog re-decorates the whole window. // Switching the icon pack in the options dialog re-decorates the whole window.
IconTheme.get().addListener(() -> SwingUtilities.invokeLater(this::rebuildIcons)); IconTheme.get().addListener(() -> SwingUtilities.invokeLater(this::rebuildIcons));
add(tabPane, BorderLayout.CENTER); add(tabPane, BorderLayout.CENTER);
add(buildStatusBar(), BorderLayout.SOUTH); statusBar = new StatusBar();
statusBar.setVisible(settings.showStatusBar);
add(statusBar, BorderLayout.SOUTH);
codecLabel.setText(audio.description()); statusBar.setCodec(audio.description());
ServerTab first = newTab(); ServerTab first = newTab();
selectTab(first); selectTab(first);
first.chat().appendSystem("Welcome to the TS3J Swing client."); first.chat().appendSystem("Welcome to the TS3J Swing client.");
first.chat().appendSystem("Use Connections → Connect to join a server."); first.chat().appendSystem("Use Connections → Connect to join a server.");
first.chat().appendSystem(tray.status());
statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus()); statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus());
statusTimer.start(); statusTimer.start();
@@ -150,160 +134,156 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
// ---- UI construction ---- // ---- UI construction ----
private JMenuBar buildMenuBar() { private MainMenuBar buildMenuBar() {
JMenuBar bar = new JMenuBar(); return new MainMenuBar(bookmarks, new MainMenuBar.Listener() {
@Override
JMenu connections = new JMenu("Connections"); public void onConnect() {
JMenuItem connect = new JMenuItem("Connect…", Icons.of("CONNECT")); showConnectDialog();
connect.setAccelerator(KeyStroke.getKeyStroke("control S"));
connect.addActionListener(e -> showConnectDialog());
JMenuItem disconnect = new JMenuItem("Disconnect", Icons.of("DISCONNECT"));
disconnect.addActionListener(e -> doDisconnect());
JMenuItem closeTab = new JMenuItem("Close tab", Icons.of("CLOSE_BUTTON"));
closeTab.setAccelerator(KeyStroke.getKeyStroke("control W"));
closeTab.addActionListener(e -> closeTab(selected));
JMenuItem quit = new JMenuItem("Quit", Icons.of("QUIT"));
quit.addActionListener(e -> quit());
connections.add(connect);
connections.add(disconnect);
connections.add(closeTab);
connections.addSeparator();
connections.add(quit);
bookmarksMenu = new JMenu("Bookmarks");
rebuildBookmarksMenu();
JMenu self = new JMenu("Self");
JMenuItem mute = new JMenuItem("Toggle microphone", Icons.of("CAPTURE"));
mute.addActionListener(e -> micButton.doClick());
JMenuItem deaf = new JMenuItem("Toggle speakers", Icons.of("PLAYBACK"));
deaf.addActionListener(e -> speakerButton.doClick());
awayItem = new JCheckBoxMenuItem("Away", Icons.of("AWAY"), false);
awayItem.addActionListener(e -> toggleAway());
awayStatusItem = new JMenuItem("Set away status…", Icons.of("EDIT"));
awayStatusItem.addActionListener(e -> setAwayStatus());
commanderItem = new JCheckBoxMenuItem("Channel commander", Icons.of("CHANNEL_COMMANDER"), false);
commanderItem.addActionListener(e -> {
if (selected != null) selected.setCommander(commanderItem.isSelected());
});
JMenuItem nick = new JMenuItem("Change nickname…", Icons.of("CHANGE_NICKNAME"));
nick.addActionListener(e -> changeNickname());
self.add(mute);
self.add(deaf);
self.addSeparator();
self.add(awayItem);
self.add(awayStatusItem);
self.add(commanderItem);
self.addSeparator();
self.add(nick);
JMenu tools = new JMenu("Tools");
JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER"));
identitiesItem.addActionListener(e -> showIdentities());
JMenuItem options = new JMenuItem("Options…", Icons.of("SETTINGS"));
options.addActionListener(e -> showSettings());
tools.add(identitiesItem);
tools.addSeparator();
tools.add(options);
JMenu help = new JMenu("Help");
JMenuItem about = new JMenuItem("About", Icons.of("ABOUT"));
about.addActionListener(e -> showAbout());
help.add(about);
bar.add(connections);
bar.add(bookmarksMenu);
bar.add(self);
bar.add(tools);
bar.add(help);
return bar;
} }
private void rebuildBookmarksMenu() { @Override
bookmarksMenu.removeAll(); public void onDisconnect() {
for (Bookmark b : bookmarks.all()) { doDisconnect();
JMenuItem item = new JMenuItem(b.displayName(), Icons.of("SERVER_GREEN"));
item.addActionListener(e -> connectToBookmark(b));
bookmarksMenu.add(item);
}
if (!bookmarks.all().isEmpty()) bookmarksMenu.addSeparator();
JMenuItem addCurrent = new JMenuItem("Add current server…", Icons.of("BOOKMARK_ADD"));
addCurrent.addActionListener(e -> addCurrentServerBookmark());
JMenuItem manage = new JMenuItem("Manage bookmarks…", Icons.of("BOOKMARK_MANAGER"));
manage.addActionListener(e -> new BookmarksDialog(this, bookmarks, identities,
this::connectToBookmark, this::rebuildBookmarksMenu).setVisible(true));
bookmarksMenu.add(addCurrent);
bookmarksMenu.add(manage);
} }
private JToolBar buildToolbar() { @Override
JToolBar tb = new JToolBar(); public void onCloseTab() {
tb.setFloatable(false); closeTab(selected);
tb.setBackground(Theme.TOOLBAR_BG); }
tb.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
connectButton = new JButton(Icons.connect()); @Override
connectButton.setToolTipText("Connect to a server"); public void onQuit() {
connectButton.addActionListener(e -> showConnectDialog()); quit();
}
disconnectButton = new JButton(Icons.disconnect()); @Override
disconnectButton.setToolTipText("Disconnect"); public void onToggleMic() {
disconnectButton.addActionListener(e -> doDisconnect()); toolbar.clickMic();
}
activeButton = new JToggleButton(Icons.micActive()); @Override
activeButton.setToolTipText("Speak on this server (moves the microphone to this tab)"); public void onToggleSpeaker() {
activeButton.addActionListener(e -> { toolbar.clickSpeaker();
if (selected != null && selected.isConnected()) setMicTab(selected); }
updateToolbar();
@Override
public void onAwayToggle(boolean away) {
toggleAway(away);
}
@Override
public void onAwayStatus() {
setAwayStatus();
}
@Override
public void onCommanderToggle(boolean commander) {
if (selected != null) selected.setCommander(commander);
}
@Override
public void onChangeNickname() {
changeNickname();
}
@Override
public void onShowIdentities() {
showIdentities();
}
@Override
public void onShowSettings() {
showSettings();
}
@Override
public void onShowAbout() {
showAbout();
}
@Override
public void onConnectBookmark(Bookmark bookmark) {
connectToBookmark(bookmark);
}
@Override
public void onAddCurrentServerBookmark() {
addCurrentServerBookmark();
}
@Override
public void onManageBookmarks() {
new BookmarksDialog(MainFrame.this, bookmarks, identities,
MainFrame.this::connectToBookmark, menuBar::rebuildBookmarks).setVisible(true);
}
}); });
}
micButton = new JToggleButton(Icons.mic()); private MainToolbar buildToolbar() {
micButton.setToolTipText("Mute / unmute microphone on this server"); return new MainToolbar(settings, new MainToolbar.Listener() {
micButton.addActionListener(e -> { @Override
public void onConnect() {
showConnectDialog();
}
@Override
public void onDisconnect() {
doDisconnect();
}
@Override
public void onActivate() {
moveMicrophoneToSelectedTab();
}
@Override
public void onMicMuteToggle(boolean muted) {
if (selected == null) return; if (selected == null) return;
selected.setMicMuted(micButton.isSelected()); selected.setMicMuted(muted);
updateToolbar(); updateToolbar();
}); }
speakerButton = new JToggleButton(Icons.speaker()); @Override
speakerButton.setToolTipText("Deafen / undeafen (mute speakers) on this server"); public void onDeafenToggle(boolean deafened) {
speakerButton.addActionListener(e -> {
if (selected == null) return; if (selected == null) return;
selected.setDeafened(speakerButton.isSelected()); selected.setDeafened(deafened);
updateToolbar(); updateToolbar();
}
@Override
public void onAwayToggle(boolean away) {
toggleAway(away);
}
@Override
public JPopupMenu buildAwayMenu() {
return MainFrame.this.buildAwayMenu();
}
@Override
public void onSettings() {
showSettings();
}
@Override
public void onMasterVolumeChanged() {
applyOutputSettingsToAllTabs();
}
@Override
public void onStatusBarVisibilityChanged(boolean visible) {
statusBar.setVisible(visible);
revalidate();
repaint();
}
}); });
awayButton = new DropDownToggleButton(Icons.away(),
"Away on this server (the arrow offers the global actions and presets)",
this::buildAwayMenu);
awayButton.addActionListener(e -> {
if (selected == null) return;
// The plain toggle carries no message; the menu is where messages are chosen.
selected.setAway(awayButton.isSelected(), "");
updateToolbar();
});
JButton settingsButton = new JButton(Icons.settings());
settingsButton.setToolTipText("Options");
settingsButton.addActionListener(e -> showSettings());
tb.add(connectButton);
tb.add(disconnectButton);
tb.addSeparator();
tb.add(activeButton);
tb.add(micButton);
tb.add(speakerButton);
tb.add(awayButton);
tb.addSeparator();
tb.add(settingsButton);
tb.add(Box.createHorizontalGlue());
return tb;
} }
/** Rebuilds the icon-bearing chrome after the active icon pack changed. */ /** Rebuilds the icon-bearing chrome after the active icon pack changed. */
private void rebuildIcons() { private void rebuildIcons() {
setIconImage(Icons.app().getImage()); setIconImage(Icons.app().getImage());
setJMenuBar(buildMenuBar()); menuBar = buildMenuBar();
setJMenuBar(menuBar);
remove(toolbar); remove(toolbar);
toolbar = buildToolbar(); toolbar = buildToolbar();
add(toolbar, BorderLayout.NORTH); add(toolbar, BorderLayout.NORTH);
@@ -314,18 +294,6 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
repaint(); repaint();
} }
private JPanel buildStatusBar() {
JPanel bar = new JPanel(new BorderLayout());
bar.setBackground(Theme.STATUS_BG);
bar.setBorder(BorderFactory.createEmptyBorder(3, 8, 3, 8));
statusLabel.setFont(Theme.UI_FONT);
codecLabel.setFont(Theme.UI_FONT);
codecLabel.setForeground(Theme.CHAT_SYSTEM);
bar.add(statusLabel, BorderLayout.WEST);
bar.add(codecLabel, BorderLayout.EAST);
return bar;
}
// ---- tab management ---- // ---- tab management ----
private ServerTab newTab() { private ServerTab newTab() {
@@ -646,7 +614,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
if (joinChannel.isSelected()) bookmark.channel = channelPath; if (joinChannel.isSelected()) bookmark.channel = channelPath;
bookmarks.add(bookmark); bookmarks.add(bookmark);
bookmarks.save(); bookmarks.save();
rebuildBookmarksMenu(); menuBar.rebuildBookmarks();
} }
/** The away button's drop-down: the global actions, the presets and their editor. */ /** The away button's drop-down: the global actions, the presets and their editor. */
@@ -683,9 +651,9 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
return menu; return menu;
} }
private void toggleAway() { private void toggleAway(boolean away) {
if (selected == null) return; if (selected == null) return;
selected.setAway(awayItem.isSelected(), ""); selected.setAway(away, "");
updateToolbar(); updateToolbar();
} }
@@ -783,7 +751,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
soundPlayer.setOutputDevice(settings.outputDevice); soundPlayer.setOutputDevice(settings.outputDevice);
for (ServerTab tab : tabs) { for (ServerTab tab : tabs) {
if (tab.connection().getPlayback() == null) continue; if (tab.connection().getPlayback() == null) continue;
tab.connection().getPlayback().setMasterVolume(settings.outputVolume); tab.connection().getPlayback().setMasterVolume(settings.effectiveOutputVolume());
tab.connection().getPlayback().setOutputDevice(settings.outputDevice); tab.connection().getPlayback().setOutputDevice(settings.outputDevice);
} }
} }
@@ -803,7 +771,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
"An open-source TeamSpeak 3 desktop client built on the ts3j\n" + "An open-source TeamSpeak 3 desktop client built on the ts3j\n" +
"reverse-engineered protocol library, with native Opus voice,\n" + "reverse-engineered protocol library, with native Opus voice,\n" +
"voice-activation detection and push-to-talk.\n\n" + "voice-activation detection and push-to-talk.\n\n" +
codecLabel.getText(), statusBar.codecText(),
"About TS3J", JOptionPane.INFORMATION_MESSAGE); "About TS3J", JOptionPane.INFORMATION_MESSAGE);
} }
@@ -811,28 +779,15 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private void updateToolbar() { private void updateToolbar() {
boolean connected = selected != null && selected.isConnected(); boolean connected = selected != null && selected.isConnected();
disconnectButton.setEnabled(connected); boolean anyConnected = tabs.stream().anyMatch(ServerTab::isConnected);
micButton.setEnabled(connected);
speakerButton.setEnabled(connected);
activeButton.setEnabled(connected);
awayItem.setEnabled(connected);
awayStatusItem.setEnabled(connected);
// The menu's actions are global, so the arrow stays live while any server is up.
awayButton.setEnabled(tabs.stream().anyMatch(ServerTab::isConnected));
awayButton.setToggleEnabled(connected);
commanderItem.setEnabled(connected);
boolean micMuted = connected && selected.isMicMuted(); boolean micMuted = connected && selected.isMicMuted();
boolean deaf = connected && selected.isDeafened(); boolean deaf = connected && selected.isDeafened();
micButton.setSelected(micMuted); boolean active = selected != null && selected == micTab;
micButton.setIcon(micMuted ? Icons.micMutedLarge() : Icons.mic());
speakerButton.setSelected(deaf);
speakerButton.setIcon(deaf ? Icons.speakerMutedLarge() : Icons.speaker());
activeButton.setSelected(selected != null && selected == micTab);
boolean away = connected && selected.isAway(); boolean away = connected && selected.isAway();
awayItem.setSelected(away); boolean commander = connected && selected.isCommander();
awayButton.setSelected(away); toolbar.refresh(connected, anyConnected, micMuted, deaf, active, away);
commanderItem.setSelected(connected && selected.isCommander()); menuBar.refresh(connected, away, commander);
updateTray(); updateTray();
} }
@@ -856,7 +811,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
} }
private void updateStatusLabel() { private void updateStatusLabel() {
statusLabel.setText(selected == null ? "Not connected" : selected.status()); statusBar.setStatus(selected == null ? "Not connected" : selected.status());
} }
private void updateConnectionStatus() { private void updateConnectionStatus() {
@@ -869,6 +824,6 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
double ping = tab.connection().getPingMillis(); double ping = tab.connection().getPingMillis();
if (ping >= 0) s.append(" | ping ").append(Math.round(ping)).append(" ms"); if (ping >= 0) s.append(" | ping ").append(Math.round(ping)).append(" ms");
if (tab != micTab) s.append(" | microphone on another tab"); if (tab != micTab) s.append(" | microphone on another tab");
statusLabel.setText(s.toString()); statusBar.setStatus(s.toString());
} }
} }

View File

@@ -0,0 +1,155 @@
package com.ts3client.ui;
import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
import javax.swing.KeyStroke;
/**
* The main window's menu bar: Connections, Bookmarks, Self, Tools and Help.
* Talks to {@link MainFrame} only through {@link Listener}; the away/commander
* item states are pushed in by {@link #refresh}, mirroring how {@link MainToolbar}
* is kept in sync.
*/
final class MainMenuBar extends JMenuBar {
interface Listener {
void onConnect();
void onDisconnect();
void onCloseTab();
void onQuit();
void onToggleMic();
void onToggleSpeaker();
/** The plain toggle carries no message; "Set away status…" is where messages are chosen. */
void onAwayToggle(boolean away);
void onAwayStatus();
void onCommanderToggle(boolean commander);
void onChangeNickname();
void onShowIdentities();
void onShowSettings();
void onShowAbout();
void onConnectBookmark(Bookmark bookmark);
void onAddCurrentServerBookmark();
void onManageBookmarks();
}
private final Bookmarks bookmarks;
private final Listener listener;
private JMenu bookmarksMenu;
private JCheckBoxMenuItem awayItem;
private JMenuItem awayStatusItem;
private JCheckBoxMenuItem commanderItem;
MainMenuBar(Bookmarks bookmarks, Listener listener) {
this.bookmarks = bookmarks;
this.listener = listener;
JMenu connections = new JMenu("Connections");
JMenuItem connect = new JMenuItem("Connect…", Icons.of("CONNECT"));
connect.setAccelerator(KeyStroke.getKeyStroke("control S"));
connect.addActionListener(e -> listener.onConnect());
JMenuItem disconnect = new JMenuItem("Disconnect", Icons.of("DISCONNECT"));
disconnect.addActionListener(e -> listener.onDisconnect());
JMenuItem closeTab = new JMenuItem("Close tab", Icons.of("CLOSE_BUTTON"));
closeTab.setAccelerator(KeyStroke.getKeyStroke("control W"));
closeTab.addActionListener(e -> listener.onCloseTab());
JMenuItem quit = new JMenuItem("Quit", Icons.of("QUIT"));
quit.addActionListener(e -> listener.onQuit());
connections.add(connect);
connections.add(disconnect);
connections.add(closeTab);
connections.addSeparator();
connections.add(quit);
bookmarksMenu = new JMenu("Bookmarks");
rebuildBookmarks();
JMenu self = new JMenu("Self");
JMenuItem mute = new JMenuItem("Toggle microphone", Icons.of("CAPTURE"));
mute.addActionListener(e -> listener.onToggleMic());
JMenuItem deaf = new JMenuItem("Toggle speakers", Icons.of("PLAYBACK"));
deaf.addActionListener(e -> listener.onToggleSpeaker());
awayItem = new JCheckBoxMenuItem("Away", Icons.of("AWAY"), false);
awayItem.addActionListener(e -> listener.onAwayToggle(awayItem.isSelected()));
awayStatusItem = new JMenuItem("Set away status…", Icons.of("EDIT"));
awayStatusItem.addActionListener(e -> listener.onAwayStatus());
commanderItem = new JCheckBoxMenuItem("Channel commander", Icons.of("CHANNEL_COMMANDER"), false);
commanderItem.addActionListener(e -> listener.onCommanderToggle(commanderItem.isSelected()));
JMenuItem nick = new JMenuItem("Change nickname…", Icons.of("CHANGE_NICKNAME"));
nick.addActionListener(e -> listener.onChangeNickname());
self.add(mute);
self.add(deaf);
self.addSeparator();
self.add(awayItem);
self.add(awayStatusItem);
self.add(commanderItem);
self.addSeparator();
self.add(nick);
JMenu tools = new JMenu("Tools");
JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER"));
identitiesItem.addActionListener(e -> listener.onShowIdentities());
JMenuItem options = new JMenuItem("Options…", Icons.of("SETTINGS"));
options.addActionListener(e -> listener.onShowSettings());
tools.add(identitiesItem);
tools.addSeparator();
tools.add(options);
JMenu help = new JMenu("Help");
JMenuItem about = new JMenuItem("About", Icons.of("ABOUT"));
about.addActionListener(e -> listener.onShowAbout());
help.add(about);
add(connections);
add(bookmarksMenu);
add(self);
add(tools);
add(help);
}
/** Reflects the current tab's away/commander state on the menu items. */
void refresh(boolean connected, boolean away, boolean commander) {
awayItem.setEnabled(connected);
awayStatusItem.setEnabled(connected);
commanderItem.setEnabled(connected);
awayItem.setSelected(away);
commanderItem.setSelected(commander);
}
/** Re-lists the saved bookmarks; called after they change (add, remove, manage…). */
void rebuildBookmarks() {
bookmarksMenu.removeAll();
for (Bookmark b : bookmarks.all()) {
JMenuItem item = new JMenuItem(b.displayName(), Icons.of("SERVER_GREEN"));
item.addActionListener(e -> listener.onConnectBookmark(b));
bookmarksMenu.add(item);
}
if (!bookmarks.all().isEmpty()) bookmarksMenu.addSeparator();
JMenuItem addCurrent = new JMenuItem("Add current server…", Icons.of("BOOKMARK_ADD"));
addCurrent.addActionListener(e -> listener.onAddCurrentServerBookmark());
JMenuItem manage = new JMenuItem("Manage bookmarks…", Icons.of("BOOKMARK_MANAGER"));
manage.addActionListener(e -> listener.onManageBookmarks());
bookmarksMenu.add(addCurrent);
bookmarksMenu.add(manage);
}
}

View File

@@ -0,0 +1,226 @@
package com.ts3client.ui;
import com.ts3client.config.Settings;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.JButton;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JSlider;
import javax.swing.JToggleButton;
import javax.swing.JToolBar;
import javax.swing.event.PopupMenuEvent;
import javax.swing.event.PopupMenuListener;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.FlowLayout;
/**
* The main window's toolbar: connect/disconnect, the per-tab mic/speaker/away
* controls, and the Master Volume slider on the right. A right-click anywhere on
* the bar opens a customization menu for the window's optional chrome (this
* toolbar has no say over the status bar's own visibility beyond reporting the
* toggle).
*
* <p>Talks to {@link MainFrame} only through {@link Listener}, so it owns no
* connection/tab state itself &mdash; {@link #refresh} is handed everything it
* needs to redraw each time the selection changes.
*/
final class MainToolbar extends JToolBar {
interface Listener {
void onConnect();
void onDisconnect();
/** Moves the microphone to the tab on screen. */
void onActivate();
void onMicMuteToggle(boolean muted);
void onDeafenToggle(boolean deafened);
/** The plain toggle carries no message; the drop-down arrow is where messages are chosen. */
void onAwayToggle(boolean away);
JPopupMenu buildAwayMenu();
void onSettings();
/** The slider changed; push the new master volume to every open connection. */
void onMasterVolumeChanged();
void onStatusBarVisibilityChanged(boolean visible);
}
private final Settings settings;
private final Listener listener;
private final JButton connectButton;
private final JButton disconnectButton;
private final JToggleButton activeButton;
private final JToggleButton micButton;
private final JToggleButton speakerButton;
private final DropDownToggleButton awayButton;
private final JComponent masterVolumePanel;
MainToolbar(Settings settings, Listener listener) {
this.settings = settings;
this.listener = listener;
setFloatable(false);
setBackground(Theme.TOOLBAR_BG);
setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
setComponentPopupMenu(buildContextMenu());
connectButton = new JButton(Icons.connect());
connectButton.setToolTipText("Connect to a server");
connectButton.addActionListener(e -> listener.onConnect());
disconnectButton = new JButton(Icons.disconnect());
disconnectButton.setToolTipText("Disconnect");
disconnectButton.addActionListener(e -> listener.onDisconnect());
activeButton = new JToggleButton(Icons.micActive());
activeButton.setToolTipText("Speak on this server (moves the microphone to this tab)");
activeButton.addActionListener(e -> listener.onActivate());
micButton = new JToggleButton(Icons.mic());
micButton.setToolTipText("Mute / unmute microphone on this server");
micButton.addActionListener(e -> listener.onMicMuteToggle(micButton.isSelected()));
speakerButton = new JToggleButton(Icons.speaker());
speakerButton.setToolTipText("Deafen / undeafen (mute speakers) on this server");
speakerButton.addActionListener(e -> listener.onDeafenToggle(speakerButton.isSelected()));
awayButton = new DropDownToggleButton(Icons.away(),
"Away on this server (the arrow offers the global actions and presets)",
listener::buildAwayMenu);
awayButton.addActionListener(e -> listener.onAwayToggle(awayButton.isSelected()));
JButton settingsButton = new JButton(Icons.settings());
settingsButton.setToolTipText("Options");
settingsButton.addActionListener(e -> listener.onSettings());
add(connectButton);
add(disconnectButton);
addSeparator();
add(activeButton);
add(micButton);
add(speakerButton);
add(awayButton);
addSeparator();
add(settingsButton);
add(Box.createHorizontalGlue());
masterVolumePanel = buildMasterVolumeControl();
masterVolumePanel.setVisible(settings.showMasterVolumeSlider);
add(masterVolumePanel);
}
/** Reflects the current tab's state on the buttons. */
void refresh(boolean connected, boolean anyConnected, boolean micMuted, boolean deafened,
boolean active, boolean away) {
disconnectButton.setEnabled(connected);
micButton.setEnabled(connected);
speakerButton.setEnabled(connected);
activeButton.setEnabled(connected);
// The away menu's actions are global, so the arrow stays live while any server is up.
awayButton.setEnabled(anyConnected);
awayButton.setToggleEnabled(connected);
micButton.setSelected(micMuted);
micButton.setIcon(micMuted ? Icons.micMutedLarge() : Icons.mic());
speakerButton.setSelected(deafened);
speakerButton.setIcon(deafened ? Icons.speakerMutedLarge() : Icons.speaker());
activeButton.setSelected(active);
awayButton.setSelected(away);
}
/** Simulates a click, for the "Toggle microphone" menu item. */
void clickMic() {
micButton.doClick();
}
/** Simulates a click, for the "Toggle speakers" menu item. */
void clickSpeaker() {
speakerButton.doClick();
}
/** The Master Volume slider: multiplies both voice and notification volume. */
private JComponent buildMasterVolumeControl() {
JPanel panel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0));
panel.setOpaque(false);
panel.setAlignmentY(Component.CENTER_ALIGNMENT);
JLabel icon = new JLabel(Icons.speaker());
icon.setToolTipText("Master volume (voice + notifications)");
icon.setAlignmentY(Component.CENTER_ALIGNMENT);
JSlider slider = new JSlider(0, 200, (int) Math.round(settings.masterVolume * 100));
slider.setOpaque(false);
slider.setToolTipText("Master volume (voice + notifications) — scroll to adjust");
slider.setAlignmentY(Component.CENTER_ALIGNMENT);
slider.setPreferredSize(new Dimension(135, slider.getPreferredSize().height));
slider.addChangeListener(e -> {
settings.masterVolume = slider.getValue() / 100.0;
listener.onMasterVolumeChanged();
// Only touch the disk once the drag (or a wheel step) has settled.
if (!slider.getValueIsAdjusting()) settings.save();
});
slider.addMouseWheelListener(e -> {
int step = e.getWheelRotation() < 0 ? 5 : -5;
slider.setValue(slider.getValue() + step);
e.consume();
});
panel.add(icon);
panel.add(slider);
return panel;
}
/** Right-click on the toolbar: toggles for the window's optional chrome. */
private JPopupMenu buildContextMenu() {
JPopupMenu menu = new JPopupMenu();
JCheckBoxMenuItem statusBarItem = new JCheckBoxMenuItem("Show Status Bar", settings.showStatusBar);
statusBarItem.addActionListener(e -> {
settings.showStatusBar = statusBarItem.isSelected();
settings.save();
listener.onStatusBarVisibilityChanged(settings.showStatusBar);
});
JCheckBoxMenuItem volumeItem = new JCheckBoxMenuItem("Show Master Volume Slider", settings.showMasterVolumeSlider);
volumeItem.addActionListener(e -> {
settings.showMasterVolumeSlider = volumeItem.isSelected();
settings.save();
masterVolumePanel.setVisible(settings.showMasterVolumeSlider);
revalidate();
repaint();
});
menu.add(statusBarItem);
menu.add(volumeItem);
// Either toggle can also be flipped elsewhere (or via settings.properties), so
// re-sync the ticks each time the menu is about to be shown.
menu.addPopupMenuListener(new PopupMenuListener() {
@Override
public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
statusBarItem.setSelected(settings.showStatusBar);
volumeItem.setSelected(settings.showMasterVolumeSlider);
}
@Override
public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
}
@Override
public void popupMenuCanceled(PopupMenuEvent e) {
}
});
return menu;
}
}

View File

@@ -6,13 +6,11 @@ import com.ts3client.config.IdentityStore;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry; import com.ts3client.net.ClientEntry;
import com.ts3client.net.ConnectionListener;
import com.ts3client.net.TeamspeakConnection; import com.ts3client.net.TeamspeakConnection;
import com.ts3client.sound.SoundNotifier; import com.ts3client.sound.SoundNotifier;
import com.ts3client.text.TsLink; import com.ts3client.text.TsLink;
import javax.swing.JComponent; import javax.swing.JComponent;
import javax.swing.JOptionPane;
import javax.swing.JSplitPane; import javax.swing.JSplitPane;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import java.util.ArrayList; import java.util.ArrayList;
@@ -24,10 +22,12 @@ import java.awt.Component;
* client keeps several of these side by side; {@link MainFrame} shows one at a * client keeps several of these side by side; {@link MainFrame} shows one at a
* time and owns the toolbar, menus and status bar that act on it. * time and owns the toolbar, menus and status bar that act on it.
* *
* <p>Everything that is per-server lives here: the connection, its microphone * <p>ServerTab itself owns the connection, the panels and this tab's identity
* and speaker mute state, the away/commander flags and the chat history. * (title/status); the mic/away/deafen flags live in {@link ServerTabSelfState},
* tree context-menu actions and selection in {@link ServerTabTreeActions}, and
* {@link com.ts3client.net.ConnectionListener} callbacks in {@link ServerTabConnectionEvents}.
*/ */
final class ServerTab implements ConnectionListener, ServerTreePanel.Actions { final class ServerTab implements ServerTabConnectionEvents.Listener {
private final MainFrame host; private final MainFrame host;
private final Settings settings; private final Settings settings;
@@ -35,10 +35,16 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
private final TeamspeakConnection conn; private final TeamspeakConnection conn;
private final GroupIcons groupIcons; private final GroupIcons groupIcons;
private final ServerTabConnectionEvents events;
private final ServerTabSelfState selfState;
private final ServerTabTreeActions treeActions;
private final ServerTreePanel treePanel; private final ServerTreePanel treePanel;
private final ChatPanel chatPanel; private final ChatPanel chatPanel;
private final InfoPanel infoPanel = new InfoPanel(); private final InfoPanel infoPanel = new InfoPanel();
private final JComponent component; private final JComponent component;
private JSplitPane leftColumn;
private int normalDividerSize;
private int savedDividerLocation = -1;
/** Label shown in the tab bar: the server name once known, the address before that. */ /** Label shown in the tab bar: the server name once known, the address before that. */
private String title = "New connection"; private String title = "New connection";
@@ -48,43 +54,61 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
/** Identity used for the current connection, so it can be saved into a bookmark. */ /** Identity used for the current connection, so it can be saved into a bookmark. */
private String identityId = ""; private String identityId = "";
private boolean micMuted;
private boolean micLocalMuted;
private boolean deafened;
private boolean away;
/** Away message currently published, empty when away carries no message. */
private String awayMessage = "";
private boolean commander;
private Object currentSelection;
ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio, ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio,
SoundNotifier sounds) { SoundNotifier sounds) {
this.host = host; this.host = host;
this.settings = settings; this.settings = settings;
this.identities = identities; this.identities = identities;
this.conn = new TeamspeakConnection(settings, audio, this, sounds);
// ServerTabConnectionEvents must exist before the connection (which needs a
// listener up front), and TeamspeakConnection must exist before the tree/chat
// panels and the other tab helpers that read from it — so wiring finishes with
// an explicit attach() once everything is built. Nothing fires callbacks before then.
this.events = new ServerTabConnectionEvents(host, this, this);
this.conn = new TeamspeakConnection(settings, audio, events, sounds);
this.groupIcons = new GroupIcons(conn.getIcons()); this.groupIcons = new GroupIcons(conn.getIcons());
this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, this); this.selfState = new ServerTabSelfState(conn);
this.chatPanel = new ChatPanel(); this.chatPanel = new ChatPanel();
this.treeActions = new ServerTabTreeActions(host, this, conn, chatPanel, infoPanel);
this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, treeActions);
treeActions.attach(treePanel);
events.attach(conn, treePanel, chatPanel, selfState, treeActions);
chatPanel.setSendHandler(this::onSendChat); chatPanel.setSendHandler(this::onSendChat);
chatPanel.setLinkHandler(new ChatPanel.LinkHandler() { chatPanel.setLinkHandler(new ChatPanel.LinkHandler() {
@Override @Override
public void onClientLink(TsLink.Ref ref, Component source, int x, int y) { public void onClientLink(TsLink.Ref ref, Component source, int x, int y) {
ServerTab.this.onClientLink(ref, source, x, y); treeActions.handleClientLink(ref, source, x, y);
} }
@Override @Override
public void onChannelLink(TsLink.Ref ref, Component source, int x, int y) { public void onChannelLink(TsLink.Ref ref, Component source, int x, int y) {
ServerTab.this.onChannelLink(ref, source, x, y); treeActions.handleChannelLink(ref, source, x, y);
} }
}); });
chatPanel.setInputEnabled(false); chatPanel.setInputEnabled(false);
JSplitPane leftColumn = new JSplitPane(JSplitPane.VERTICAL_SPLIT, treePanel, infoPanel); infoPanel.setDescriptionHandler(new InfoPanel.DescriptionHandler() {
@Override
public void showInfoTab(String html, Runnable onClose) {
chatPanel.openDescriptionTab("info", Icons.channelClientPair(), "", html, onClose);
}
@Override
public void closeInfoTab() {
chatPanel.closeNoteTab("info");
}
@Override
public void setPanelHidden(boolean hidden) {
ServerTab.this.setInfoPanelHidden(hidden);
}
});
leftColumn = new JSplitPane(JSplitPane.VERTICAL_SPLIT, treePanel, infoPanel);
leftColumn.setResizeWeight(0.68); leftColumn.setResizeWeight(0.68);
leftColumn.setContinuousLayout(true); leftColumn.setContinuousLayout(true);
normalDividerSize = leftColumn.getDividerSize();
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftColumn, chatPanel); JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftColumn, chatPanel);
split.setResizeWeight(0.55); split.setResizeWeight(0.55);
@@ -137,40 +161,32 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
} }
boolean isMicMuted() { boolean isMicMuted() {
return micMuted; return selfState.isMicMuted();
} }
boolean isMicLocalMuted() { boolean isMicLocalMuted() {
return micLocalMuted; return selfState.isMicLocalMuted();
} }
boolean isDeafened() { boolean isDeafened() {
return deafened; return selfState.isDeafened();
} }
boolean isAway() { boolean isAway() {
return away; return selfState.isAway();
} }
String awayMessage() { String awayMessage() {
return awayMessage; return selfState.awayMessage();
} }
boolean isCommander() { boolean isCommander() {
return commander; return selfState.isCommander();
} }
/** What the local client looks like on this server, for the tray icon. */ /** What the local client looks like on this server, for the tray icon. */
SelfState selfState() { SelfState selfState() {
if (!conn.isConnected()) return SelfState.DISCONNECTED; return selfState.compute();
if (deafened) return SelfState.DEAFENED;
if (micMuted) return SelfState.MIC_MUTED;
if (micLocalMuted) return SelfState.MIC_LOCAL_MUTED;
if (away) return SelfState.AWAY;
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
boolean talking = self != null && self.talking;
if (commander) return talking ? SelfState.COMMANDER_TALKING : SelfState.COMMANDER;
return talking ? SelfState.TALKING : SelfState.IDLE;
} }
/** Path of the channel we are in, or empty when not connected. */ /** Path of the channel we are in, or empty when not connected. */
@@ -194,8 +210,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
title = address + ":" + port; title = address + ":" + port;
chatPanel.appendSystem("Connecting to " + address + ":" + port chatPanel.appendSystem("Connecting to " + address + ":" + port
+ (channel == null || channel.isBlank() ? "" : " (channel \"" + channel + "\")") + ""); + (channel == null || channel.isBlank() ? "" : " (channel \"" + channel + "\")") + "");
onStatus("Loading identity…"); events.onStatus("Loading identity…");
host.tabUpdated(this);
// Resolving may have to generate a first identity, so keep it off the EDT. // Resolving may have to generate a first identity, so keep it off the EDT.
new Thread(() -> { new Thread(() -> {
@@ -204,7 +219,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
entry = identities.resolve(settings, identityId); entry = identities.resolve(settings, identityId);
} catch (Exception e) { } catch (Exception e) {
connecting = false; connecting = false;
onError("Could not load identity: " + e.getMessage()); events.onError("Could not load identity: " + e.getMessage());
return; return;
} }
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
@@ -229,26 +244,36 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
if (conn.isConnected()) conn.disconnectBlocking("Leaving"); if (conn.isConnected()) conn.disconnectBlocking("Leaving");
} }
// ---- ServerTabConnectionEvents.Listener ----
@Override
public void setStatus(String status) {
this.status = status;
}
@Override
public void setTitle(String title) {
this.title = title;
}
@Override
public void setConnecting(boolean connecting) {
this.connecting = connecting;
}
// ---- self state ---- // ---- self state ----
void setMicMuted(boolean muted) { void setMicMuted(boolean muted) {
micMuted = muted; selfState.setMicMuted(muted);
conn.setMicMuted(muted);
chatPanel.appendSystem(muted ? "Microphone muted." : "Microphone active.");
} }
/** TS3's "Local Mic Mute": silences capture without publishing a status change. */ /** TS3's "Local Mic Mute": silences capture without publishing a status change. */
void setMicLocalMuted(boolean muted) { void setMicLocalMuted(boolean muted) {
micLocalMuted = muted; selfState.setMicLocalMuted(muted);
conn.setMicLocalMuted(muted);
chatPanel.appendSystem(muted ? "Microphone locally muted." : "Microphone locally unmuted.");
} }
void setDeafened(boolean deaf) { void setDeafened(boolean deaf) {
deafened = deaf; selfState.setDeafened(deaf);
conn.setDeafened(deaf);
if (deaf) micMuted = true;
chatPanel.appendSystem(deaf ? "Speakers muted (deafened)." : "Speakers active.");
} }
/** Hands the capture device to (or takes it from) this connection. */ /** Hands the capture device to (or takes it from) this connection. */
@@ -261,16 +286,11 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
* message outlives coming back, so toggling away again restores it * message outlives coming back, so toggling away again restores it
*/ */
void setAway(boolean away, String message) { void setAway(boolean away, String message) {
this.away = away; selfState.setAway(away, message);
if (message != null) this.awayMessage = message;
conn.setAway(away, awayMessage);
chatPanel.appendSystem(!away ? "No longer away."
: awayMessage.isEmpty() ? "Away." : "Away: " + awayMessage);
} }
void setCommander(boolean commander) { void setCommander(boolean commander) {
this.commander = commander; selfState.setCommander(commander);
conn.setChannelCommander(commander);
} }
void setNickname(String nickname) { void setNickname(String nickname) {
@@ -302,33 +322,6 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
} }
} }
/** A client link in the chat log was clicked: show the same menu as the tree does. */
private void onClientLink(TsLink.Ref ref, Component source, int x, int y) {
ClientEntry client = conn.getModel().getClient(ref.id);
// The id is only valid for the session the link was made in; fall back to
// the unique id (and finally the nickname) so older links still resolve.
if (client == null || (!ref.uniqueId.isEmpty() && !ref.uniqueId.equals(client.uniqueId))) {
ClientEntry byUid = ref.uniqueId.isEmpty() ? null
: conn.getModel().findClientByUniqueId(ref.uniqueId);
if (byUid == null && !ref.name.isEmpty()) byUid = conn.getModel().findClientByName(ref.name);
if (byUid != null) client = byUid;
}
if (client == null) {
chatPanel.appendSystem("That client is no longer on the server.");
return;
}
ClientMenu.build(client, client.id == conn.getSelfClientId(), this).show(source, x, y);
}
private void onChannelLink(TsLink.Ref ref, Component source, int x, int y) {
ChannelNode channel = conn.getModel().getChannel(ref.id);
if (channel == null) {
chatPanel.appendSystem("That channel no longer exists.");
return;
}
ChannelMenu.build(channel, this).show(source, x, y);
}
private String peerName(int clientId) { private String peerName(int clientId) {
ClientEntry c = conn.getModel().getClient(clientId); ClientEntry c = conn.getModel().getClient(clientId);
return c != null ? c.nickname : "Client " + clientId; return c != null ? c.nickname : "Client " + clientId;
@@ -361,233 +354,24 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
return self == null ? null : conn.getModel().getChannel(self.channelId); return self == null ? null : conn.getModel().getChannel(self.channelId);
} }
// ---- ServerTreePanel.Actions ---- /** Opens the file repository browser for a channel, as the "Browse Files" hotkey/menu action does. */
void browseFiles(ChannelNode channel) {
@Override treeActions.browseFiles(channel);
public void joinChannel(int channelId) {
if (conn.isConnected()) conn.joinChannel(channelId, null);
} }
@Override /** Collapses the info panel to nothing while its content lives in the chat tab, or restores it. */
public void moveClientToChannel(ClientEntry client, ChannelNode target) { private void setInfoPanelHidden(boolean hidden) {
if (!conn.isConnected()) return; if (hidden == !infoPanel.isVisible()) return;
if (client.id == conn.getSelfClientId()) { if (hidden) {
conn.joinChannel(target.id, null); savedDividerLocation = leftColumn.getDividerLocation();
infoPanel.setVisible(false);
leftColumn.setDividerSize(0);
leftColumn.setDividerLocation(1.0);
} else { } else {
conn.moveClient(client.id, target.id, null); infoPanel.setVisible(true);
} leftColumn.setDividerSize(normalDividerSize);
} if (savedDividerLocation >= 0) leftColumn.setDividerLocation(savedDividerLocation);
}
@Override leftColumn.revalidate();
public void moveChannel(ChannelNode channel, int newParentId, int orderPredecessorId) {
if (conn.isConnected()) conn.moveChannel(channel.id, newParentId, orderPredecessorId);
}
@Override
public void openPrivateChat(ClientEntry client) {
chatPanel.openPrivateChat(client.id, client.nickname);
}
@Override
public void pokeClient(ClientEntry client) {
String msg = JOptionPane.showInputDialog(host, "Poke message for " + client.nickname + ":", "Poke!");
if (msg != null) conn.poke(client.id, msg);
}
@Override
public void kickClientFromChannel(ClientEntry client) {
String reason = kickReason("Kick Client from Channel", client);
if (reason != null) conn.kickFromChannel(client.id, reason);
}
@Override
public void kickClientFromServer(ClientEntry client) {
String reason = kickReason("Kick Client from Server", client);
if (reason != null) conn.kickFromServer(client.id, reason);
}
private String kickReason(String title, ClientEntry client) {
if (!conn.isConnected()) return null;
return ReasonDialog.prompt(host, title, "Reason for kicking " + client.nickname + ":",
ReasonDialog.KICK_REASON_LIMIT);
}
@Override
public void banClient(ClientEntry client) {
if (!conn.isConnected()) return;
BanDialog dialog = new BanDialog(host, client.nickname);
dialog.setVisible(true);
if (dialog.isConfirmed()) conn.banClient(client.id, dialog.getSeconds(), dialog.getReason());
}
@Override
public void toggleClientMute(ClientEntry client) {
if (conn.getPlayback() == null) return;
boolean now = !conn.getPlayback().isClientMuted(client.id);
conn.getPlayback().setClientMuted(client.id, now);
chatPanel.appendSystem((now ? "Muted " : "Unmuted ") + client.nickname + ".");
}
@Override
public void showConnectionInfo(ClientEntry client) {
if (!conn.isConnected()) return;
new ConnectionInfoDialog(host, conn, client.id, client.nickname).setVisible(true);
}
@Override
public void moveClientToOwnChannel(ClientEntry client) {
if (!conn.isConnected() || client.id == conn.getSelfClientId()) return;
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
if (self != null) conn.moveClient(client.id, self.channelId, null);
}
@Override
public void setChannelSubscribed(ChannelNode channel, boolean family, boolean subscribed) {
if (conn.isConnected()) conn.setChannelsSubscribed(channel.familyIds(family), subscribed);
}
@Override
public void browseFiles(ChannelNode channel) {
if (!conn.canTransferFiles()) return;
new FileBrowserDialog(host, conn, channel).setVisible(true);
}
@Override
public boolean isClientLocallyMuted(int clientId) {
return conn.getPlayback() != null && conn.getPlayback().isClientMuted(clientId);
}
@Override
public void onSelectionChanged(Object userObject) {
currentSelection = userObject;
renderInfo();
if (!conn.isConnected()) return;
if (userObject instanceof ChannelNode) {
ChannelNode ch = (ChannelNode) userObject;
if (!ch.descriptionLoaded) conn.requestChannelInfo(ch.id);
} else if (userObject instanceof ClientEntry) {
conn.requestClientInfo(((ClientEntry) userObject).id);
}
}
private void renderInfo() {
Object sel = currentSelection;
if (sel instanceof ChannelNode) {
infoPanel.showChannel((ChannelNode) sel);
} else if (sel instanceof ClientEntry) {
infoPanel.showClient((ClientEntry) sel, conn.getModel(), conn.getIcons());
} else {
infoPanel.clear();
}
}
// ---- ConnectionListener (marshal to EDT) ----
@Override
public void onStatus(String text) {
SwingUtilities.invokeLater(() -> {
status = text;
host.tabUpdated(this);
});
}
@Override
public void onConnected() {
SwingUtilities.invokeLater(() -> {
connecting = false;
treePanel.setSelfClientId(conn.getSelfClientId());
micMuted = false;
micLocalMuted = false;
deafened = false;
away = false;
awayMessage = "";
commander = false;
chatPanel.setInputEnabled(true);
chatPanel.appendSystem("Connected.");
host.tabConnected(this);
});
}
@Override
public void onDisconnected(String reason) {
SwingUtilities.invokeLater(() -> {
connecting = false;
conn.getModel().clear();
treePanel.showDisconnected();
currentSelection = null;
infoPanel.clear();
chatPanel.setInputEnabled(false);
chatPanel.closePrivateChats();
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
host.tabDisconnected(this);
});
}
@Override
public void onModelChanged() {
SwingUtilities.invokeLater(() -> {
treePanel.rebuild();
renderInfo();
String name = conn.getModel().getServerName();
if (conn.isConnected() && name != null && !name.isBlank() && !name.equals(title)) {
title = name;
host.tabUpdated(this);
}
});
}
@Override
public void onInfoUpdated() {
SwingUtilities.invokeLater(this::renderInfo);
}
@Override
public void onIconsUpdated() {
SwingUtilities.invokeLater(() -> {
treePanel.refreshRowSizes();
renderInfo();
});
}
@Override
public void onChat(ChatScope scope, int fromClientId, String fromName, String message) {
switch (scope) {
case PRIVATE:
chatPanel.appendPrivateMessage(fromClientId, fromName, fromClientId, fromName, message);
break;
case SERVER:
chatPanel.appendServerMessage(fromClientId, fromName, message);
break;
default:
chatPanel.appendChannelMessage(fromClientId, fromName, message);
break;
}
}
@Override
public void onTalkStateChanged(int clientId, boolean talking) {
SwingUtilities.invokeLater(() -> {
treePanel.refreshVisual();
if (clientId == conn.getSelfClientId()) host.selfStateChanged(this);
});
}
@Override
public void onError(String message) {
SwingUtilities.invokeLater(() -> {
chatPanel.appendSystem("Error: " + message);
status = message;
host.tabUpdated(this);
});
}
@Override
public void onPoke(String fromName, String message) {
SwingUtilities.invokeLater(() -> {
chatPanel.appendSystem("You were poked by " + fromName + ": " + message);
host.selectTab(this);
JOptionPane.showMessageDialog(host, fromName + " poked you:\n\n" + message,
"Poke", JOptionPane.INFORMATION_MESSAGE);
});
} }
} }

View File

@@ -0,0 +1,160 @@
package com.ts3client.ui;
import com.ts3client.net.ConnectionListener;
import com.ts3client.net.TeamspeakConnection;
import javax.swing.JOptionPane;
import javax.swing.SwingUtilities;
/**
* Marshals {@link ConnectionListener} callbacks onto the EDT and fans them out to
* this tab's views (tree, chat, info panel) and to {@link MainFrame}.
*
* <p>Constructed before the {@link TeamspeakConnection} it listens to exists — the
* connection's constructor needs a listener up front — so {@link #attach} wires the
* rest of the tab in once everything else has been built.
*/
final class ServerTabConnectionEvents implements ConnectionListener {
/** The handful of {@link ServerTab} fields this class updates but can't reach directly. */
interface Listener {
void setStatus(String status);
void setTitle(String title);
void setConnecting(boolean connecting);
}
private final MainFrame host;
private final ServerTab tab;
private final Listener listener;
private TeamspeakConnection conn;
private ServerTreePanel treePanel;
private ChatPanel chatPanel;
private ServerTabSelfState selfState;
private ServerTabTreeActions treeActions;
ServerTabConnectionEvents(MainFrame host, ServerTab tab, Listener listener) {
this.host = host;
this.tab = tab;
this.listener = listener;
}
void attach(TeamspeakConnection conn, ServerTreePanel treePanel, ChatPanel chatPanel,
ServerTabSelfState selfState, ServerTabTreeActions treeActions) {
this.conn = conn;
this.treePanel = treePanel;
this.chatPanel = chatPanel;
this.selfState = selfState;
this.treeActions = treeActions;
}
@Override
public void onStatus(String text) {
SwingUtilities.invokeLater(() -> {
listener.setStatus(text);
host.tabUpdated(tab);
});
}
@Override
public void onConnected() {
SwingUtilities.invokeLater(() -> {
listener.setConnecting(false);
treePanel.setSelfClientId(conn.getSelfClientId());
selfState.resetOnConnect();
chatPanel.setInputEnabled(true);
chatPanel.appendSystem("Connected.");
host.tabConnected(tab);
});
}
@Override
public void onDisconnected(String reason) {
SwingUtilities.invokeLater(() -> {
listener.setConnecting(false);
conn.getModel().clear();
treePanel.showDisconnected();
treeActions.clearSelection();
chatPanel.setInputEnabled(false);
chatPanel.closePrivateChats();
chatPanel.closeNoteTabs();
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
host.tabDisconnected(tab);
});
}
@Override
public void onModelChanged() {
SwingUtilities.invokeLater(() -> {
treePanel.rebuild();
treeActions.renderInfo();
String name = conn.getModel().getServerName();
if (conn.isConnected() && name != null && !name.isBlank() && !name.equals(tab.title())) {
listener.setTitle(name);
host.tabUpdated(tab);
}
});
}
@Override
public void onInfoUpdated() {
SwingUtilities.invokeLater(treeActions::renderInfo);
}
@Override
public void onIconsUpdated() {
SwingUtilities.invokeLater(() -> {
treePanel.refreshRowSizes();
treeActions.renderInfo();
});
}
@Override
public void onChat(ChatScope scope, int fromClientId, String fromName, String message) {
switch (scope) {
case PRIVATE:
chatPanel.appendPrivateMessage(fromClientId, fromName, fromClientId, fromName, message);
break;
case SERVER:
chatPanel.appendServerMessage(fromClientId, fromName, message);
break;
default:
chatPanel.appendChannelMessage(fromClientId, fromName, message);
break;
}
}
@Override
public void onTalkStateChanged(int clientId, boolean talking) {
SwingUtilities.invokeLater(() -> {
treePanel.refreshVisual();
if (clientId == conn.getSelfClientId()) host.selfStateChanged(tab);
});
}
@Override
public void onError(String message) {
SwingUtilities.invokeLater(() -> {
chatPanel.appendSystem("Error: " + message);
listener.setStatus(message);
host.tabUpdated(tab);
});
}
@Override
public void onServerLog(String message) {
SwingUtilities.invokeLater(() -> chatPanel.appendServerLog(message));
}
@Override
public void onPoke(String fromName, String message) {
SwingUtilities.invokeLater(() -> {
chatPanel.appendSystem("You were poked by " + fromName + ": " + message);
host.selectTab(tab);
JOptionPane.showMessageDialog(host, fromName + " poked you:\n\n" + message,
"Poke", JOptionPane.INFORMATION_MESSAGE);
});
}
}

View File

@@ -34,11 +34,14 @@ final class ServerTabPane extends JPanel {
private final Listener listener; private final Listener listener;
private final JTabbedPane tabbed = new JTabbedPane(); private final JTabbedPane tabbed = new JTabbedPane();
private final List<ServerTab> tabs = new ArrayList<>(); private final List<ServerTab> tabs = new ArrayList<>();
private final TabDragReorder dragReorder = new TabDragReorder(tabbed, this::moveTab);
/** True while the tab strip is in use, i.e. more than one connection is open. */ /** True while the tab strip is in use, i.e. more than one connection is open. */
private boolean tabbedMode; private boolean tabbedMode;
/** Suppresses selection callbacks while we rearrange the pane ourselves. */ /** Suppresses selection callbacks while we rearrange the pane ourselves. */
private boolean updating; private boolean updating;
/** Remembered so a drag-reorder can rebuild the tab labels without the caller's help. */
private ServerTab lastMicTab;
ServerTabPane(Listener listener) { ServerTabPane(Listener listener) {
super(new BorderLayout()); super(new BorderLayout());
@@ -51,6 +54,24 @@ final class ServerTabPane extends JPanel {
if (i >= 0 && i < tabs.size()) listener.selectTab(tabs.get(i)); if (i >= 0 && i < tabs.size()) listener.selectTab(tabs.get(i));
}); });
tabbed.addMouseWheelListener(this::onWheel); tabbed.addMouseWheelListener(this::onWheel);
dragReorder.attach(tabbed);
}
/** Drags a tab from one position to another, keeping the current selection on screen. */
private void moveTab(int from, int to) {
if (from < 0 || to < 0 || from >= tabs.size() || to >= tabs.size() || from == to) return;
ServerTab selected = tabbed.getSelectedIndex() >= 0 && tabbed.getSelectedIndex() < tabs.size()
? tabs.get(tabbed.getSelectedIndex()) : null;
tabs.add(to, tabs.remove(from));
updating = true;
try {
tabbed.removeAll();
for (ServerTab tab : tabs) tabbed.addTab(tab.title(), tab.component());
if (selected != null) tabbed.setSelectedIndex(tabs.indexOf(selected));
} finally {
updating = false;
}
refresh(lastMicTab);
} }
/** /**
@@ -93,6 +114,7 @@ final class ServerTabPane extends JPanel {
/** Refreshes the tab labels; {@code micTab} is marked as owning the microphone. */ /** Refreshes the tab labels; {@code micTab} is marked as owning the microphone. */
void refresh(ServerTab micTab) { void refresh(ServerTab micTab) {
lastMicTab = micTab;
if (!tabbedMode) return; if (!tabbedMode) return;
for (int i = 0; i < tabs.size(); i++) { for (int i = 0; i < tabs.size(); i++) {
ServerTab tab = tabs.get(i); ServerTab tab = tabs.get(i);
@@ -143,6 +165,8 @@ final class ServerTabPane extends JPanel {
} }
}); });
cell.add(label); cell.add(label);
dragReorder.attach(cell);
dragReorder.attach(label);
JButton close = new JButton(""); JButton close = new JButton("");
close.setFont(Theme.UI_FONT); close.setFont(Theme.UI_FONT);

View File

@@ -0,0 +1,106 @@
package com.ts3client.ui;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.TeamspeakConnection;
/**
* Mic/speaker/away/commander flags for one connection, plus forwarding them into
* {@link TeamspeakConnection}. Split out of {@link ServerTab} because the flags
* are reset together in one place (on (re)connect) and read from several
* (tray icon, toolbar, menu checkmarks).
*/
final class ServerTabSelfState {
private final TeamspeakConnection conn;
private boolean micMuted;
private boolean micLocalMuted;
private boolean deafened;
private boolean away;
/** Away message currently published, empty when away carries no message. */
private String awayMessage = "";
private boolean commander;
ServerTabSelfState(TeamspeakConnection conn) {
this.conn = conn;
}
boolean isMicMuted() {
return micMuted;
}
boolean isMicLocalMuted() {
return micLocalMuted;
}
boolean isDeafened() {
return deafened;
}
boolean isAway() {
return away;
}
String awayMessage() {
return awayMessage;
}
boolean isCommander() {
return commander;
}
void setMicMuted(boolean muted) {
micMuted = muted;
conn.setMicMuted(muted);
}
/** TS3's "Local Mic Mute": silences capture without publishing a status change. */
void setMicLocalMuted(boolean muted) {
micLocalMuted = muted;
conn.setMicLocalMuted(muted);
}
void setDeafened(boolean deaf) {
deafened = deaf;
conn.setDeafened(deaf);
if (deaf) micMuted = true;
}
/**
* @param message the away message, or null to keep the one already set — the
* message outlives coming back, so toggling away again restores it
*/
void setAway(boolean away, String message) {
this.away = away;
if (message != null) this.awayMessage = message;
conn.setAway(away, awayMessage);
}
void setCommander(boolean commander) {
this.commander = commander;
conn.setChannelCommander(commander);
}
/** Clears all flags on a fresh connection; the server starts us out clean, so nothing to publish. */
void resetOnConnect() {
micMuted = false;
micLocalMuted = false;
deafened = false;
away = false;
awayMessage = "";
commander = false;
}
/** What the local client looks like on this server, for the tray icon. */
SelfState compute() {
if (!conn.isConnected()) return SelfState.DISCONNECTED;
if (deafened) return SelfState.DEAFENED;
if (micMuted) return SelfState.MIC_MUTED;
if (micLocalMuted) return SelfState.MIC_LOCAL_MUTED;
if (away) return SelfState.AWAY;
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
boolean talking = self != null && self.talking;
if (commander) return talking ? SelfState.COMMANDER_TALKING : SelfState.COMMANDER;
return talking ? SelfState.TALKING : SelfState.IDLE;
}
}

View File

@@ -0,0 +1,202 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.TeamspeakConnection;
import com.ts3client.text.TsLink;
import javax.swing.JOptionPane;
import java.awt.Component;
/**
* Context-menu and drag/drop actions for one server's tree, plus tracking which
* node is selected so the info panel stays in sync. Also handles client/channel
* links clicked in the chat log, which open the same menus as the tree does.
*
* <p>Constructed before the {@link ServerTreePanel} it drives exists — the tree's
* constructor needs an {@link ServerTreePanel.Actions} up front — so {@link #attach}
* wires the tree back in once it has been built.
*/
final class ServerTabTreeActions implements ServerTreePanel.Actions {
private final MainFrame host;
private final ServerTab tab;
private final TeamspeakConnection conn;
private final ChatPanel chatPanel;
private final InfoPanel infoPanel;
private ServerTreePanel treePanel;
private Object currentSelection;
ServerTabTreeActions(MainFrame host, ServerTab tab, TeamspeakConnection conn,
ChatPanel chatPanel, InfoPanel infoPanel) {
this.host = host;
this.tab = tab;
this.conn = conn;
this.chatPanel = chatPanel;
this.infoPanel = infoPanel;
}
void attach(ServerTreePanel treePanel) {
this.treePanel = treePanel;
}
/** Refreshes the info panel for whatever is currently selected (or clears it). */
void renderInfo() {
Object sel = currentSelection;
if (sel instanceof ChannelNode) {
infoPanel.showChannel((ChannelNode) sel);
} else if (sel instanceof ClientEntry) {
infoPanel.showClient((ClientEntry) sel, conn.getModel(), conn.getIcons());
} else {
infoPanel.clear();
}
}
/** Drops the selection on disconnect, since the model it points into is gone. */
void clearSelection() {
currentSelection = null;
infoPanel.clear();
}
/** A client link in the chat log was clicked: show the same menu as the tree does. */
void handleClientLink(TsLink.Ref ref, Component source, int x, int y) {
ClientEntry client = conn.getModel().getClient(ref.id);
// The id is only valid for the session the link was made in; fall back to
// the unique id (and finally the nickname) so older links still resolve.
if (client == null || (!ref.uniqueId.isEmpty() && !ref.uniqueId.equals(client.uniqueId))) {
ClientEntry byUid = ref.uniqueId.isEmpty() ? null
: conn.getModel().findClientByUniqueId(ref.uniqueId);
if (byUid == null && !ref.name.isEmpty()) byUid = conn.getModel().findClientByName(ref.name);
if (byUid != null) client = byUid;
}
if (client == null) {
chatPanel.appendSystem("That client is no longer on the server.");
return;
}
ClientMenu.build(client, client.id == conn.getSelfClientId(), this).show(source, x, y);
}
void handleChannelLink(TsLink.Ref ref, Component source, int x, int y) {
ChannelNode channel = conn.getModel().getChannel(ref.id);
if (channel == null) {
chatPanel.appendSystem("That channel no longer exists.");
return;
}
ChannelMenu.build(channel, this).show(source, x, y);
}
// ---- ServerTreePanel.Actions ----
@Override
public void joinChannel(int channelId) {
if (conn.isConnected()) conn.joinChannel(channelId, null);
}
@Override
public void moveClientToChannel(ClientEntry client, ChannelNode target) {
if (!conn.isConnected()) return;
if (client.id == conn.getSelfClientId()) {
conn.joinChannel(target.id, null);
} else {
conn.moveClient(client.id, target.id, null);
}
}
@Override
public void moveChannel(ChannelNode channel, int newParentId, int orderPredecessorId) {
if (conn.isConnected()) conn.moveChannel(channel.id, newParentId, orderPredecessorId);
}
@Override
public void openPrivateChat(ClientEntry client) {
chatPanel.openPrivateChat(client.id, client.nickname);
}
@Override
public void pokeClient(ClientEntry client) {
String msg = JOptionPane.showInputDialog(host, "Poke message for " + client.nickname + ":", "Poke!");
if (msg != null) conn.poke(client.id, msg);
}
@Override
public void kickClientFromChannel(ClientEntry client) {
String reason = kickReason("Kick Client from Channel", client);
if (reason != null) conn.kickFromChannel(client.id, reason);
}
@Override
public void kickClientFromServer(ClientEntry client) {
String reason = kickReason("Kick Client from Server", client);
if (reason != null) conn.kickFromServer(client.id, reason);
}
private String kickReason(String title, ClientEntry client) {
if (!conn.isConnected()) return null;
return ReasonDialog.prompt(host, title, "Reason for kicking " + client.nickname + ":",
ReasonDialog.KICK_REASON_LIMIT);
}
@Override
public void banClient(ClientEntry client) {
if (!conn.isConnected()) return;
BanDialog dialog = new BanDialog(host, client.nickname);
dialog.setVisible(true);
if (dialog.isConfirmed()) conn.banClient(client.id, dialog.getSeconds(), dialog.getReason());
}
@Override
public void toggleClientMute(ClientEntry client) {
if (conn.getPlayback() == null) return;
boolean now = !conn.getPlayback().isClientMuted(client.id);
conn.getPlayback().setClientMuted(client.id, now);
}
@Override
public void findClientInTree(ClientEntry client) {
host.selectTab(tab);
treePanel.selectClient(client.id);
}
@Override
public void showConnectionInfo(ClientEntry client) {
if (!conn.isConnected()) return;
new ConnectionInfoDialog(host, conn, client.id, client.nickname).setVisible(true);
}
@Override
public void moveClientToOwnChannel(ClientEntry client) {
if (!conn.isConnected() || client.id == conn.getSelfClientId()) return;
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
if (self != null) conn.moveClient(client.id, self.channelId, null);
}
@Override
public void setChannelSubscribed(ChannelNode channel, boolean family, boolean subscribed) {
if (conn.isConnected()) conn.setChannelsSubscribed(channel.familyIds(family), subscribed);
}
@Override
public void browseFiles(ChannelNode channel) {
if (!conn.canTransferFiles()) return;
new FileBrowserDialog(host, conn, channel).setVisible(true);
}
@Override
public boolean isClientLocallyMuted(int clientId) {
return conn.getPlayback() != null && conn.getPlayback().isClientMuted(clientId);
}
@Override
public void onSelectionChanged(Object userObject) {
currentSelection = userObject;
renderInfo();
if (!conn.isConnected()) return;
if (userObject instanceof ChannelNode) {
ChannelNode ch = (ChannelNode) userObject;
if (!ch.descriptionLoaded) conn.requestChannelInfo(ch.id);
} else if (userObject instanceof ClientEntry) {
conn.requestClientInfo(((ClientEntry) userObject).id);
}
}
}

View File

@@ -0,0 +1,80 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import javax.swing.ImageIcon;
import javax.swing.JTree;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.DefaultTreeCellRenderer;
import java.awt.Component;
/**
* Draws a {@link ServerTreePanel} row: the status icon and the label. The group
* icon strip is painted separately, right-aligned, by {@link DropIndicatorTree}.
*/
final class ServerTreeCellRenderer extends DefaultTreeCellRenderer {
@Override
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel,
boolean expanded, boolean leaf, int row,
boolean hasFocus) {
super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus);
setBackgroundNonSelectionColor(Theme.TREE_BG);
setBackgroundSelectionColor(Theme.TREE_SELECTION);
setBorderSelectionColor(Theme.TREE_SELECTION);
Object obj = ((DefaultMutableTreeNode) value).getUserObject();
if (obj instanceof ChannelNode) {
ChannelNode c = (ChannelNode) obj;
Spacers.Spacer spacer = Spacers.parse(c.name);
if (spacer != null) {
setText(Spacers.render(spacer, 40));
setIcon(null);
setForeground(Theme.IDLE_CLIENT);
setFont(Theme.UI_FONT);
} else {
setText(c.name);
setIcon(iconFor(c));
setForeground(Theme.CHANNEL_TEXT);
setFont(Theme.UI_BOLD);
}
} else if (obj instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) obj;
String label = cl.nickname;
if (cl.away && !cl.awayMessage.isEmpty()) label += " [" + cl.awayMessage + "]";
setText(label);
setIcon(iconFor(cl));
setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT);
setFont(cl.talking ? Theme.UI_BOLD : Theme.UI_FONT);
} else {
// root / server
setText(String.valueOf(obj));
setIcon(Icons.server());
setForeground(Theme.SERVER_TEXT);
setFont(Theme.UI_BOLD);
}
return this;
}
private ImageIcon iconFor(ChannelNode c) {
if (c.hasPassword) return Icons.channelLocked(c.subscribed);
if (c.maxClients >= 0 && c.clients.size() >= c.maxClients) return Icons.channelFull(c.subscribed);
return Icons.channel(c.subscribed);
}
/** The client's state, in the order the official client gives them priority. */
private ImageIcon iconFor(ClientEntry cl) {
if (cl.isQuery()) return Icons.clientQuery();
if (!cl.outputHardware) return Icons.speakerDisabled();
if (cl.outputMuted) return Icons.speakerMuted();
if (!cl.inputHardware) return Icons.micDisabled();
if (cl.inputMuted) return Icons.micMuted();
if (cl.away) return Icons.clientAway();
if (cl.channelCommander) {
return cl.talking ? Icons.clientCommanderTalking() : Icons.clientCommander();
}
if (cl.talking) return Icons.clientTalking();
return Icons.clientIdle();
}
}

View File

@@ -0,0 +1,235 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.ServerModel;
import com.ts3client.text.TsLink;
import javax.swing.JComponent;
import javax.swing.JTree;
import javax.swing.TransferHandler;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.TreePath;
import java.awt.datatransfer.DataFlavor;
import java.awt.datatransfer.Transferable;
import java.awt.datatransfer.UnsupportedFlavorException;
import java.util.function.Function;
/**
* Drag-and-drop for the {@link ServerTreePanel} tree: dragging a client onto a
* channel moves it there, dragging a channel reorders/reparents it, and dropping
* either outside the tree yields its TS3 link BBCode, which the chat input accepts
* as plain text.
*/
final class ServerTreeDragAndDrop {
/** Carries the dragged node inside this JVM; drops elsewhere get the BBCode text. */
private static final DataFlavor NODE_FLAVOR = new DataFlavor(
DataFlavor.javaJVMLocalObjectMimeType + ";class=java.lang.Object",
"TeamSpeak tree node");
private final ServerModel model;
private final ServerTreePanel.Actions actions;
private final DropIndicatorTree tree;
/** Looks up the tree path currently showing a given channel/client, or {@code null}. */
private final Function<Object, TreePath> pathOf;
ServerTreeDragAndDrop(ServerModel model, ServerTreePanel.Actions actions, DropIndicatorTree tree,
Function<Object, TreePath> pathOf) {
this.model = model;
this.actions = actions;
this.tree = tree;
this.pathOf = pathOf;
}
TransferHandler transferHandler() {
return new TreeTransferHandler();
}
private static DefaultMutableTreeNode nodeOf(TreePath path) {
return path == null ? null : (DefaultMutableTreeNode) path.getLastPathComponent();
}
/**
* The channel a dragged client would land in, or {@code null} if the drop makes
* no sense (outside a channel, or the channel the client is already in).
*/
private ChannelNode resolveClientDrop(JTree.DropLocation loc, ClientEntry dragged) {
DefaultMutableTreeNode target = nodeOf(loc.getPath());
if (target == null) return null;
Object obj = target.getUserObject();
ChannelNode channel = null;
if (obj instanceof ChannelNode) {
channel = (ChannelNode) obj;
} else if (obj instanceof ClientEntry) {
channel = model.getChannel(((ClientEntry) obj).channelId);
}
if (channel == null || Spacers.isSpacer(channel.name)) return null;
if (channel.id == dragged.channelId) return null;
return channel;
}
/**
* The new parent and predecessor for a dragged channel as {@code {cpid, order}},
* or {@code null} if this drop is not a legal (or meaningful) move.
*/
private int[] resolveChannelDrop(JTree.DropLocation loc, ChannelNode dragged) {
DefaultMutableTreeNode target = nodeOf(loc.getPath());
if (target == null) return null;
DefaultMutableTreeNode parentNode;
int insertIndex;
if (loc.getChildIndex() >= 0) {
parentNode = target;
insertIndex = loc.getChildIndex();
} else {
// Dropped onto a node: become its last subchannel.
parentNode = target.getUserObject() instanceof ClientEntry
? (DefaultMutableTreeNode) target.getParent() : target;
if (parentNode == null) return null;
insertIndex = parentNode.getChildCount();
}
int parentId = 0;
Object parentObj = parentNode.getUserObject();
if (parentObj instanceof ChannelNode) {
ChannelNode parent = (ChannelNode) parentObj;
if (Spacers.isSpacer(parent.name)) return null;
if (isSelfOrDescendant(parent, dragged)) return null; // would detach the subtree
parentId = parent.id;
} else if (parentNode.getParent() != null) {
return null;
}
int predecessorId = 0;
for (int i = 0; i < insertIndex && i < parentNode.getChildCount(); i++) {
Object o = ((DefaultMutableTreeNode) parentNode.getChildAt(i)).getUserObject();
if (o instanceof ChannelNode && ((ChannelNode) o).id != dragged.id) {
predecessorId = ((ChannelNode) o).id;
}
}
if (parentId == dragged.parentId && predecessorId == dragged.order) return null; // no-op
return new int[]{parentId, predecessorId};
}
/** Whether {@code candidate} is {@code ancestor} itself or sits below it. */
private boolean isSelfOrDescendant(ChannelNode candidate, ChannelNode ancestor) {
ChannelNode c = candidate;
for (int guard = 0; c != null && guard < 64; guard++) {
if (c.id == ancestor.id) return true;
c = model.getChannel(c.parentId);
}
return false;
}
private final class TreeTransferHandler extends TransferHandler {
@Override
public int getSourceActions(JComponent c) {
return COPY | MOVE;
}
@Override
protected Transferable createTransferable(JComponent c) {
TreePath path = tree.getSelectionPath();
if (path == null) return null;
Object obj = nodeOf(path).getUserObject();
if (obj instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) obj;
return new NodeTransferable(cl, TsLink.clientBBCode(cl.id, cl.uniqueId, cl.nickname));
}
if (obj instanceof ChannelNode) {
ChannelNode ch = (ChannelNode) obj;
// Spacers have no meaningful link text, but can still be re-ordered.
return new NodeTransferable(ch, Spacers.isSpacer(ch.name)
? null : TsLink.channelBBCode(ch.id, ch.name));
}
return null;
}
@Override
public boolean canImport(TransferSupport support) {
if (!support.isDrop() || !support.isDataFlavorSupported(NODE_FLAVOR)) {
tree.highlightChannel(null);
return false;
}
if ((support.getSourceDropActions() & MOVE) == MOVE) support.setDropAction(MOVE);
Object resolved = resolve(support);
// A client always lands *inside* a channel, so mark that channel instead of
// drawing a line that would suggest a position among its clients.
tree.highlightChannel(resolved instanceof ChannelNode
? pathOf.apply(resolved) : null);
// The indicator spans the full width, past the row rectangles Swing
// repaints on its own when the drop location moves.
tree.repaint();
return resolved != null;
}
@Override
protected void exportDone(JComponent source, Transferable data, int action) {
tree.highlightChannel(null);
tree.repaint();
}
@Override
public boolean importData(TransferSupport support) {
if (!canImport(support)) return false;
Object dragged = draggedNode(support);
Object resolved = resolve(support);
tree.highlightChannel(null);
if (dragged instanceof ClientEntry) {
actions.moveClientToChannel((ClientEntry) dragged, (ChannelNode) resolved);
} else if (dragged instanceof ChannelNode) {
int[] place = (int[]) resolved;
actions.moveChannel((ChannelNode) dragged, place[0], place[1]);
}
return true;
}
/** The destination for this drop: a ChannelNode, an {@code {cpid, order}} pair, or null. */
private Object resolve(TransferSupport support) {
Object dragged = draggedNode(support);
if (!(support.getDropLocation() instanceof JTree.DropLocation)) return null;
JTree.DropLocation loc = (JTree.DropLocation) support.getDropLocation();
if (dragged instanceof ClientEntry) return resolveClientDrop(loc, (ClientEntry) dragged);
if (dragged instanceof ChannelNode) return resolveChannelDrop(loc, (ChannelNode) dragged);
return null;
}
private Object draggedNode(TransferSupport support) {
try {
return support.getTransferable().getTransferData(NODE_FLAVOR);
} catch (Exception e) {
return null;
}
}
}
/** Offers the dragged node locally and its TS3 link BBCode to other applications. */
private static final class NodeTransferable implements Transferable {
private final Object node;
private final String text;
NodeTransferable(Object node, String text) {
this.node = node;
this.text = text;
}
@Override
public DataFlavor[] getTransferDataFlavors() {
return text == null ? new DataFlavor[]{NODE_FLAVOR}
: new DataFlavor[]{NODE_FLAVOR, DataFlavor.stringFlavor};
}
@Override
public boolean isDataFlavorSupported(DataFlavor flavor) {
return NODE_FLAVOR.equals(flavor) || (text != null && DataFlavor.stringFlavor.equals(flavor));
}
@Override
public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException {
if (NODE_FLAVOR.equals(flavor)) return node;
if (text != null && DataFlavor.stringFlavor.equals(flavor)) return text;
throw new UnsupportedFlavorException(flavor);
}
}
}

View File

@@ -3,32 +3,15 @@ package com.ts3client.ui;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry; import com.ts3client.net.ClientEntry;
import com.ts3client.net.ServerModel; import com.ts3client.net.ServerModel;
import com.ts3client.text.TsLink;
import javax.swing.DropMode; import javax.swing.DropMode;
import javax.swing.Icon;
import javax.swing.ImageIcon;
import javax.swing.JComponent;
import javax.swing.JScrollPane; import javax.swing.JScrollPane;
import javax.swing.JTree;
import javax.swing.JViewport; import javax.swing.JViewport;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import javax.swing.TransferHandler;
import javax.swing.plaf.basic.BasicTreeUI; import javax.swing.plaf.basic.BasicTreeUI;
import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.DefaultTreeModel; import javax.swing.tree.DefaultTreeModel;
import javax.swing.tree.DefaultTreeCellRenderer;
import javax.swing.tree.TreeModel;
import javax.swing.tree.TreePath; import javax.swing.tree.TreePath;
import java.awt.BasicStroke;
import java.awt.Component;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.datatransfer.DataFlavor;
import java.awt.datatransfer.Transferable;
import java.awt.datatransfer.UnsupportedFlavorException;
import java.awt.event.MouseAdapter; import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent; import java.awt.event.MouseEvent;
import java.util.List; import java.util.List;
@@ -72,6 +55,9 @@ public final class ServerTreePanel extends JScrollPane {
boolean isClientLocallyMuted(int clientId); boolean isClientLocallyMuted(int clientId);
/** Selects and scrolls to a client in the tree, e.g. from a chat/log link. */
void findClientInTree(ClientEntry client);
/** A channel or client node was selected (or {@code null} when cleared). */ /** A channel or client node was selected (or {@code null} when cleared). */
void onSelectionChanged(Object userObject); void onSelectionChanged(Object userObject);
@@ -87,14 +73,11 @@ public final class ServerTreePanel extends JScrollPane {
void moveChannel(ChannelNode channel, int newParentId, int orderPredecessorId); void moveChannel(ChannelNode channel, int newParentId, int orderPredecessorId);
} }
/** Carries the dragged node inside this JVM; drops elsewhere get the BBCode text. */
private static final DataFlavor NODE_FLAVOR = new DataFlavor(
DataFlavor.javaJVMLocalObjectMimeType + ";class=java.lang.Object",
"TeamSpeak tree node");
private final DropIndicatorTree tree; private final DropIndicatorTree tree;
private final DefaultMutableTreeNode root = new DefaultMutableTreeNode(); private final DefaultMutableTreeNode root = new DefaultMutableTreeNode();
private final DefaultTreeModel treeModel = new DefaultTreeModel(root); private final DefaultTreeModel treeModel = new DefaultTreeModel(root);
/** True while {@link #rebuild()} clears and restores the selection, to swallow the transient null in between. */
private boolean rebuilding;
private final ServerModel model; private final ServerModel model;
private final GroupIcons groupIcons; private final GroupIcons groupIcons;
private final Actions actions; private final Actions actions;
@@ -105,7 +88,7 @@ public final class ServerTreePanel extends JScrollPane {
this.groupIcons = groupIcons; this.groupIcons = groupIcons;
this.actions = actions; this.actions = actions;
root.setUserObject("Not connected"); root.setUserObject("Not connected");
this.tree = new DropIndicatorTree(treeModel); this.tree = new DropIndicatorTree(treeModel, model, groupIcons);
tree.setRootVisible(true); tree.setRootVisible(true);
// The server node is the only top-level row and always stays open, so it gets // The server node is the only top-level row and always stays open, so it gets
// no expand control. Nesting is tightened too: horizontal space in this view // no expand control. Nesting is tightened too: horizontal space in this view
@@ -119,7 +102,7 @@ public final class ServerTreePanel extends JScrollPane {
tree.setRowHeight(20); tree.setRowHeight(20);
tree.setBackground(Theme.TREE_BG); tree.setBackground(Theme.TREE_BG);
tree.setFont(Theme.UI_FONT); tree.setFont(Theme.UI_FONT);
tree.setCellRenderer(new Renderer()); tree.setCellRenderer(new ServerTreeCellRenderer());
setViewportView(tree); setViewportView(tree);
getViewport().setBackground(Theme.TREE_BG); getViewport().setBackground(Theme.TREE_BG);
// The icon strip is drawn against the viewport's right edge, so the blitted // The icon strip is drawn against the viewport's right edge, so the blitted
@@ -130,9 +113,10 @@ public final class ServerTreePanel extends JScrollPane {
// yields the TS3 link BBCode, which the chat input accepts as plain text. // yields the TS3 link BBCode, which the chat input accepts as plain text.
tree.setDragEnabled(true); tree.setDragEnabled(true);
tree.setDropMode(DropMode.ON_OR_INSERT); tree.setDropMode(DropMode.ON_OR_INSERT);
tree.setTransferHandler(new TreeTransferHandler()); tree.setTransferHandler(new ServerTreeDragAndDrop(model, actions, tree, this::pathOf).transferHandler());
tree.addTreeSelectionListener(e -> { tree.addTreeSelectionListener(e -> {
if (rebuilding) return;
TreePath path = tree.getSelectionPath(); TreePath path = tree.getSelectionPath();
Object obj = null; Object obj = null;
if (path != null) { if (path != null) {
@@ -172,6 +156,16 @@ public final class ServerTreePanel extends JScrollPane {
this.selfClientId = id; this.selfClientId = id;
} }
/** Selects and scrolls to the row showing {@code client}, if it is currently visible. */
public void selectClient(int clientId) {
ClientEntry client = model.getClient(clientId);
if (client == null) return;
TreePath path = pathOf(client);
if (path == null) return;
tree.setSelectionPath(path);
tree.scrollPathToVisible(path);
}
private Object nodeAt(MouseEvent e) { private Object nodeAt(MouseEvent e) {
TreePath path = tree.getPathForLocation(e.getX(), e.getY()); TreePath path = tree.getPathForLocation(e.getX(), e.getY());
if (path == null) return null; if (path == null) return null;
@@ -201,12 +195,6 @@ public final class ServerTreePanel extends JScrollPane {
ChannelMenu.build(channel, actions).show(tree, e.getX(), e.getY()); ChannelMenu.build(channel, actions).show(tree, e.getX(), e.getY());
} }
// ---- drag and drop ----
private static DefaultMutableTreeNode nodeOf(TreePath path) {
return path == null ? null : (DefaultMutableTreeNode) path.getLastPathComponent();
}
/** The path of the tree node showing {@code target}, or {@code null}. */ /** The path of the tree node showing {@code target}, or {@code null}. */
private TreePath pathOf(Object target) { private TreePath pathOf(Object target) {
java.util.Enumeration<?> nodes = root.breadthFirstEnumeration(); java.util.Enumeration<?> nodes = root.breadthFirstEnumeration();
@@ -218,332 +206,16 @@ public final class ServerTreePanel extends JScrollPane {
} }
/** /**
* The channel a dragged client would land in, or {@code null} if the drop makes * Rebuilds the tree from the model, preserving full expansion. Rebuilding
* no sense (outside a channel, or the channel the client is already in). * replaces every tree node, which would otherwise drop the current
* selection on every update (a client talking, a group change, …); the
* previously selected channel/client is restored by identity once the new
* nodes are in place, so a selection sticks until the user changes it.
*/ */
private ChannelNode resolveClientDrop(JTree.DropLocation loc, ClientEntry dragged) {
DefaultMutableTreeNode target = nodeOf(loc.getPath());
if (target == null) return null;
Object obj = target.getUserObject();
ChannelNode channel = null;
if (obj instanceof ChannelNode) {
channel = (ChannelNode) obj;
} else if (obj instanceof ClientEntry) {
channel = model.getChannel(((ClientEntry) obj).channelId);
}
if (channel == null || Spacers.isSpacer(channel.name)) return null;
if (channel.id == dragged.channelId) return null;
return channel;
}
/**
* The new parent and predecessor for a dragged channel as {@code {cpid, order}},
* or {@code null} if this drop is not a legal (or meaningful) move.
*/
private int[] resolveChannelDrop(JTree.DropLocation loc, ChannelNode dragged) {
DefaultMutableTreeNode target = nodeOf(loc.getPath());
if (target == null) return null;
DefaultMutableTreeNode parentNode;
int insertIndex;
if (loc.getChildIndex() >= 0) {
parentNode = target;
insertIndex = loc.getChildIndex();
} else {
// Dropped onto a node: become its last subchannel.
parentNode = target.getUserObject() instanceof ClientEntry
? (DefaultMutableTreeNode) target.getParent() : target;
if (parentNode == null) return null;
insertIndex = parentNode.getChildCount();
}
int parentId = 0;
Object parentObj = parentNode.getUserObject();
if (parentObj instanceof ChannelNode) {
ChannelNode parent = (ChannelNode) parentObj;
if (Spacers.isSpacer(parent.name)) return null;
if (isSelfOrDescendant(parent, dragged)) return null; // would detach the subtree
parentId = parent.id;
} else if (parentNode != root) {
return null;
}
int predecessorId = 0;
for (int i = 0; i < insertIndex && i < parentNode.getChildCount(); i++) {
Object o = ((DefaultMutableTreeNode) parentNode.getChildAt(i)).getUserObject();
if (o instanceof ChannelNode && ((ChannelNode) o).id != dragged.id) {
predecessorId = ((ChannelNode) o).id;
}
}
if (parentId == dragged.parentId && predecessorId == dragged.order) return null; // no-op
return new int[]{parentId, predecessorId};
}
/** Whether {@code candidate} is {@code ancestor} itself or sits below it. */
private boolean isSelfOrDescendant(ChannelNode candidate, ChannelNode ancestor) {
ChannelNode c = candidate;
for (int guard = 0; c != null && guard < 64; guard++) {
if (c.id == ancestor.id) return true;
c = model.getChannel(c.parentId);
}
return false;
}
private final class TreeTransferHandler extends TransferHandler {
@Override
public int getSourceActions(JComponent c) {
return COPY | MOVE;
}
@Override
protected Transferable createTransferable(JComponent c) {
TreePath path = tree.getSelectionPath();
if (path == null) return null;
Object obj = nodeOf(path).getUserObject();
if (obj instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) obj;
return new NodeTransferable(cl, TsLink.clientBBCode(cl.id, cl.uniqueId, cl.nickname));
}
if (obj instanceof ChannelNode) {
ChannelNode ch = (ChannelNode) obj;
// Spacers have no meaningful link text, but can still be re-ordered.
return new NodeTransferable(ch, Spacers.isSpacer(ch.name)
? null : TsLink.channelBBCode(ch.id, ch.name));
}
return null;
}
@Override
public boolean canImport(TransferSupport support) {
if (!support.isDrop() || !support.isDataFlavorSupported(NODE_FLAVOR)) {
tree.highlightChannel(null);
return false;
}
if ((support.getSourceDropActions() & MOVE) == MOVE) support.setDropAction(MOVE);
Object resolved = resolve(support);
// A client always lands *inside* a channel, so mark that channel instead of
// drawing a line that would suggest a position among its clients.
tree.highlightChannel(resolved instanceof ChannelNode
? pathOf((ChannelNode) resolved) : null);
// The indicator spans the full width, past the row rectangles Swing
// repaints on its own when the drop location moves.
tree.repaint();
return resolved != null;
}
@Override
protected void exportDone(JComponent source, Transferable data, int action) {
tree.highlightChannel(null);
tree.repaint();
}
@Override
public boolean importData(TransferSupport support) {
if (!canImport(support)) return false;
Object dragged = draggedNode(support);
Object resolved = resolve(support);
tree.highlightChannel(null);
if (dragged instanceof ClientEntry) {
actions.moveClientToChannel((ClientEntry) dragged, (ChannelNode) resolved);
} else if (dragged instanceof ChannelNode) {
int[] place = (int[]) resolved;
actions.moveChannel((ChannelNode) dragged, place[0], place[1]);
}
return true;
}
/** The destination for this drop: a ChannelNode, an {@code {cpid, order}} pair, or null. */
private Object resolve(TransferSupport support) {
Object dragged = draggedNode(support);
if (!(support.getDropLocation() instanceof JTree.DropLocation)) return null;
JTree.DropLocation loc = (JTree.DropLocation) support.getDropLocation();
if (dragged instanceof ClientEntry) return resolveClientDrop(loc, (ClientEntry) dragged);
if (dragged instanceof ChannelNode) return resolveChannelDrop(loc, (ChannelNode) dragged);
return null;
}
private Object draggedNode(TransferSupport support) {
try {
return support.getTransferable().getTransferData(NODE_FLAVOR);
} catch (Exception e) {
return null;
}
}
}
/** Offers the dragged node locally and its TS3 link BBCode to other applications. */
private static final class NodeTransferable implements Transferable {
private final Object node;
private final String text;
NodeTransferable(Object node, String text) {
this.node = node;
this.text = text;
}
@Override
public DataFlavor[] getTransferDataFlavors() {
return text == null ? new DataFlavor[]{NODE_FLAVOR}
: new DataFlavor[]{NODE_FLAVOR, DataFlavor.stringFlavor};
}
@Override
public boolean isDataFlavorSupported(DataFlavor flavor) {
return NODE_FLAVOR.equals(flavor) || (text != null && DataFlavor.stringFlavor.equals(flavor));
}
@Override
public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException {
if (NODE_FLAVOR.equals(flavor)) return node;
if (text != null && DataFlavor.stringFlavor.equals(flavor)) return text;
throw new UnsupportedFlavorException(flavor);
}
}
// ---- group icon strip ----
/** Gap kept between a row's label and the right-aligned icon strip. */
private static final int BADGE_GAP = 8;
/** Inset of the strip from the visible right edge. */
private static final int BADGE_MARGIN = 4;
/**
* Paints the icons of every visible row — a client's group icons, a channel's own
* icon — flush with the right edge of the viewport, the way TeamSpeak lines them up.
* Drawing them here rather than in the cell renderer keeps the rows' measured widths
* (and thus the selection highlight) tied to the label alone.
*/
private void paintBadges(Graphics g, JTree tree) {
Rectangle visible = tree.getVisibleRect();
int right = visible.x + visible.width - BADGE_MARGIN;
for (int row = 0; row < tree.getRowCount(); row++) {
Rectangle bounds = tree.getRowBounds(row);
if (bounds == null || bounds.y + bounds.height < visible.y) continue;
if (bounds.y > visible.y + visible.height) break;
TreePath path = tree.getPathForRow(row);
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
List<Icon> icons = badgesOf(obj);
if (icons.isEmpty()) continue;
GroupIcons.Row strip = new GroupIcons.Row(icons);
int x = Math.max(bounds.x + bounds.width + BADGE_GAP, right - strip.getIconWidth());
strip.paintIcon(tree, g, x, bounds.y + (bounds.height - strip.getIconHeight()) / 2);
}
}
/** The icon strip a row shows on its right, empty when it has none (yet). */
private List<Icon> badgesOf(Object node) {
if (node instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) node;
return groupIcons.iconsOf(
model.serverGroupsOf(cl.serverGroupIds), model.channelGroup(cl.channelGroupId));
}
if (node instanceof ChannelNode) {
ChannelNode ch = (ChannelNode) node;
Icon icon = Spacers.isSpacer(ch.name) ? null : groupIcons.icon(ch.iconId);
if (icon != null) return List.of(icon);
}
return List.of();
}
/**
* Draws where the drop will land: an insertion line between rows, or an outline
* around the row that will receive the dragged node.
*/
private final class DropIndicatorTree extends JTree {
/** Set while a drop would move a client into this channel row. */
private TreePath highlight;
DropIndicatorTree(TreeModel model) {
super(model);
}
/** Keeps the server row permanently open; collapsing it would hide everything. */
@Override
public void setExpandedState(TreePath path, boolean state) {
if (!state && path.getPathCount() == 1) return;
super.setExpandedState(path, state);
}
/**
* Widens every repaint request to the full visible width. Swing only asks for
* the row rectangle, which stops short of the right-aligned icon strip and would
* leave it behind when a row's label changes width.
*/
@Override
public void repaint(long tm, int x, int y, int width, int height) {
Rectangle visible = getVisibleRect();
super.repaint(tm, visible.x, y, visible.width, height);
}
void highlightChannel(TreePath path) {
if (path == highlight || (path != null && path.equals(highlight))) return;
highlight = path;
repaint();
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
paintBadges(g, this);
JTree.DropLocation loc = getDropLocation();
if (loc == null || loc.getPath() == null) return;
Graphics2D g2 = (Graphics2D) g.create();
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(Theme.ACCENT);
if (highlight != null || loc.getChildIndex() < 0) {
Rectangle r = getPathBounds(highlight != null ? highlight : loc.getPath());
if (r != null) {
g2.setStroke(new BasicStroke(2f));
g2.drawRoundRect(r.x, r.y + 1, r.width - 1, r.height - 3, 4, 4);
}
} else {
Rectangle line = insertLine(loc);
if (line != null) {
g2.fillRect(line.x, line.y - 1, line.width, 2);
g2.fillOval(line.x - 3, line.y - 4, 7, 7);
}
}
g2.dispose();
}
/** The 1px-tall strip where the insertion line goes, in tree coordinates. */
private Rectangle insertLine(JTree.DropLocation loc) {
DefaultMutableTreeNode parent = (DefaultMutableTreeNode) loc.getPath().getLastPathComponent();
int index = loc.getChildIndex();
if (index < parent.getChildCount()) {
Rectangle r = getPathBounds(loc.getPath().pathByAddingChild(parent.getChildAt(index)));
return r == null ? null : new Rectangle(r.x, r.y, getWidth() - r.x, 2);
}
if (parent.getChildCount() == 0) {
Rectangle r = getPathBounds(loc.getPath());
if (r == null) return null;
int x = r.x + getRowHeight();
return new Rectangle(x, r.y + r.height, getWidth() - x, 2);
}
// Past the last child: below that child's whole (expanded) subtree.
TreePath lastChild = loc.getPath().pathByAddingChild(parent.getChildAt(parent.getChildCount() - 1));
Rectangle head = getPathBounds(lastChild);
Rectangle tail = getPathBounds(lastVisibleRow(lastChild));
if (head == null || tail == null) return null;
return new Rectangle(head.x, tail.y + tail.height, getWidth() - head.x, 2);
}
private TreePath lastVisibleRow(TreePath path) {
int row = getRowForPath(path);
if (row < 0) return path;
for (int i = row + 1; i < getRowCount(); i++) {
if (!path.isDescendant(getPathForRow(i))) break;
row = i;
}
return getPathForRow(row);
}
}
/** Rebuilds the tree from the model, preserving full expansion. */
public void rebuild() { public void rebuild() {
Object selected = selectedUserObject();
rebuilding = true;
try {
root.setUserObject(model.getServerName()); root.setUserObject(model.getServerName());
root.removeAllChildren(); root.removeAllChildren();
List<ChannelNode> roots = model.buildTree(); List<ChannelNode> roots = model.buildTree();
@@ -554,6 +226,22 @@ public final class ServerTreePanel extends JScrollPane {
for (int i = 0; i < tree.getRowCount(); i++) { for (int i = 0; i < tree.getRowCount(); i++) {
tree.expandRow(i); tree.expandRow(i);
} }
if (selected != null) {
TreePath path = pathOf(selected);
if (path != null) tree.setSelectionPath(path);
}
} finally {
rebuilding = false;
}
// The selection listener was swallowed above; tell the caller only if it actually changed
// (e.g. the previously selected channel/client is gone), since it already knows the rest.
Object nowSelected = selectedUserObject();
if (nowSelected != selected) actions.onSelectionChanged(nowSelected);
}
private Object selectedUserObject() {
TreePath path = tree.getSelectionPath();
return path == null ? null : ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
} }
private DefaultMutableTreeNode buildChannel(ChannelNode c) { private DefaultMutableTreeNode buildChannel(ChannelNode c) {
@@ -593,73 +281,4 @@ public final class ServerTreePanel extends JScrollPane {
} }
} }
/**
* Draws a tree row: the status icon and the label. The group icon strip is painted
* separately, right-aligned, by {@link #paintBadges}.
*/
private final class Renderer extends DefaultTreeCellRenderer {
@Override
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel,
boolean expanded, boolean leaf, int row,
boolean hasFocus) {
super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus);
setBackgroundNonSelectionColor(Theme.TREE_BG);
setBackgroundSelectionColor(Theme.TREE_SELECTION);
setBorderSelectionColor(Theme.TREE_SELECTION);
Object obj = ((DefaultMutableTreeNode) value).getUserObject();
if (obj instanceof ChannelNode) {
ChannelNode c = (ChannelNode) obj;
Spacers.Spacer spacer = Spacers.parse(c.name);
if (spacer != null) {
setText(Spacers.render(spacer, 40));
setIcon(null);
setForeground(Theme.IDLE_CLIENT);
setFont(Theme.UI_FONT);
} else {
setText(c.name);
setIcon(iconFor(c));
setForeground(Theme.CHANNEL_TEXT);
setFont(Theme.UI_BOLD);
}
} else if (obj instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) obj;
String label = cl.nickname;
if (cl.away && !cl.awayMessage.isEmpty()) label += " [" + cl.awayMessage + "]";
setText(label);
setIcon(iconFor(cl));
setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT);
setFont(cl.talking ? Theme.UI_BOLD : Theme.UI_FONT);
} else {
// root / server
setText(String.valueOf(obj));
setIcon(Icons.server());
setForeground(Theme.SERVER_TEXT);
setFont(Theme.UI_BOLD);
}
return this;
}
private ImageIcon iconFor(ChannelNode c) {
if (c.hasPassword) return Icons.channelLocked(c.subscribed);
if (c.maxClients >= 0 && c.clients.size() >= c.maxClients) return Icons.channelFull(c.subscribed);
return Icons.channel(c.subscribed);
}
/** The client's state, in the order the official client gives them priority. */
private ImageIcon iconFor(ClientEntry cl) {
if (cl.isQuery()) return Icons.clientQuery();
if (!cl.outputHardware) return Icons.speakerDisabled();
if (cl.outputMuted) return Icons.speakerMuted();
if (!cl.inputHardware) return Icons.micDisabled();
if (cl.inputMuted) return Icons.micMuted();
if (cl.away) return Icons.clientAway();
if (cl.channelCommander) {
return cl.talking ? Icons.clientCommanderTalking() : Icons.clientCommander();
}
if (cl.talking) return Icons.clientTalking();
return Icons.clientIdle();
}
}
} }

View File

@@ -1,98 +1,40 @@
package com.ts3client.ui; package com.ts3client.ui;
import com.ts3client.audio.InputLevel;
import com.ts3client.audio.OpusParameters; import com.ts3client.audio.OpusParameters;
import com.ts3client.audio.VoiceInput; import com.ts3client.audio.VoiceInput;
import com.ts3client.audio.VoiceOutput; import com.ts3client.audio.VoiceOutput;
import com.ts3client.audio.desktop.AudioDevices;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import com.ts3client.hotkey.Hotkey;
import com.ts3client.hotkey.HotkeyAction;
import com.ts3client.sound.SoundNotifier; import com.ts3client.sound.SoundNotifier;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.ButtonGroup;
import javax.swing.JButton; import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JDialog; import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JPanel; import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSlider;
import javax.swing.JTabbedPane; import javax.swing.JTabbedPane;
import javax.swing.JToggleButton;
import javax.swing.Scrollable;
import javax.swing.SwingUtilities;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.Dimension; import java.awt.Dimension;
import java.awt.Frame; import java.awt.Frame;
import java.awt.GridBagConstraints; import java.util.function.Consumer;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Rectangle;
import java.util.List;
/** /**
* Options dialog: audio device selection plus voice-activation / push-to-talk * Options dialog: a tabbed coordinator over the individual settings pages. Each tab owns
* tuning with a live input meter. Changes are applied to the running audio * its own controls and reads/writes {@link Settings} on {@link #apply()}; this class wires
* subsystem immediately and persisted to {@link Settings} on OK. * the tabs that preview themselves live against the running audio subsystem (the device
* and voice-activation tabs, which share a single microphone test) and owns the
* OK/Cancel/Apply plumbing.
*/ */
public final class SettingsDialog extends JDialog { public final class SettingsDialog extends JDialog {
/** Preferred widths of the form's field column; rows shrink with the dialog from there. */
private static final int FIELD_WIDTH = 240;
private static final int SLIDER_WIDTH = 200;
private static final int MIN_FIELD_WIDTH = 60;
private static final int MIN_BITRATE_KBITS = 8;
private static final int MAX_BITRATE_KBITS = 160;
private final Settings settings; private final Settings settings;
private final VoiceInput liveMic; private final VoiceInput liveMic;
private final VoiceOutput livePlayback; private final VoiceOutput livePlayback;
private final SoundNotifier sounds;
private final HotkeyService hotkeys;
private final Runnable onApply; private final Runnable onApply;
private NotificationsPanel notificationsPanel; private final DevicesPanel devicesPanel;
private IconPackPanel iconPackPanel; private final VoiceActivationPanel voiceActivationPanel;
private ClientVersionPanel clientVersionPanel; private final NotificationsPanel notificationsPanel;
private HotkeysPanel hotkeysPanel; private final IconPackPanel iconPackPanel;
private final HotkeysPanel hotkeysPanel;
private JComboBox<AudioDevices.Device> inputCombo; private final ClientVersionPanel clientVersionPanel;
private JComboBox<AudioDevices.Device> outputCombo;
private JSlider inputGain;
private JSlider outputVol;
private JCheckBox denoiseCheck;
private JSlider denoiseLevel;
private JCheckBox typingCheck;
private JCheckBox agcCheck;
private JRadioButton vadRadio;
private JRadioButton pttRadio;
private JRadioButton contRadio;
private JComboBox<String> vadModeCombo;
private JSlider thresholdSlider;
private JSlider speechSlider;
private JCheckBox vadOverPttCheck;
private JLabel thresholdLabel;
private JLabel speechLabel;
private LevelMeter meter;
private JToggleButton testButton;
private JCheckBox loopbackCheck;
private JLabel talkIndicator;
private JButton pttKeyButton;
private JSlider bitrateSlider;
private JLabel bitrateLabel;
private JSlider complexitySlider;
private JCheckBox vbrCheck;
private JCheckBox fecCheck;
private JCheckBox musicCheck;
private final MicrophoneTest micTest = new MicrophoneTest(this::onTestLevel, this::onTestTalking);
public SettingsDialog(Frame owner, Settings settings, public SettingsDialog(Frame owner, Settings settings,
VoiceInput liveMic, VoiceOutput livePlayback, VoiceInput liveMic, VoiceOutput livePlayback,
@@ -101,20 +43,23 @@ public final class SettingsDialog extends JDialog {
this.settings = settings; this.settings = settings;
this.liveMic = liveMic; this.liveMic = liveMic;
this.livePlayback = livePlayback; this.livePlayback = livePlayback;
this.sounds = sounds;
this.onApply = onApply; this.onApply = onApply;
this.hotkeys = hotkeys;
notificationsPanel = new NotificationsPanel(settings, sounds);
iconPackPanel = new IconPackPanel(settings);
hotkeysPanel = new HotkeysPanel(hotkeys);
clientVersionPanel = new ClientVersionPanel(settings);
devicesPanel = new DevicesPanel(settings, livePlayback,
this::applyLive, this::restartTest, this::setTestOutputDevice);
voiceActivationPanel = new VoiceActivationPanel(settings, liveMic, hotkeys,
hotkeysPanel, this::audioSnapshot);
JTabbedPane tabs = new JTabbedPane(); JTabbedPane tabs = new JTabbedPane();
tabs.addTab("Playback / Capture", scrollable(buildDevicesTab())); tabs.addTab("Playback / Capture", scrollable(devicesPanel));
tabs.addTab("Voice Activation", scrollable(buildVoiceTab())); tabs.addTab("Voice Activation", scrollable(voiceActivationPanel));
notificationsPanel = new NotificationsPanel(settings, sounds);
tabs.addTab("Notifications", notificationsPanel); tabs.addTab("Notifications", notificationsPanel);
iconPackPanel = new IconPackPanel(settings);
tabs.addTab("Design", iconPackPanel); tabs.addTab("Design", iconPackPanel);
hotkeysPanel = new HotkeysPanel(hotkeys);
tabs.addTab("Hotkeys", hotkeysPanel); tabs.addTab("Hotkeys", hotkeysPanel);
clientVersionPanel = new ClientVersionPanel(settings);
tabs.addTab("Client Version", scrollable(clientVersionPanel)); tabs.addTab("Client Version", scrollable(clientVersionPanel));
JPanel buttons = new JPanel(new BorderLayout()); JPanel buttons = new JPanel(new BorderLayout());
@@ -139,7 +84,7 @@ public final class SettingsDialog extends JDialog {
addWindowListener(new java.awt.event.WindowAdapter() { addWindowListener(new java.awt.event.WindowAdapter() {
@Override @Override
public void windowClosed(java.awt.event.WindowEvent e) { public void windowClosed(java.awt.event.WindowEvent e) {
micTest.stop(); voiceActivationPanel.stopTest();
} }
}); });
@@ -149,327 +94,6 @@ public final class SettingsDialog extends JDialog {
setLocationRelativeTo(owner); setLocationRelativeTo(owner);
} }
private JPanel buildDevicesTab() {
JPanel p = formPanel();
GridBagConstraints c = gbc();
List<AudioDevices.Device> ins = AudioDevices.inputDevices();
List<AudioDevices.Device> outs = AudioDevices.outputDevices();
inputCombo = new JComboBox<>(ins.toArray(new AudioDevices.Device[0]));
outputCombo = new JComboBox<>(outs.toArray(new AudioDevices.Device[0]));
selectOrDefault(inputCombo, settings.inputDevice);
selectOrDefault(outputCombo, settings.outputDevice);
String deviceHint = "<html>Named devices are PipeWire's, and are routed through it "
+ "(so per-application volume and rerouting keep working).<br>"
+ "<b>ALSA:</b> entries talk to the sound card directly, taking it exclusively.</html>";
inputCombo.setToolTipText(deviceHint);
outputCombo.setToolTipText(deviceHint);
limitWidth(inputCombo, FIELD_WIDTH);
limitWidth(outputCombo, FIELD_WIDTH);
int row = 0;
addRow(p, c, row++, new JLabel("Capture device (microphone):"), inputCombo);
addRow(p, c, row++, new JLabel("Playback device (speakers):"), outputCombo);
inputGain = new JSlider(0, 200, (int) Math.round(settings.inputVolume * 100));
outputVol = new JSlider(0, 200, (int) Math.round(settings.outputVolume * 100));
limitWidth(inputGain, SLIDER_WIDTH);
limitWidth(outputVol, SLIDER_WIDTH);
addRow(p, c, row++, new JLabel("Microphone gain:"), inputGain);
addRow(p, c, row++, new JLabel("Playback volume:"), outputVol);
outputVol.addChangeListener(e -> {
if (livePlayback != null) livePlayback.setMasterVolume(outputVol.getValue() / 100.0);
});
inputGain.addChangeListener(e ->
applyLive(m -> m.setInputGain(inputGain.getValue() / 100.0)));
inputCombo.addActionListener(e -> restartTest());
outputCombo.addActionListener(e -> micTest.setOutputDevice(comboValue(outputCombo)));
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
c.insets = new Insets(14, 4, 2, 4);
p.add(new JLabel("Noise reduction"), c);
c.insets = new Insets(4, 4, 4, 4);
c.gridwidth = 1;
denoiseCheck = new JCheckBox("Remove background noise", settings.denoise);
denoiseCheck.setToolTipText("Attempt to filter out background noises.");
denoiseLevel = new JSlider(0, 100, (int) Math.round(settings.denoiserLevel * 100));
limitWidth(denoiseLevel, SLIDER_WIDTH);
denoiseLevel.setToolTipText("Higher = more aggressive noise removal.");
typingCheck = new JCheckBox("Typing attenuation", settings.typingAttenuation);
typingCheck.setToolTipText("<html><b>Typing attenuation</b> tries to detect and "
+ "reduce the sounds made by typing.</html>");
agcCheck = new JCheckBox("Automatic gain control (AGC)", settings.agc);
agcCheck.setToolTipText("<html><b>Automatic gain control</b> normalises your "
+ "microphone loudness to a target level, boosting quiet mics and taming "
+ "loud ones.</html>");
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
p.add(denoiseCheck, c);
c.gridwidth = 1;
addRow(p, c, row++, new JLabel("Noise removal level:"), denoiseLevel);
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
p.add(typingCheck, c);
c.gridy = row++;
p.add(agcCheck, c);
c.gridwidth = 1;
Runnable syncNoise = () -> {
denoiseLevel.setEnabled(denoiseCheck.isSelected());
applyLive(m -> {
m.setNoiseSuppression(denoiseCheck.isSelected());
m.setDenoiserLevel(denoiseLevel.getValue() / 100.0);
m.setTypingAttenuation(typingCheck.isSelected());
m.setAgc(agcCheck.isSelected());
});
};
denoiseCheck.addActionListener(e -> syncNoise.run());
typingCheck.addActionListener(e -> syncNoise.run());
agcCheck.addActionListener(e -> syncNoise.run());
denoiseLevel.addChangeListener(e ->
applyLive(m -> m.setDenoiserLevel(denoiseLevel.getValue() / 100.0)));
syncNoise.run();
// filler
c.gridx = 0;
c.gridy = row;
c.weighty = 1;
p.add(Box.createGlue(), c);
return p;
}
private JPanel buildVoiceTab() {
JPanel p = formPanel();
GridBagConstraints c = gbc();
vadRadio = new JRadioButton("Voice Activation Detection");
pttRadio = new JRadioButton("Push-To-Talk");
contRadio = new JRadioButton("Continuous");
ButtonGroup group = new ButtonGroup();
group.add(vadRadio);
group.add(pttRadio);
group.add(contRadio);
switch (settings.inputMode) {
case PUSH_TO_TALK:
pttRadio.setSelected(true);
break;
case CONTINUOUS:
contRadio.setSelected(true);
break;
default:
vadRadio.setSelected(true);
}
int row = 0;
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
p.add(vadRadio, c);
c.gridy = row++;
p.add(pttRadio, c);
c.gridy = row++;
p.add(contRadio, c);
c.gridwidth = 1;
meter = new LevelMeter();
meter.setThreshold(settings.vadThresholdDb);
c.gridx = 0;
c.gridy = row;
c.gridwidth = 2;
c.insets = new Insets(10, 4, 2, 4);
p.add(new JLabel("Input level:"), c);
c.gridy = ++row;
p.add(meter, c);
c.insets = new Insets(4, 4, 4, 4);
c.gridy = ++row;
p.add(buildTestControls(), c);
c.gridwidth = 1;
row++;
vadModeCombo = new JComboBox<>(new String[]{"Automatic", "Volume Gate", "Hybrid"});
vadModeCombo.setSelectedIndex(vadModeIndex(settings.vadMode));
vadModeCombo.addActionListener(e -> micTest.configure(m -> m.setVadMode(currentVadMode())));
vadModeCombo.setToolTipText("<html><b>Automatic</b>: intelligent speech detection.<br>"
+ "<b>Volume Gate</b>: transmit when loud enough.<br>"
+ "<b>Hybrid</b>: loud enough <i>and</i> detected as speech.</html>");
addRow(p, c, row++, new JLabel("Detection:"), vadModeCombo);
thresholdSlider = new JSlider((int) InputLevel.MIN_DB, (int) InputLevel.MAX_DB,
(int) Math.round(settings.vadThresholdDb));
thresholdLabel = new JLabel(Math.round(settings.vadThresholdDb) + " dB");
thresholdSlider.addChangeListener(e -> {
meter.setThreshold(thresholdSlider.getValue());
thresholdLabel.setText(thresholdSlider.getValue() + " dB");
applyLive(m -> m.setThresholdDb(thresholdSlider.getValue()));
});
addRow(p, c, row++, new JLabel("Volume gate:"), sliderWithLabel(thresholdSlider, thresholdLabel));
speechSlider = new JSlider(0, 100, (int) Math.round(settings.speechThreshold * 100));
speechLabel = new JLabel(Math.round(settings.speechThreshold * 100) + "%");
speechSlider.addChangeListener(e -> {
speechLabel.setText(speechSlider.getValue() + "%");
applyLive(m -> m.setSpeechThreshold(speechSlider.getValue() / 100.0));
});
addRow(p, c, row++, new JLabel("Speech threshold:"), sliderWithLabel(speechSlider, speechLabel));
pttKeyButton = new JButton(pushToTalkHotkeyText());
pttKeyButton.setToolTipText("Push-to-talk is a global hotkey; this opens its binding");
pttKeyButton.addActionListener(e -> editPushToTalkHotkey());
addRow(p, c, row++, new JLabel("Push-to-talk hotkey:"), pttKeyButton);
vadOverPttCheck = new JCheckBox("Also detect voice while push-to-talk", settings.vadOverPtt);
vadOverPttCheck.addActionListener(e ->
micTest.configure(m -> m.setVadOverPtt(vadOverPttCheck.isSelected())));
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
p.add(vadOverPttCheck, c);
c.gridwidth = 1;
int kbits = Math.max(MIN_BITRATE_KBITS, Math.min(MAX_BITRATE_KBITS, settings.bitrate / 1000));
bitrateSlider = new JSlider(MIN_BITRATE_KBITS, MAX_BITRATE_KBITS, kbits);
bitrateLabel = new JLabel(kbits + " kbit/s");
bitrateSlider.addChangeListener(e -> {
bitrateLabel.setText(bitrateSlider.getValue() + " kbit/s");
pushOpusLive();
});
addRow(p, c, row++, new JLabel("Opus bitrate:"),
sliderWithLabel(bitrateSlider, bitrateLabel, 70));
complexitySlider = new JSlider(0, 10, settings.complexity);
limitWidth(complexitySlider, SLIDER_WIDTH);
complexitySlider.addChangeListener(e -> pushOpusLive());
addRow(p, c, row++, new JLabel("Opus complexity:"), complexitySlider);
vbrCheck = new JCheckBox("Variable bitrate (VBR)", settings.vbr);
fecCheck = new JCheckBox("Forward error correction (FEC)", settings.fec);
musicCheck = new JCheckBox("Music codec (stereo, higher fidelity)", settings.music);
musicCheck.setToolTipText("<html>Transmits <b>OPUS_MUSIC</b>: stereo when the capture "
+ "device has two channels, and without the voice pre-processing "
+ "(noise removal, typing attenuation, AGC).<br>"
+ "Voice mode (<b>OPUS_VOICE</b>) is mono, as in the official client.</html>");
vbrCheck.addActionListener(e -> pushOpusLive());
fecCheck.addActionListener(e -> pushOpusLive());
musicCheck.addActionListener(e -> pushOpusLive());
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
p.add(vbrCheck, c);
c.gridy = row++;
p.add(fecCheck, c);
c.gridy = row++;
p.add(musicCheck, c);
c.gridwidth = 1;
Runnable syncEnabled = () -> {
boolean vad = vadRadio.isSelected();
boolean ptt = pttRadio.isSelected();
boolean vadContext = vad || (ptt && vadOverPttCheck.isSelected());
Settings.VadMode vm = currentVadMode();
boolean usesGate = vm != Settings.VadMode.AUTOMATIC;
boolean usesSpeech = vm != Settings.VadMode.VOLUME_GATE;
vadModeCombo.setEnabled(vadContext);
thresholdSlider.setEnabled(vadContext && usesGate);
speechSlider.setEnabled(vadContext && usesSpeech);
pttKeyButton.setEnabled(ptt);
vadOverPttCheck.setEnabled(ptt);
meter.setShowThreshold(vadContext && usesGate);
if (liveMic != null) {
liveMic.setMode(ptt ? Settings.InputMode.PUSH_TO_TALK
: vad ? Settings.InputMode.VOICE_ACTIVATION
: Settings.InputMode.CONTINUOUS);
liveMic.setVadMode(vm);
liveMic.setVadOverPtt(vadOverPttCheck.isSelected());
}
};
vadRadio.addActionListener(e -> syncEnabled.run());
pttRadio.addActionListener(e -> syncEnabled.run());
contRadio.addActionListener(e -> syncEnabled.run());
vadModeCombo.addActionListener(e -> syncEnabled.run());
vadOverPttCheck.addActionListener(e -> syncEnabled.run());
syncEnabled.run();
c.gridx = 0;
c.gridy = row;
c.weighty = 1;
p.add(Box.createGlue(), c);
return p;
}
private static int vadModeIndex(Settings.VadMode m) {
switch (m) {
case AUTOMATIC:
return 0;
case VOLUME_GATE:
return 1;
default:
return 2;
}
}
private Settings.VadMode currentVadMode() {
switch (vadModeCombo.getSelectedIndex()) {
case 0:
return Settings.VadMode.AUTOMATIC;
case 1:
return Settings.VadMode.VOLUME_GATE;
default:
return Settings.VadMode.HYBRID;
}
}
/**
* The form panel used by both tabs. It follows the scroll pane's width instead of
* demanding its own preferred one, so rows stay inside the dialog.
*/
private static JPanel formPanel() {
JPanel p = new FormPanel();
p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
return p;
}
private static final class FormPanel extends JPanel implements Scrollable {
FormPanel() {
super(new GridBagLayout());
}
@Override
public Dimension getPreferredScrollableViewportSize() {
return getPreferredSize();
}
@Override
public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) {
return 16;
}
@Override
public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) {
return visible.height;
}
@Override
public boolean getScrollableTracksViewportWidth() {
return true;
}
@Override
public boolean getScrollableTracksViewportHeight() {
return false;
}
}
private static javax.swing.JScrollPane scrollable(JPanel content) { private static javax.swing.JScrollPane scrollable(JPanel content) {
javax.swing.JScrollPane sp = new javax.swing.JScrollPane(content, javax.swing.JScrollPane sp = new javax.swing.JScrollPane(content,
javax.swing.ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, javax.swing.ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
@@ -479,103 +103,15 @@ public final class SettingsDialog extends JDialog {
return sp; return sp;
} }
private static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel) { /** Copies the audio tabs into {@code target}, without touching anything else. */
return sliderWithLabel(slider, valueLabel, 48);
}
/**
* Pairs a slider with its value readout. The readout gets a fixed width wide enough for
* the longest value so the slider does not jump around as it is dragged.
*/
private static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel, int labelWidth) {
JPanel panel = new JPanel(new BorderLayout(6, 0));
limitWidth(slider, SLIDER_WIDTH);
panel.add(slider, BorderLayout.CENTER);
valueLabel.setPreferredSize(new Dimension(labelWidth, valueLabel.getPreferredSize().height));
panel.add(valueLabel, BorderLayout.EAST);
return panel;
}
/**
* Caps a field's preferred width and lets it shrink: long device names and wide sliders
* would otherwise force the form past the dialog's edge, where the scroll pane (which
* never scrolls horizontally) simply clips them.
*/
private static void limitWidth(JComponent comp, int preferredWidth) {
int height = comp.getPreferredSize().height;
comp.setPreferredSize(new Dimension(preferredWidth, height));
comp.setMinimumSize(new Dimension(MIN_FIELD_WIDTH, height));
}
private OpusParameters currentOpusParameters() {
return new OpusParameters(
bitrateSlider.getValue() * 1000,
complexitySlider.getValue(),
vbrCheck.isSelected(),
fecCheck.isSelected(),
settings.packetLoss,
musicCheck.isSelected());
}
/** Applies the current Opus controls to the running encoder immediately. */
private void pushOpusLive() {
if (liveMic != null) liveMic.setOpusParameters(currentOpusParameters());
}
private String pushToTalkHotkeyText() {
Hotkey ptt = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
return ptt == null ? "No hotkey assigned" : hotkeys.display(ptt.combo);
}
/**
* Push-to-talk is an ordinary hotkey, so this shortcut edits that binding — adding
* it when there is none — rather than keeping a key of its own.
*/
private void editPushToTalkHotkey() {
Hotkey existing = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
HotkeyDialog dlg = new HotkeyDialog(this, hotkeys,
existing == null ? new Hotkey(HotkeyAction.PTT_ACTIVATE, null) : existing);
dlg.setVisible(true);
if (!dlg.isConfirmed()) return;
List<Hotkey> updated = new java.util.ArrayList<>();
synchronized (hotkeys.all()) {
for (Hotkey h : hotkeys.all()) {
if (h != existing) updated.add(h.copy());
}
}
updated.add(dlg.result());
hotkeys.replaceAll(updated);
pttKeyButton.setText(pushToTalkHotkeyText());
hotkeysPanel.reload();
}
/** Copies the audio form into {@code target}, without touching anything else. */
private void writeAudioSettings(Settings target) { private void writeAudioSettings(Settings target) {
target.inputDevice = comboValue(inputCombo); devicesPanel.writeInto(target);
target.outputDevice = comboValue(outputCombo); voiceActivationPanel.writeInto(target);
target.inputVolume = inputGain.getValue() / 100.0;
target.outputVolume = outputVol.getValue() / 100.0;
target.denoise = denoiseCheck.isSelected();
target.denoiserLevel = denoiseLevel.getValue() / 100.0;
target.typingAttenuation = typingCheck.isSelected();
target.agc = agcCheck.isSelected();
target.inputMode = pttRadio.isSelected() ? Settings.InputMode.PUSH_TO_TALK
: contRadio.isSelected() ? Settings.InputMode.CONTINUOUS
: Settings.InputMode.VOICE_ACTIVATION;
target.vadMode = currentVadMode();
target.vadThresholdDb = thresholdSlider.getValue();
target.speechThreshold = speechSlider.getValue() / 100.0;
target.vadOverPtt = vadOverPttCheck.isSelected();
target.bitrate = bitrateSlider.getValue() * 1000;
target.complexity = complexitySlider.getValue();
target.vbr = vbrCheck.isSelected();
target.fec = fecCheck.isSelected();
target.music = musicCheck.isSelected();
} }
/** /**
* The audio form as a standalone {@link Settings}, so the test chain runs with the * The audio tabs as a standalone {@link Settings}, so the microphone test chain runs
* values currently on screen rather than the ones last saved. * with the values currently on screen rather than the ones last saved.
* *
* <p>The test always runs voice activation: it exists to tune the gate, and push-to-talk * <p>The test always runs voice activation: it exists to tune the gate, and push-to-talk
* would need the global hotkey, which belongs to the connected microphone. * would need the global hotkey, which belongs to the connected microphone.
@@ -625,129 +161,27 @@ public final class SettingsDialog extends JDialog {
} }
private void close() { private void close() {
micTest.stop(); voiceActivationPanel.stopTest();
dispose(); dispose();
} }
// ---- microphone test ----
/** /**
* The test row: a toggle that runs the capture chain, an indicator showing whether the * Applies a live change to the connected microphone and to the microphone test alike.
* gate is open, and an optional loopback so you can hear what is being sent. *
* <p>Guarded against a null {@code voiceActivationPanel}: the Devices tab applies its
* controls once as they are built, which happens before that tab exists.
*/ */
private JPanel buildTestControls() { private void applyLive(Consumer<VoiceInput> change) {
JPanel row = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.LEFT, 8, 0));
testButton = new JToggleButton("Begin Test");
testButton.setToolTipText("Run the capture chain exactly as it runs while connected, "
+ "so the bar and the indicator show what would actually be transmitted.");
testButton.addActionListener(e -> setTesting(testButton.isSelected()));
loopbackCheck = new JCheckBox("Hear myself");
loopbackCheck.setToolTipText("Play the transmitted audio back through the playback "
+ "device. Use headphones to avoid feedback.");
loopbackCheck.setEnabled(false);
loopbackCheck.addActionListener(e -> micTest.setLoopback(loopbackCheck.isSelected()));
talkIndicator = new JLabel("Not transmitting", Icons.clientIdle(), JLabel.LEFT);
talkIndicator.setToolTipText("Lights up while your microphone is open, exactly as "
+ "other users would see you in the channel list.");
row.add(testButton);
row.add(loopbackCheck);
row.add(talkIndicator);
return row;
}
private void setTesting(boolean on) {
if (on && !micTest.start(audioSnapshot())) {
testButton.setSelected(false);
testButton.setText("Begin Test");
loopbackCheck.setEnabled(false);
resetTestIndicators();
talkIndicator.setText("Capture device unavailable");
return;
}
if (!on) {
micTest.stop();
loopbackCheck.setSelected(false);
resetTestIndicators();
}
testButton.setText(on ? "Stop Test" : "Begin Test");
loopbackCheck.setEnabled(on);
}
private void resetTestIndicators() {
onTestTalking(false);
onTestLevel(InputLevel.SILENCE_DB);
}
/** Restarts the test chain, if running, so a device change takes effect. */
private void restartTest() {
if (micTest.isRunning()) {
boolean loopback = loopbackCheck.isSelected();
if (micTest.start(audioSnapshot())) {
micTest.setLoopback(loopback);
} else {
setTesting(false);
testButton.setSelected(false);
}
}
}
/** Applies a live change to the connected microphone and to the test one alike. */
private void applyLive(java.util.function.Consumer<VoiceInput> change) {
if (liveMic != null) change.accept(liveMic); if (liveMic != null) change.accept(liveMic);
micTest.configure(change); if (voiceActivationPanel != null) voiceActivationPanel.configureTest(change);
} }
private void onTestLevel(double db) { /** Restarts the microphone test, if running, so a device change takes effect. */
if (meter != null) meter.setLevel(db); private void restartTest() {
if (voiceActivationPanel != null) voiceActivationPanel.restartTest();
} }
private void onTestTalking(boolean talking) { private void setTestOutputDevice(String deviceId) {
if (meter != null) meter.setTransmitting(talking); if (voiceActivationPanel != null) voiceActivationPanel.setTestOutputDevice(deviceId);
if (talkIndicator != null) {
talkIndicator.setIcon(talking ? Icons.clientTalking() : Icons.clientIdle());
talkIndicator.setText(talking ? "Transmitting" : "Not transmitting");
}
}
// ---- small helpers ----
private static GridBagConstraints gbc() {
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(4, 4, 4, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
return c;
}
private static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, java.awt.Component field) {
c.gridx = 0;
c.gridy = row;
c.weightx = 0;
c.gridwidth = 1;
p.add(label, c);
c.gridx = 1;
c.weightx = 1;
p.add(field, c);
}
private static void selectOrDefault(JComboBox<AudioDevices.Device> combo, String deviceId) {
if (deviceId != null && !deviceId.isEmpty()) {
for (int i = 0; i < combo.getItemCount(); i++) {
if (deviceId.equals(combo.getItemAt(i).id())) {
combo.setSelectedIndex(i);
return;
}
}
}
combo.setSelectedIndex(0);
}
private static String comboValue(JComboBox<AudioDevices.Device> combo) {
AudioDevices.Device d = (AudioDevices.Device) combo.getSelectedItem();
return d == null ? "" : d.id();
} }
} }

View File

@@ -0,0 +1,36 @@
package com.ts3client.ui;
import javax.swing.BorderFactory;
import javax.swing.JLabel;
import javax.swing.JPanel;
import java.awt.BorderLayout;
/** The strip at the bottom of the window: connection status on the left, codec info on the right. */
final class StatusBar extends JPanel {
private final JLabel statusLabel = new JLabel("Not connected");
private final JLabel codecLabel = new JLabel();
StatusBar() {
super(new BorderLayout());
setBackground(Theme.STATUS_BG);
setBorder(BorderFactory.createEmptyBorder(3, 8, 3, 8));
statusLabel.setFont(Theme.UI_FONT);
codecLabel.setFont(Theme.UI_FONT);
codecLabel.setForeground(Theme.CHAT_SYSTEM);
add(statusLabel, BorderLayout.WEST);
add(codecLabel, BorderLayout.EAST);
}
void setStatus(String text) {
statusLabel.setText(text);
}
void setCodec(String text) {
codecLabel.setText(text);
}
String codecText() {
return codecLabel.getText();
}
}

View File

@@ -0,0 +1,279 @@
package com.ts3client.ui;
import javax.swing.JComponent;
import javax.swing.JRootPane;
import javax.swing.JTabbedPane;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import java.awt.AlphaComposite;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.List;
/**
* Lets the user reorder a {@link JTabbedPane}'s tabs by dragging one sideways over
* another, browser-tab style. Snapshots of every tab are drawn on the window's
* glass pane: the dragged one follows the cursor's X only (its Y stays put, and it
* can't leave the strip) and stays drawn on top; it swaps with whichever neighbour
* it reaches the midpoint of. The real tab model is only updated once, when the
* mouse is released, and the overlay eases into its final position on top of it.
*
* <p>Swing delivers drag events only to whichever component originally got the
* press, so every component that should start a drag &mdash; the tab strip itself
* and each custom tab label &mdash; must be {@link #attach}ed individually.
*/
final class TabDragReorder {
/** Moves the tab at {@code from} to sit where {@code to} currently is. */
interface Reorder {
void moveTab(int from, int to);
}
/** Below this many pixels of movement, a press is treated as a click, not a drag. */
private static final int THRESHOLD = 5;
private static final double EASE = 0.35;
private static final double SETTLE_EPSILON = 0.5;
private static final int FRAME_MS = 15;
private final JTabbedPane tabbed;
private final Reorder reorder;
private int pressSlot = -1;
private Point pressPoint;
private boolean dragging;
private boolean releasing;
private Overlay overlay;
private List<Tile> order;
private Tile draggedTile;
private int dragSlot;
private int startSlot;
private int stripStartX;
private int stripWidth;
private int grabDx;
private Timer timer;
TabDragReorder(JTabbedPane tabbed, Reorder reorder) {
this.tabbed = tabbed;
this.reorder = reorder;
}
void attach(JComponent source) {
MouseAdapter listener = new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
Point inTabbed = SwingUtilities.convertPoint(e.getComponent(), e.getPoint(), tabbed);
pressSlot = tabbed.indexAtLocation(inTabbed.x, inTabbed.y);
pressPoint = inTabbed;
dragging = false;
}
@Override
public void mouseDragged(MouseEvent e) {
if (pressSlot < 0) return;
Point inTabbed = SwingUtilities.convertPoint(e.getComponent(), e.getPoint(), tabbed);
if (!dragging) {
if (pressPoint.distance(inTabbed) < THRESHOLD) return;
dragging = beginDrag(inTabbed);
if (!dragging) return;
}
dragTo(inTabbed.x);
}
@Override
public void mouseReleased(MouseEvent e) {
if (dragging) endDrag();
pressSlot = -1;
dragging = false;
}
};
source.addMouseListener(listener);
source.addMouseMotionListener(listener);
}
/** Snapshots every tab and shows them on the glass pane in place of the real strip. */
private boolean beginDrag(Point inTabbed) {
JRootPane root = tabbed.getRootPane();
int n = tabbed.getTabCount();
if (root == null || pressSlot < 0 || pressSlot >= n) return false;
order = new ArrayList<>(n);
for (int i = 0; i < n; i++) {
Rectangle b = tabbed.getBoundsAt(i);
if (b == null || b.isEmpty()) return false;
order.add(new Tile(snapshot(b), b.x, b.y, b.width, b.height));
}
stripStartX = order.get(0).x;
stripWidth = 0;
for (Tile t : order) stripWidth += t.width;
draggedTile = order.get(pressSlot);
dragSlot = pressSlot;
startSlot = pressSlot;
grabDx = inTabbed.x - draggedTile.x;
releasing = false;
if (!(root.getGlassPane() instanceof Overlay)) {
root.setGlassPane(new Overlay());
}
overlay = (Overlay) root.getGlassPane();
overlay.origin = SwingUtilities.convertPoint(tabbed, new Point(0, 0), overlay);
overlay.tiles = order;
overlay.onTop = draggedTile;
overlay.setVisible(true);
overlay.repaint();
timer = new Timer(FRAME_MS, e -> tick());
timer.start();
return true;
}
private BufferedImage snapshot(Rectangle bounds) {
BufferedImage img = new BufferedImage(bounds.width, bounds.height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics();
g.setClip(0, 0, bounds.width, bounds.height);
g.translate(-bounds.x, -bounds.y);
tabbed.paint(g);
g.dispose();
return img;
}
/**
* Swaps the dragged tile with whichever neighbour it has reached the midpoint
* of: its trailing edge past the next tile's midpoint when moving right, or its
* leading edge past the previous tile's midpoint when moving left. Checked
* against the neighbour's target slot rather than its live position, since that
* may itself still be mid-animation.
*/
private void dragTo(int cursorX) {
int min = stripStartX;
int max = stripStartX + stripWidth - draggedTile.width;
draggedTile.currentX = Math.max(min, Math.min(max, cursorX - grabDx));
if (dragSlot + 1 < order.size()) {
Tile next = order.get(dragSlot + 1);
double rightEdge = draggedTile.currentX + draggedTile.width;
if (rightEdge > next.targetX + next.width / 2.0) {
swap(dragSlot, dragSlot + 1);
return;
}
}
if (dragSlot - 1 >= 0) {
Tile prev = order.get(dragSlot - 1);
double leftEdge = draggedTile.currentX;
if (leftEdge < prev.targetX + prev.width / 2.0) {
swap(dragSlot - 1, dragSlot);
}
}
}
/** Swaps the two adjacent slots {@code a} and {@code b} (one of them the dragged tile's) in {@link #order}, not the real tab model. */
private void swap(int a, int b) {
int newSlot = dragSlot == a ? b : a;
order.remove(draggedTile);
order.add(newSlot, draggedTile);
dragSlot = newSlot;
retarget(false);
}
/** Assigns each tile's slot target from the current {@code order}; the dragged tile follows the cursor instead, unless {@code includeDragged}. */
private void retarget(boolean includeDragged) {
double x = stripStartX;
for (Tile t : order) {
if (t != draggedTile || includeDragged) t.targetX = x;
x += t.width;
}
}
/**
* Commits the reorder to the real tab model in one shot. Doing this once here,
* rather than per swap while dragging, matters because reordering the real
* model can rebuild tab components (new labels, new listeners), which would
* otherwise yank the component out from under an in-flight mouse grab.
*/
private void endDrag() {
releasing = true;
if (dragSlot != startSlot) reorder.moveTab(startSlot, dragSlot);
retarget(true);
}
private void tick() {
boolean settled = true;
for (Tile t : order) {
if (t == draggedTile && !releasing) continue;
double diff = t.targetX - t.currentX;
if (Math.abs(diff) < SETTLE_EPSILON) {
t.currentX = t.targetX;
} else {
t.currentX += diff * EASE;
settled = false;
}
}
overlay.repaint();
if (releasing && settled) {
timer.stop();
overlay.tiles = null;
overlay.onTop = null;
overlay.setVisible(false);
order = null;
draggedTile = null;
}
}
/** One tab's snapshot, animating from {@link #currentX} toward {@link #targetX}. */
private static final class Tile {
final BufferedImage image;
final int x, y, width, height;
double currentX;
double targetX;
Tile(BufferedImage image, int x, int y, int width, int height) {
this.image = image;
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.currentX = x;
this.targetX = x;
}
}
/** A transparent overlay on the glass pane that paints every tab's animated snapshot. */
private static final class Overlay extends JComponent {
List<Tile> tiles;
/** Painted last (on top) so it stays above tabs it's currently overlapping while dragged. */
Tile onTop;
Point origin = new Point();
Overlay() {
setOpaque(false);
}
/** Never claims mouse events, so drags keep reaching the component that was actually pressed. */
@Override
public boolean contains(int x, int y) {
return false;
}
@Override
protected void paintComponent(Graphics g) {
if (tiles == null) return;
Graphics2D g2 = (Graphics2D) g.create();
g2.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.9f));
for (Tile t : tiles) {
if (t != onTop) draw(g2, t);
}
if (onTop != null) draw(g2, onTop);
g2.dispose();
}
private void draw(Graphics2D g2, Tile t) {
g2.drawImage(t.image, origin.x + (int) Math.round(t.currentX), origin.y + t.y, null);
}
}
}

View File

@@ -0,0 +1,406 @@
package com.ts3client.ui;
import com.ts3client.audio.InputLevel;
import com.ts3client.audio.OpusParameters;
import com.ts3client.audio.VoiceInput;
import com.ts3client.config.Settings;
import com.ts3client.hotkey.Hotkey;
import com.ts3client.hotkey.HotkeyAction;
import javax.swing.Box;
import javax.swing.ButtonGroup;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSlider;
import javax.swing.JToggleButton;
import java.awt.GridBagConstraints;
import java.awt.Insets;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Supplier;
/**
* Options dialog's "Voice Activation" tab: input mode (VAD / push-to-talk / continuous),
* the detection tuning with a live input meter, and the Opus encoder controls.
*
* <p>Owns the microphone test (start/stop button, loopback, indicator): it runs a real
* capture chain built from the form's current values, plus whatever the "Playback / Capture"
* tab currently has on screen, via {@code audioSnapshot}.
*/
final class VoiceActivationPanel extends FormPanel {
private static final int MIN_BITRATE_KBITS = 8;
private static final int MAX_BITRATE_KBITS = 160;
private final Settings settings;
private final VoiceInput liveMic;
private final HotkeyService hotkeys;
private final HotkeysPanel hotkeysPanel;
private final Supplier<Settings> audioSnapshot;
private final MicrophoneTest micTest = new MicrophoneTest(this::onTestLevel, this::onTestTalking);
private final JRadioButton vadRadio;
private final JRadioButton pttRadio;
private final JRadioButton contRadio;
private final JComboBox<String> vadModeCombo;
private final JSlider thresholdSlider;
private final JSlider speechSlider;
private final JCheckBox vadOverPttCheck;
private final LevelMeter meter;
private JToggleButton testButton;
private JCheckBox loopbackCheck;
private JLabel talkIndicator;
private final JButton pttKeyButton;
private final JSlider bitrateSlider;
private final JSlider complexitySlider;
private final JCheckBox vbrCheck;
private final JCheckBox fecCheck;
private final JCheckBox musicCheck;
VoiceActivationPanel(Settings settings, VoiceInput liveMic, HotkeyService hotkeys,
HotkeysPanel hotkeysPanel, Supplier<Settings> audioSnapshot) {
this.settings = settings;
this.liveMic = liveMic;
this.hotkeys = hotkeys;
this.hotkeysPanel = hotkeysPanel;
this.audioSnapshot = audioSnapshot;
GridBagConstraints c = gbc();
vadRadio = new JRadioButton("Voice Activation Detection");
pttRadio = new JRadioButton("Push-To-Talk");
contRadio = new JRadioButton("Continuous");
ButtonGroup group = new ButtonGroup();
group.add(vadRadio);
group.add(pttRadio);
group.add(contRadio);
switch (settings.inputMode) {
case PUSH_TO_TALK:
pttRadio.setSelected(true);
break;
case CONTINUOUS:
contRadio.setSelected(true);
break;
default:
vadRadio.setSelected(true);
}
int row = 0;
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
add(vadRadio, c);
c.gridy = row++;
add(pttRadio, c);
c.gridy = row++;
add(contRadio, c);
c.gridwidth = 1;
meter = new LevelMeter();
meter.setThreshold(settings.vadThresholdDb);
c.gridx = 0;
c.gridy = row;
c.gridwidth = 2;
c.insets = new Insets(10, 4, 2, 4);
add(new JLabel("Input level:"), c);
c.gridy = ++row;
add(meter, c);
c.insets = new Insets(4, 4, 4, 4);
c.gridy = ++row;
add(buildTestControls(), c);
c.gridwidth = 1;
row++;
vadModeCombo = new JComboBox<>(new String[]{"Automatic", "Volume Gate", "Hybrid"});
vadModeCombo.setSelectedIndex(vadModeIndex(settings.vadMode));
vadModeCombo.addActionListener(e -> micTest.configure(m -> m.setVadMode(currentVadMode())));
vadModeCombo.setToolTipText("<html><b>Automatic</b>: intelligent speech detection.<br>"
+ "<b>Volume Gate</b>: transmit when loud enough.<br>"
+ "<b>Hybrid</b>: loud enough <i>and</i> detected as speech.</html>");
addRow(this, c, row++, new JLabel("Detection:"), vadModeCombo);
thresholdSlider = new JSlider((int) InputLevel.MIN_DB, (int) InputLevel.MAX_DB,
(int) Math.round(settings.vadThresholdDb));
JLabel thresholdLabel = new JLabel(Math.round(settings.vadThresholdDb) + " dB");
thresholdSlider.addChangeListener(e -> {
meter.setThreshold(thresholdSlider.getValue());
thresholdLabel.setText(thresholdSlider.getValue() + " dB");
applyLive(m -> m.setThresholdDb(thresholdSlider.getValue()));
});
addRow(this, c, row++, new JLabel("Volume gate:"), sliderWithLabel(thresholdSlider, thresholdLabel));
speechSlider = new JSlider(0, 100, (int) Math.round(settings.speechThreshold * 100));
JLabel speechLabel = new JLabel(Math.round(settings.speechThreshold * 100) + "%");
speechSlider.addChangeListener(e -> {
speechLabel.setText(speechSlider.getValue() + "%");
applyLive(m -> m.setSpeechThreshold(speechSlider.getValue() / 100.0));
});
addRow(this, c, row++, new JLabel("Speech threshold:"), sliderWithLabel(speechSlider, speechLabel));
pttKeyButton = new JButton(pushToTalkHotkeyText());
pttKeyButton.setToolTipText("Push-to-talk is a global hotkey; this opens its binding");
pttKeyButton.addActionListener(e -> editPushToTalkHotkey());
addRow(this, c, row++, new JLabel("Push-to-talk hotkey:"), pttKeyButton);
vadOverPttCheck = new JCheckBox("Also detect voice while push-to-talk", settings.vadOverPtt);
vadOverPttCheck.addActionListener(e ->
micTest.configure(m -> m.setVadOverPtt(vadOverPttCheck.isSelected())));
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
add(vadOverPttCheck, c);
c.gridwidth = 1;
int kbits = Math.max(MIN_BITRATE_KBITS, Math.min(MAX_BITRATE_KBITS, settings.bitrate / 1000));
bitrateSlider = new JSlider(MIN_BITRATE_KBITS, MAX_BITRATE_KBITS, kbits);
JLabel bitrateLabel = new JLabel(kbits + " kbit/s");
bitrateSlider.addChangeListener(e -> {
bitrateLabel.setText(bitrateSlider.getValue() + " kbit/s");
pushOpusLive();
});
addRow(this, c, row++, new JLabel("Opus bitrate:"),
sliderWithLabel(bitrateSlider, bitrateLabel, 70));
complexitySlider = new JSlider(0, 10, settings.complexity);
limitWidth(complexitySlider, SLIDER_WIDTH);
complexitySlider.addChangeListener(e -> pushOpusLive());
addRow(this, c, row++, new JLabel("Opus complexity:"), complexitySlider);
vbrCheck = new JCheckBox("Variable bitrate (VBR)", settings.vbr);
fecCheck = new JCheckBox("Forward error correction (FEC)", settings.fec);
musicCheck = new JCheckBox("Music codec (stereo, higher fidelity)", settings.music);
musicCheck.setToolTipText("<html>Transmits <b>OPUS_MUSIC</b>: stereo when the capture "
+ "device has two channels, and without the voice pre-processing "
+ "(noise removal, typing attenuation, AGC).<br>"
+ "Voice mode (<b>OPUS_VOICE</b>) is mono, as in the official client.</html>");
vbrCheck.addActionListener(e -> pushOpusLive());
fecCheck.addActionListener(e -> pushOpusLive());
musicCheck.addActionListener(e -> pushOpusLive());
c.gridx = 0;
c.gridy = row++;
c.gridwidth = 2;
add(vbrCheck, c);
c.gridy = row++;
add(fecCheck, c);
c.gridy = row++;
add(musicCheck, c);
c.gridwidth = 1;
Runnable syncEnabled = () -> {
boolean vad = vadRadio.isSelected();
boolean ptt = pttRadio.isSelected();
boolean vadContext = vad || (ptt && vadOverPttCheck.isSelected());
Settings.VadMode vm = currentVadMode();
boolean usesGate = vm != Settings.VadMode.AUTOMATIC;
boolean usesSpeech = vm != Settings.VadMode.VOLUME_GATE;
vadModeCombo.setEnabled(vadContext);
thresholdSlider.setEnabled(vadContext && usesGate);
speechSlider.setEnabled(vadContext && usesSpeech);
pttKeyButton.setEnabled(ptt);
vadOverPttCheck.setEnabled(ptt);
meter.setShowThreshold(vadContext && usesGate);
if (liveMic != null) {
liveMic.setMode(ptt ? Settings.InputMode.PUSH_TO_TALK
: vad ? Settings.InputMode.VOICE_ACTIVATION
: Settings.InputMode.CONTINUOUS);
liveMic.setVadMode(vm);
liveMic.setVadOverPtt(vadOverPttCheck.isSelected());
}
};
vadRadio.addActionListener(e -> syncEnabled.run());
pttRadio.addActionListener(e -> syncEnabled.run());
contRadio.addActionListener(e -> syncEnabled.run());
vadModeCombo.addActionListener(e -> syncEnabled.run());
vadOverPttCheck.addActionListener(e -> syncEnabled.run());
syncEnabled.run();
c.gridx = 0;
c.gridy = row;
c.weighty = 1;
add(Box.createGlue(), c);
}
/** Copies the form into {@code target}, without touching anything else. */
void writeInto(Settings target) {
target.inputMode = pttRadio.isSelected() ? Settings.InputMode.PUSH_TO_TALK
: contRadio.isSelected() ? Settings.InputMode.CONTINUOUS
: Settings.InputMode.VOICE_ACTIVATION;
target.vadMode = currentVadMode();
target.vadThresholdDb = thresholdSlider.getValue();
target.speechThreshold = speechSlider.getValue() / 100.0;
target.vadOverPtt = vadOverPttCheck.isSelected();
target.bitrate = bitrateSlider.getValue() * 1000;
target.complexity = complexitySlider.getValue();
target.vbr = vbrCheck.isSelected();
target.fec = fecCheck.isSelected();
target.music = musicCheck.isSelected();
}
/** Applies a change to the microphone test, if it is running; used by the Devices tab too. */
void configureTest(Consumer<VoiceInput> change) {
micTest.configure(change);
}
/** The playback device to loop the test through; used by the Devices tab's output picker. */
void setTestOutputDevice(String deviceId) {
micTest.setOutputDevice(deviceId);
}
/** Restarts the test chain, if running, so a device change on the Devices tab takes effect. */
void restartTest() {
if (micTest.isRunning()) {
boolean loopback = loopbackCheck.isSelected();
if (micTest.start(audioSnapshot.get())) {
micTest.setLoopback(loopback);
} else {
setTesting(false);
testButton.setSelected(false);
}
}
}
void stopTest() {
micTest.stop();
}
private static int vadModeIndex(Settings.VadMode m) {
switch (m) {
case AUTOMATIC:
return 0;
case VOLUME_GATE:
return 1;
default:
return 2;
}
}
private Settings.VadMode currentVadMode() {
switch (vadModeCombo.getSelectedIndex()) {
case 0:
return Settings.VadMode.AUTOMATIC;
case 1:
return Settings.VadMode.VOLUME_GATE;
default:
return Settings.VadMode.HYBRID;
}
}
private OpusParameters currentOpusParameters() {
return new OpusParameters(
bitrateSlider.getValue() * 1000,
complexitySlider.getValue(),
vbrCheck.isSelected(),
fecCheck.isSelected(),
settings.packetLoss,
musicCheck.isSelected());
}
/** Applies the current Opus controls to the running encoder immediately. */
private void pushOpusLive() {
if (liveMic != null) liveMic.setOpusParameters(currentOpusParameters());
}
private String pushToTalkHotkeyText() {
Hotkey ptt = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
return ptt == null ? "No hotkey assigned" : hotkeys.display(ptt.combo);
}
/**
* Push-to-talk is an ordinary hotkey, so this shortcut edits that binding — adding
* it when there is none — rather than keeping a key of its own.
*/
private void editPushToTalkHotkey() {
Hotkey existing = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
HotkeyDialog dlg = new HotkeyDialog(javax.swing.SwingUtilities.getWindowAncestor(this),
hotkeys, existing == null ? new Hotkey(HotkeyAction.PTT_ACTIVATE, null) : existing);
dlg.setVisible(true);
if (!dlg.isConfirmed()) return;
List<Hotkey> updated = new java.util.ArrayList<>();
synchronized (hotkeys.all()) {
for (Hotkey h : hotkeys.all()) {
if (h != existing) updated.add(h.copy());
}
}
updated.add(dlg.result());
hotkeys.replaceAll(updated);
pttKeyButton.setText(pushToTalkHotkeyText());
hotkeysPanel.reload();
}
/** Applies a live change to the connected microphone and to the test one alike. */
private void applyLive(Consumer<VoiceInput> change) {
if (liveMic != null) change.accept(liveMic);
micTest.configure(change);
}
// ---- microphone test ----
/**
* The test row: a toggle that runs the capture chain, an indicator showing whether the
* gate is open, and an optional loopback so you can hear what is being sent.
*/
private JPanel buildTestControls() {
JPanel row = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.LEFT, 8, 0));
testButton = new JToggleButton("Begin Test");
testButton.setToolTipText("Run the capture chain exactly as it runs while connected, "
+ "so the bar and the indicator show what would actually be transmitted.");
testButton.addActionListener(e -> setTesting(testButton.isSelected()));
loopbackCheck = new JCheckBox("Hear myself");
loopbackCheck.setToolTipText("Play the transmitted audio back through the playback "
+ "device. Use headphones to avoid feedback.");
loopbackCheck.setEnabled(false);
loopbackCheck.addActionListener(e -> micTest.setLoopback(loopbackCheck.isSelected()));
talkIndicator = new JLabel("Not transmitting", Icons.clientIdle(), JLabel.LEFT);
talkIndicator.setToolTipText("Lights up while your microphone is open, exactly as "
+ "other users would see you in the channel list.");
row.add(testButton);
row.add(loopbackCheck);
row.add(talkIndicator);
return row;
}
private void setTesting(boolean on) {
if (on && !micTest.start(audioSnapshot.get())) {
testButton.setSelected(false);
testButton.setText("Begin Test");
loopbackCheck.setEnabled(false);
resetTestIndicators();
talkIndicator.setText("Capture device unavailable");
return;
}
if (!on) {
micTest.stop();
loopbackCheck.setSelected(false);
resetTestIndicators();
}
testButton.setText(on ? "Stop Test" : "Begin Test");
loopbackCheck.setEnabled(on);
}
private void resetTestIndicators() {
onTestTalking(false);
onTestLevel(InputLevel.SILENCE_DB);
}
private void onTestLevel(double db) {
if (meter != null) meter.setLevel(db);
}
private void onTestTalking(boolean talking) {
if (meter != null) meter.setTransmitting(talking);
if (talkIndicator != null) {
talkIndicator.setIcon(talking ? Icons.clientTalking() : Icons.clientIdle());
talkIndicator.setText(talking ? "Transmitting" : "Not transmitting");
}
}
}