Compare commits

...

26 Commits

Author SHA1 Message Date
98719d717e Darken the dark theme, and hover a tree row like a menu
The client's own look-and-feel defaults now live beside LookAndFeelManager as
FlatLaf properties files, which FlatLaf loads on top of each theme's own,
instead of a handful of UIManager.put calls.

The dark theme is built on three surfaces keyed to the toolbar's #282828: the
window chrome, the tab strips and menu bar one step below it at #232323, and
the content the client shows — channel tree, chat log, text fields, lists — at
#1e1e1e. Selections are #364b6f, and channel names in the tree are white; the
chat lines about server events keep the blue they had, which channel names no
longer carry. Everything FlatLaf derives (borders, scroll bars, hover states)
follows from those.

A read-only editor pane is a chat log or an information view here, not a
disabled input, so it keeps the content background rather than FlatLaf's
inactive one; and since neither a text view nor a tree fills its viewport, the
space below the last line takes their background instead of the window's.

The row under the pointer is now washed in the same colour a hovered menu is,
rather than outlined. FlatLaf states that colour as a shift of the surface
beneath it, so it is resolved against the window background exactly as the
menu bar resolves it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:07:19 +00:00
35f10ed14c Paint the client with FlatLaf, in a light and a dark theme
The look-and-feel is FlatLaf now, chosen light or dark on the new Design
tab and remembered in the settings. Theme keeps only the colours TeamSpeak
gives meaning to (talking, away, muted, channel and server names) and takes
its surfaces and fonts from the look-and-feel, so a switch recolours the
whole window, chat logs included: the HTML panes share one themed stylesheet
and re-parse their content when the theme changes.

Also fixes the channel tree not scrolling: the tree listened for the mouse
wheel to re-check its hover row, and AWT only forwards a wheel event to the
enclosing scroll pane when the component under the pointer has no wheel
listener of its own. The hover row now follows the viewport instead, which
also covers scrollbar drags and keyboard scrolling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:06:55 +00:00
d952ffd856 Put each client limit's count beside its "Limited" choice
The counts sat on a line of their own below the choices they belong to,
which also made the Advanced tab the tallest of the four and so decided
how much of the dialog the tab strip took. Each box now keeps its
natural height, and the free space collects below them.

Advanced went from 250px to 220px of preferred height, leaving the tab
strip about a third of the dialog rather than half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:11:40 +00:00
acfe25db15 Show our own name in bold, in the row and in its editor
The renderer asks the panel which id is ours rather than reading
ClientEntry.self: our own row usually arrives among the enter-view events
of the connect handshake, before the connection knows its own client id,
so that flag was left false. It is now stamped once the id is known,
which also fixes the "(you)" marker in the info panel.

The inline editor matches, and sits 2px further left so its text lines up
with the label it replaces rather than with its own border.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:59:55 +00:00
08e6c51e90 Leave a talking client's name alone, and rename from the whole row
Bolding the name of whoever is speaking re-measures the row while the
label keeps its old width, so a nickname spent every utterance clipped to
an ellipsis. Talking already shows in the client's icon, which is where
TeamSpeak shows it too, so the name keeps its normal weight and colour.

Renaming now starts from the tree's own double-click handling, which
works a row by its whole line, instead of Swing's, which only sees clicks
that land on the label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:50:03 +00:00
acd438dd14 Don't send a rename that changes nothing
Closing the inline editor without touching the name asked the server to
rename us to the name we already hold, which it refuses with "nickname
is already in use".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:43:00 +00:00
6e65797f25 Rename yourself by double-clicking your own tree row
The row turns into a text field holding the current nickname, selected so
typing replaces it; Enter commits, Escape leaves the name alone. Only our
own row edits — every other double click keeps its meaning, and ours no
longer opens a private chat with ourselves.

The editor keeps the row's own icon (the inherited one knows only the
renderer's default leaf icon) and selects the text on a short delay,
after the release that opened it has been turned into a caret placement.
The tree nodes only mirror the connection's model, so the typed name is
sent to the server rather than written into the node; the row follows
when the rename is reported back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:38:49 +00:00
fa99cadc44 Fill the selected tree row across its whole width
The cell renderer paints the selection only behind the label, which now
looks short next to the full-width hover outline and the full-line hit
area. The tree fills the selected rows itself, in the renderer's own
selection colour so the two meet seamlessly, which means it can no
longer clear its background as an opaque component would.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:16:06 +00:00
9ba0982f25 Work tree rows by their whole line, and outline the hovered one
Swing hit-tests a tree row only as far as its label, so a click past the
end of the name did nothing. Rows are now found by their vertical band
alone, which covers selection, the context menus, double-click join and
middle-click info; the expand handle to the left of a label still works
the row without selecting it.

The hovered row is outlined across that same full width, so what a click
would act on is visible before making it. Spacers, which nothing can be
done with, are skipped, and the outline gives way to the drag-and-drop
indicator while a drag is in progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:56:17 +00:00
275de77c8b Bold the channel references in the log, as the client ones are
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:24:05 +00:00
189620df7f Link the clients and channels named in server log lines
Log lines now name clients and channels with TeamSpeak's own client:// and
channel:// links, the same ones a dragged-in reference produces, so the
chat log's existing handling turns them into the context menus.

The log is rendered with a links-only pass rather than the full BBCode
renderer: its lines are assembled by the client itself, so nothing else in
them should be interpreted — a channel called "[b]VIP" stays literal.
Channels we no longer have (a deleted one) are still named in plain text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:11:14 +00:00
cb05b097a6 Add "Change Description" to the client menu
Offered for our own client as well as others, with TS3's edit icon. The
description is read back from the server before prompting, since the
model only carries one for clients whose info has been looked at.

Note that client_description is not a clientupdate property even for
ourselves — the server only takes it through clientedit — and that our
own change is not echoed back to us, so the local entry is updated here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:04:35 +00:00
bcc034e975 Refresh a channel's cached description when it changes
The description is fetched on demand and cached, but nothing invalidated
it: notifychanneledited never carries the text, and the separate
notifychanneldescriptionchanged was ignored. Handle that event, and
refresh locally after our own edit, since the server does not notify the
invoker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:38:53 +00:00
f68c1e297c Give the channel dialog's spare height to the description
The tabs were the growing half, which left them padding empty space
below their content while the description stayed five rows tall. They
now take their natural height at the bottom of the dialog and every
spare pixel goes to the description instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:12:44 +00:00
52bd180b56 Collect directory listings from the events that carry them
ftgetfilelist is answered with a notifyfilelist event per entry and a
closing notifyfilelistfinished, not with rows of the command's own reply
— and ts3j dispatches anything named notify* as an event, so every
listing came back empty. The entries are now collected for the request
in flight, which is what the icon chooser and the file browser read.

The icons themselves live in the repository's "icons" directory on newer
servers rather than loose in its root, so both places are read. They are
still addressed as /icon_<id> for download, upload and delete, which is
how the server resolves them regardless of where it keeps them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 16:03:03 +00:00
f2885d33ad Add the Create Channel dialog
The official client creates and edits channels with one dialog, so
ChannelEditDialog becomes ChannelDialog with two entry points. Creating
starts from caller-chosen defaults instead of a channelinfo and sends a
channelcreate carrying only what deviates from a plain new channel —
TeamSpeak checks a create permission per property that is present.

The server row now has a context menu with "Create Channel" and "Create
Spacer" (pre-filled with the lowest free [spacerN], since channel names
must be unique), and a channel's menu offers "Create Channel" and
"Create Sub-Channel", both starting out temporary.

Verified against the test server: a temporary channel is created, and a
permanent one a guest may not create reports the server's refusal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:31:07 +00:00
d8a56566d4 Add the Edit Channel dialog
Recovers the official client's CreateChannelDialog layout so the widget
set, labels and tab order match: name/icon/password/topic/description
above Standard, Audio, Permissions and Advanced tabs.

Core gains ChannelSettings (diffed against the original so channeledit
only carries changed properties) and ChannelAdmin (channelinfo/edit,
channel permissions, the server icon store). TeamspeakConnection now
owns the event executor so it can drain queued notify events before
reading a command's result, which channelpermlist needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:11:49 +00:00
b29f518d59 Hide/gray client-menu items that can't do anything useful right now
"Find Client in Channel Tree" only makes sense when the menu was
opened somewhere other than the tree itself (chat log links, etc.),
so it's hidden when opened from the tree, and disabled elsewhere if
the client's channel isn't currently subscribed (so the tree
wouldn't actually show them). Adds "Join Channel of Client" above
"Move Client to own Channel", disabled under the same visibility
condition plus a full channel. Both use the CHANNEL_SWITCH icon.

Note: TS3 doesn't expose a channel's join-power threshold as a plain
property (only group membership powers work that way); it's a
permission resolved through channel/channel-group/server-group
inheritance that a regular client can't query, so join-power itself
isn't part of this check.
2026-08-18 15:25:41 +00:00
acf0221837 Add Set Server/Channel Group context menus and dialog
Lets moderators assign or revoke server and channel groups for a
client directly from its context menu, with per-group checkboxes
gated by the client's actual TS3 permissions (i_group_needed_member_
add/remove_power vs. each group's n_member_addp/removep, including
TS3's -1 "unlimited" sentinel). A "Server Groups Dialog..." entry
shows every server group, graying out ones the client can't touch.

Group membership changes now update ClientEntry locally instead of
only logging them, so the checkboxes and dialog reflect a change
immediately instead of needing a reconnect. The server's default
channel group (e.g. "Guest") is hidden from the channel-group menu
since it's not something you assign directly.
2026-08-18 15:07:38 +00:00
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
65 changed files with 7818 additions and 2445 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ out/
# The bundled proprietary TeamSpeak 3 client (reference binary, not our source) # The bundled proprietary TeamSpeak 3 client (reference binary, not our source)
/TeamSpeak3-Client-linux_amd64/ /TeamSpeak3-Client-linux_amd64/
/re-android/
# Packages # Packages
*.jar *.jar

View File

@@ -27,6 +27,12 @@ public final class Settings {
CONTINUOUS CONTINUOUS
} }
/** Which look-and-feel variant the window is painted with. */
public enum Appearance {
LIGHT,
DARK
}
/** Voice-activation strategy, mirroring the TS3 client's VAD modes. */ /** Voice-activation strategy, mirroring the TS3 client's VAD modes. */
public enum VadMode { public enum VadMode {
/** Speech-probability detector only. */ /** Speech-probability detector only. */
@@ -95,6 +101,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 +131,15 @@ 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 = "";
/** Look-and-feel variant; see {@link Appearance}. */
public Appearance appearance = Appearance.LIGHT;
// ---- 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 +176,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 +216,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 +227,9 @@ 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);
appearance = parseAppearance(props.getProperty("appearance"), appearance);
showStatusBar = parseB(props.getProperty("showStatusBar"), showStatusBar);
showMasterVolumeSlider = parseB(props.getProperty("showMasterVolumeSlider"), showMasterVolumeSlider);
notifications.load(props); notifications.load(props);
} }
@@ -223,6 +258,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 +269,9 @@ 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("appearance", appearance.name());
props.setProperty("showStatusBar", Boolean.toString(showStatusBar));
props.setProperty("showMasterVolumeSlider", Boolean.toString(showMasterVolumeSlider));
notifications.store(props); notifications.store(props);
} }
@@ -245,6 +284,15 @@ public final class Settings {
} }
} }
private static Appearance parseAppearance(String v, Appearance def) {
if (v == null) return def;
try {
return Appearance.valueOf(v);
} catch (IllegalArgumentException e) {
return def;
}
}
private static VadMode parseVadMode(String v, VadMode def) { private static VadMode parseVadMode(String v, VadMode def) {
if (v == null) return def; if (v == null) return def;
try { try {

View File

@@ -0,0 +1,275 @@
package com.ts3client.net;
import com.github.manevolent.ts3j.api.Channel;
import com.github.manevolent.ts3j.command.MultiCommand;
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.ProtocolRole;
import com.ts3client.net.filetransfer.FileTransfer;
import com.ts3client.net.filetransfer.FileTransferManager;
import com.ts3client.net.filetransfer.RemoteFile;
import java.io.File;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.zip.CRC32;
/**
* Everything the channel editor needs of the server: reading and writing a channel's
* properties and its needed-power permissions, plus the virtual server's icon store.
*
* <p>Split out of {@link TeamspeakConnection} because it is a self-contained request/
* response corner of the protocol that only the editor uses. Every call here blocks and
* must run off the UI thread; the connection's public wrappers do that.
*/
final class ChannelAdmin {
/** The directory a server's icons live in, where the server has one. */
private static final String ICON_DIRECTORY = "icons";
/** How long to wait for a whole icon to travel over the file connection. */
private static final long ICON_UPLOAD_TIMEOUT_MS = 30_000;
private final TeamspeakConnection conn;
/**
* The {@code channelpermlist} reply in progress. TeamSpeak answers the command with a
* burst of {@code notifychannelpermlist} events rather than a command response, and
* only the first of them repeats the channel id, so one request may be in flight at a
* time and its entries are collected here until the command itself completes.
*/
private volatile PermRequest permRequest;
ChannelAdmin(TeamspeakConnection conn) {
this.conn = conn;
}
// ---- channel properties ----
/** Reads a channel's full property set. */
ChannelSettings readSettings(int channelId) throws Exception {
Channel info = conn.socket().getChannelInfo(channelId);
if (info == null) throw new IllegalStateException("The server did not describe that channel");
return ChannelSettings.from(channelId, info.getMap());
}
/**
* Creates a channel below {@code parentId} (0 being the top level).
*
* @param properties the new channel's properties, as {@link ChannelSettings#creationParameters}
* @return the new channel's id
*/
int create(int parentId, Map<String, String> properties) throws Exception {
SingleCommand cmd = new SingleCommand("channelcreate", ProtocolRole.CLIENT);
cmd.add(new CommandSingleParameter("cpid", Integer.toString(parentId)));
for (Map.Entry<String, String> property : properties.entrySet()) {
cmd.add(new CommandSingleParameter(property.getKey(), property.getValue()));
}
for (SingleCommand answer : conn.socket().executeCommand(cmd).get()) {
int cid = parseInt(answer.toMap().get("cid"), -1);
if (cid > 0) return cid;
}
// Some servers acknowledge the command without naming the channel; the
// notifychannelcreated they also send does name it, so read it off the model
// once that event has been handled.
conn.awaitEventsProcessed();
ChannelNode created = conn.getModel().findChannelByName(parentId, properties.get("channel_name"));
if (created == null) throw new IllegalStateException("The server did not report the new channel");
return created.id;
}
/** Applies {@code changes} (property name to value) with a single {@code channeledit}. */
void edit(int channelId, Map<String, String> changes) throws Exception {
if (changes.isEmpty()) return;
SingleCommand cmd = new SingleCommand("channeledit", ProtocolRole.CLIENT);
cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId)));
for (Map.Entry<String, String> change : changes.entrySet()) {
cmd.add(new CommandSingleParameter(change.getKey(), change.getValue()));
}
conn.socket().executeCommand(cmd).complete();
}
// ---- channel permissions ----
/**
* Reads the permissions set directly on a channel.
*
* @return permission name to value, holding only the permissions the channel itself
* carries — anything inherited from a group is absent
*/
Map<String, Integer> readPermissions(int channelId) throws Exception {
if (!conn.getModel().hasPermissionNames()) {
// channelpermlist reports numeric ids, which only the permissionlist response
// gives names to; without it the values could not be told apart.
throw new IllegalStateException("The server did not send its permission list");
}
PermRequest request = new PermRequest(channelId);
permRequest = request;
try {
SingleCommand cmd = new SingleCommand("channelpermlist", ProtocolRole.CLIENT,
new CommandSingleParameter("cid", Integer.toString(channelId)));
conn.socket().executeCommand(cmd).complete();
// The entries were queued on the event thread before the command's own reply
// was read, so draining that thread is what guarantees they have all landed.
conn.awaitEventsProcessed();
} finally {
permRequest = null;
}
Map<String, Integer> byName = new LinkedHashMap<>();
for (Map.Entry<Integer, Integer> entry : request.values.entrySet()) {
String name = conn.getModel().permissionName(entry.getKey());
if (name != null) byName.put(name, entry.getValue());
}
return byName;
}
/** Collects one {@code notifychannelpermlist} entry for the request in flight. */
void onPermListEntry(UnknownTeamspeakEvent e) {
PermRequest request = permRequest;
if (request == null) return;
String cid = e.get("cid");
if (cid != null && !cid.isEmpty() && parseInt(cid, -1) != request.channelId) return;
int permId = parseInt(e.get("permid"), -1);
if (permId >= 0) request.values.put(permId, parseInt(e.get("permvalue"), 0));
}
/**
* Sets and clears channel permissions by name. A permission set to a value is added or
* updated; one that is cleared is removed so the channel inherits it again.
*/
void writePermissions(int channelId, Map<String, Integer> set, Collection<String> remove)
throws Exception {
if (!set.isEmpty()) {
List<SingleCommand> parts = new ArrayList<>(set.size());
for (Map.Entry<String, Integer> perm : set.entrySet()) {
parts.add(new SingleCommand("channeladdperm", ProtocolRole.CLIENT,
new CommandSingleParameter("permsid", perm.getKey()),
new CommandSingleParameter("permvalue", Integer.toString(perm.getValue()))));
}
send("channeladdperm", channelId, parts);
}
if (!remove.isEmpty()) {
List<SingleCommand> parts = new ArrayList<>(remove.size());
for (String name : remove) {
parts.add(new SingleCommand("channeldelperm", ProtocolRole.CLIENT,
new CommandSingleParameter("permsid", name)));
}
send("channeldelperm", channelId, parts);
}
}
private void send(String name, int channelId, List<SingleCommand> parts) throws Exception {
MultiCommand cmd = new MultiCommand(name, ProtocolRole.CLIENT, parts);
cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId)));
conn.socket().executeCommand(cmd).complete();
}
// ---- the server's icon store ----
/**
* The ids of the icons uploaded to this virtual server, which live in channel 0's file
* repository as {@code /icon_<id>}.
*/
List<Long> listIcons() throws Exception {
List<Long> ids = new ArrayList<>();
boolean hasIconDirectory = false;
for (RemoteFile file : fileTransfers().list(0, "", "/")) {
if (file.isDirectory()) {
hasIconDirectory |= file.getName().equals(ICON_DIRECTORY);
continue;
}
addIcon(ids, file);
}
// Newer servers keep the icons in their own directory rather than loose in the
// repository's root; both layouts exist, so whichever this server uses is read.
if (hasIconDirectory) {
for (RemoteFile file : fileTransfers().list(0, "", "/" + ICON_DIRECTORY)) {
if (!file.isDirectory()) addIcon(ids, file);
}
}
return ids;
}
private static void addIcon(List<Long> ids, RemoteFile file) {
if (!file.getName().startsWith("icon_")) return;
long id = parseLong(file.getName().substring("icon_".length()));
if (id > 0 && !ids.contains(id)) ids.add(id);
}
/**
* Uploads an image as a server icon.
*
* @return the icon's id, which TeamSpeak derives from the file's CRC32
*/
long uploadIcon(File source) throws Exception {
byte[] data = Files.readAllBytes(source.toPath());
if (data.length == 0) throw new IllegalArgumentException("That file is empty");
if (data.length > TeamspeakConnection.MAX_ICON_BYTES) {
throw new IllegalArgumentException("Icons may not be larger than "
+ (TeamspeakConnection.MAX_ICON_BYTES / 1024) + " KiB");
}
CRC32 crc = new CRC32();
crc.update(data);
long id = crc.getValue();
CountDownLatch done = new CountDownLatch(1);
FileTransfer transfer = fileTransfers().upload(0, "", "/icon_" + id, source, true, t -> {
if (t.isDone()) done.countDown();
});
if (!done.await(ICON_UPLOAD_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
throw new IllegalStateException("The upload did not finish in time");
}
if (transfer.getState() != FileTransfer.State.COMPLETED) {
throw new IllegalStateException(transfer.getErrorMessage() == null
? "The upload failed" : transfer.getErrorMessage());
}
return id;
}
void deleteIcon(long iconId) throws Exception {
fileTransfers().delete(0, "", "/icon_" + iconId);
}
private FileTransferManager fileTransfers() {
FileTransferManager ft = conn.fileTransfers();
if (ft == null) throw new IllegalStateException("Not connected");
return ft;
}
// ---- helpers ----
private static int parseInt(String value, int fallback) {
try {
return value == null || value.isEmpty() ? fallback : Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
return fallback;
}
}
private static long parseLong(String value) {
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException e) {
return 0;
}
}
/** One in-flight {@code channelpermlist} and the entries received for it so far. */
private static final class PermRequest {
final int channelId;
final Map<Integer, Integer> values = new ConcurrentHashMap<>();
PermRequest(int channelId) {
this.channelId = channelId;
}
}
}

View File

@@ -0,0 +1,216 @@
package com.ts3client.net;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* The editable properties of a channel, as read from {@code channelinfo} and written
* back with {@code channeledit}.
*
* <p>Kept apart from {@link ChannelNode} on purpose: the node is the live tree state the
* server pushes at us, while this is a snapshot an editor works on. Submitting compares
* the edited copy against the untouched original ({@link #changesFrom}) so only the
* properties the user actually changed are sent — TeamSpeak refuses the whole command
* when it contains one property the client may not modify, even an unchanged one.
*/
public final class ChannelSettings {
/** How long a channel outlives its last occupant. */
public enum Type {TEMPORARY, SEMI_PERMANENT, PERMANENT}
public int id;
public int parentId;
public String name = "";
public String topic = "";
public String description = "";
public String phoneticName = "";
/** Whether the server reports a password on this channel; the password itself is never readable. */
public boolean hasPassword;
/** {@code null} leaves the password alone, {@code ""} removes it, anything else sets it. */
public String password;
public Type type = Type.PERMANENT;
public boolean defaultChannel;
/** The sibling this channel sorts after, or 0 to sort first. */
public int order;
public int neededTalkPower;
/** TeamSpeak codec id; see {@code com.github.manevolent.ts3j.api.Codec}. */
public int codec = 4;
public int codecQuality = 6;
public boolean encrypted = true;
public int deleteDelay;
public boolean maxClientsUnlimited = true;
public int maxClients = 16;
public boolean familyInherited = true;
public boolean familyUnlimited;
public int maxFamilyClients = 16;
public long iconId;
/** Reads a {@code channelinfo} (or {@code channellist}) property map. */
public static ChannelSettings from(int channelId, Map<String, String> props) {
ChannelSettings s = new ChannelSettings();
s.id = channelId;
s.parentId = intOf(props, "pid", intOf(props, "cpid", 0));
s.name = strOf(props, "channel_name");
s.topic = strOf(props, "channel_topic");
s.description = strOf(props, "channel_description");
s.phoneticName = strOf(props, "channel_name_phonetic");
s.hasPassword = boolOf(props, "channel_flag_password");
if (boolOf(props, "channel_flag_permanent")) s.type = Type.PERMANENT;
else if (boolOf(props, "channel_flag_semi_permanent")) s.type = Type.SEMI_PERMANENT;
else s.type = Type.TEMPORARY;
s.defaultChannel = boolOf(props, "channel_flag_default");
s.order = intOf(props, "channel_order", 0);
s.neededTalkPower = intOf(props, "channel_needed_talk_power", 0);
s.codec = intOf(props, "channel_codec", 4);
s.codecQuality = intOf(props, "channel_codec_quality", 6);
s.encrypted = !boolOf(props, "channel_codec_is_unencrypted");
s.deleteDelay = intOf(props, "channel_delete_delay", 0);
s.maxClientsUnlimited = boolOf(props, "channel_flag_maxclients_unlimited");
s.maxClients = Math.max(0, intOf(props, "channel_maxclients", 16));
s.familyInherited = boolOf(props, "channel_flag_maxfamilyclients_inherited");
s.familyUnlimited = boolOf(props, "channel_flag_maxfamilyclients_unlimited");
s.maxFamilyClients = Math.max(0, intOf(props, "channel_maxfamilyclients", 16));
s.iconId = longOf(props, "channel_icon_id");
return s;
}
public ChannelSettings copy() {
ChannelSettings c = new ChannelSettings();
c.id = id;
c.parentId = parentId;
c.name = name;
c.topic = topic;
c.description = description;
c.phoneticName = phoneticName;
c.hasPassword = hasPassword;
c.password = password;
c.type = type;
c.defaultChannel = defaultChannel;
c.order = order;
c.neededTalkPower = neededTalkPower;
c.codec = codec;
c.codecQuality = codecQuality;
c.encrypted = encrypted;
c.deleteDelay = deleteDelay;
c.maxClientsUnlimited = maxClientsUnlimited;
c.maxClients = maxClients;
c.familyInherited = familyInherited;
c.familyUnlimited = familyUnlimited;
c.maxFamilyClients = maxFamilyClients;
c.iconId = iconId;
return c;
}
/**
* The {@code channeledit} parameters needed to turn {@code original} into this.
*
* @return property name to value, empty when nothing changed
*/
public Map<String, String> changesFrom(ChannelSettings original) {
Map<String, String> changes = new LinkedHashMap<>();
putIfChanged(changes, "channel_name", name, original.name);
putIfChanged(changes, "channel_topic", topic, original.topic);
putIfChanged(changes, "channel_description", description, original.description);
putIfChanged(changes, "channel_name_phonetic", phoneticName, original.phoneticName);
if (password != null) changes.put("channel_password", password);
if (type != original.type) {
// The three flags are mutually exclusive and the server wants the whole set:
// clearing one without setting another leaves the channel type ambiguous.
changes.put("channel_flag_permanent", flag(type == Type.PERMANENT));
changes.put("channel_flag_semi_permanent", flag(type == Type.SEMI_PERMANENT));
changes.put("channel_flag_temporary", flag(type == Type.TEMPORARY));
}
// Clearing the default flag is meaningless — some channel has to be the default —
// so only setting it is ever sent.
if (defaultChannel && !original.defaultChannel) changes.put("channel_flag_default", "1");
putIfChanged(changes, "channel_order", order, original.order);
putIfChanged(changes, "channel_needed_talk_power", neededTalkPower, original.neededTalkPower);
putIfChanged(changes, "channel_codec", codec, original.codec);
putIfChanged(changes, "channel_codec_quality", codecQuality, original.codecQuality);
putIfChanged(changes, "channel_codec_is_unencrypted", !encrypted, !original.encrypted);
putIfChanged(changes, "channel_delete_delay", deleteDelay, original.deleteDelay);
putIfChanged(changes, "channel_flag_maxclients_unlimited",
maxClientsUnlimited, original.maxClientsUnlimited);
if (!maxClientsUnlimited) putIfChanged(changes, "channel_maxclients", maxClients, original.maxClients);
putIfChanged(changes, "channel_flag_maxfamilyclients_inherited",
familyInherited, original.familyInherited);
putIfChanged(changes, "channel_flag_maxfamilyclients_unlimited",
familyUnlimited, original.familyUnlimited);
if (!familyInherited && !familyUnlimited) {
putIfChanged(changes, "channel_maxfamilyclients", maxFamilyClients, original.maxFamilyClients);
}
if (iconId != original.iconId) changes.put("channel_icon_id", Long.toString(iconId));
return changes;
}
/**
* The {@code channelcreate} parameters for a channel that does not exist yet.
*
* <p>Everything a fresh channel would get anyway is left out, because TeamSpeak checks
* a create permission per property that is present — a client allowed to create plain
* channels but not, say, ones with a topic must not send an empty topic. What the
* server cannot infer is always sent: the name, the channel type (whose absent flags
* would silently mean temporary), the codec, and the client limits when they are not
* unlimited.
*/
public Map<String, String> creationParameters() {
Map<String, String> params = changesFrom(new ChannelSettings());
params.put("channel_name", name);
params.put("channel_flag_permanent", flag(type == Type.PERMANENT));
params.put("channel_flag_semi_permanent", flag(type == Type.SEMI_PERMANENT));
params.put("channel_flag_temporary", flag(type == Type.TEMPORARY));
params.put("channel_codec", Integer.toString(codec));
params.put("channel_codec_quality", Integer.toString(codecQuality));
if (!maxClientsUnlimited) params.put("channel_maxclients", Integer.toString(maxClients));
if (!familyInherited && !familyUnlimited) {
params.put("channel_maxfamilyclients", Integer.toString(maxFamilyClients));
}
if (password == null || password.isEmpty()) params.remove("channel_password");
return params;
}
private static void putIfChanged(Map<String, String> into, String key, String value, String was) {
if (!value.equals(was)) into.put(key, value);
}
private static void putIfChanged(Map<String, String> into, String key, int value, int was) {
if (value != was) into.put(key, Integer.toString(value));
}
private static void putIfChanged(Map<String, String> into, String key, boolean value, boolean was) {
if (value != was) into.put(key, flag(value));
}
private static String flag(boolean set) {
return set ? "1" : "0";
}
private static String strOf(Map<String, String> props, String key) {
String v = props.get(key);
return v == null ? "" : v;
}
private static boolean boolOf(Map<String, String> props, String key) {
return "1".equals(props.get(key));
}
private static int intOf(Map<String, String> props, String key, int fallback) {
try {
String v = props.get(key);
return v == null || v.isEmpty() ? fallback : Integer.parseInt(v.trim());
} catch (NumberFormatException e) {
return fallback;
}
}
private static long longOf(Map<String, String> props, String key) {
try {
String v = props.get(key);
if (v == null || v.isEmpty()) return 0;
long id = Long.parseLong(v.trim());
return id < 0 ? id & 0xFFFFFFFFL : id;
} catch (NumberFormatException e) {
return 0;
}
}
}

View File

@@ -6,6 +6,7 @@ public final class ClientEntry {
public int channelId; public int channelId;
public String nickname; public String nickname;
public String uniqueId = ""; public String uniqueId = "";
public int databaseId;
public int type; // 0 = normal voice client, 1 = server-query public int type; // 0 = normal voice client, 1 = server-query
public int talkPower; public int talkPower;
@@ -39,4 +40,28 @@ public final class ClientEntry {
public boolean isQuery() { public boolean isQuery() {
return type == 1; return type == 1;
} }
/** Adds a server group id, if not already present. */
public void addServerGroup(int groupId) {
for (int id : serverGroupIds) if (id == groupId) return;
int[] updated = java.util.Arrays.copyOf(serverGroupIds, serverGroupIds.length + 1);
updated[serverGroupIds.length] = groupId;
serverGroupIds = updated;
}
/** Removes a server group id, if present. */
public void removeServerGroup(int groupId) {
int index = -1;
for (int i = 0; i < serverGroupIds.length; i++) {
if (serverGroupIds[i] == groupId) {
index = i;
break;
}
}
if (index < 0) return;
int[] updated = new int[serverGroupIds.length - 1];
System.arraycopy(serverGroupIds, 0, updated, 0, index);
System.arraycopy(serverGroupIds, index + 1, updated, index, serverGroupIds.length - index - 1);
serverGroupIds = updated;
}
} }

View File

@@ -0,0 +1,629 @@
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.databaseId = e.getClientDatabaseId();
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 {
String name = conn.clientLink(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, conn.clientLink(c.id), 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 = conn.clientLink(e.getClientId(),
TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier()), e.getClientNickname());
switch (safeInt(e, "reasonid")) {
case REASON_MOVED:
conn.log(name + " appears, coming from channel \"" + conn.channelLink(e.getClientFromId()) + "\"");
break;
case REASON_CHANNEL_KICK:
conn.log(name + " appears, was kicked from channel \"" + conn.channelLink(e.getClientFromId())
+ "\" by " + invokerName(e));
break;
case REASON_SWITCHED:
conn.log(name + " switched to channel \"" + conn.channelLink(e.getClientTargetId())
+ "\", coming from channel \"" + conn.channelLink(e.getClientFromId()) + "\"");
break;
default:
conn.log(name + " connected to channel \"" + conn.channelLink(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.channelLink(e.getClientTargetId())
+ "\" by " + invokerName(e) + suffix);
break;
case REASON_MOVED:
conn.log(name + " left, heading to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
break;
case REASON_SWITCHED:
conn.log(name + " left, switched to channel \"" + conn.channelLink(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.channelLink(fromChannel) + "\" to \""
+ conn.channelLink(toChannel) + "\" by " + invokerName(e));
break;
case REASON_CHANNEL_KICK:
conn.log(name + " was kicked from channel \"" + conn.channelLink(fromChannel) + "\" to \""
+ conn.channelLink(toChannel) + "\" by " + invokerName(e) + suffix);
break;
default:
conn.log(name + " switched from channel \"" + conn.channelLink(fromChannel) + "\" to \""
+ conn.channelLink(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_description")) {
c.description = e.get("client_description");
conn.ui.onInfoUpdated();
}
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 " + conn.clientLink(c.id));
}
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 \"" + conn.channelLink(cid) + "\" 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 (has(e, "channel_topic")) ch.topic = e.get("channel_topic");
if (conn.isConnected()) {
boolean current = conn.inOwnChannel(ch.id);
conn.sound(current
? byInvoker(e, SoundEvent.CHANNEL_EDITED_CURRENT_BY_YOU,
SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER, SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER)
: byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU,
SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER),
conn.channelVars(ch.id, e.get("invokername")));
conn.log("Channel \"" + conn.channelLink(ch.id) + "\" was edited by " + invokerName(e));
}
conn.ui.onModelChanged();
}
}
/**
* Descriptions never travel with the notify, so a cached one has to be re-fetched.
* Only channels whose description was already loaded are refreshed; the rest pick the
* new text up the first time they are looked at.
*/
@Override
public void onChannelDescriptionChanged(ChannelDescriptionEditedEvent e) {
ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid"));
if (ch == null || !ch.descriptionLoaded) return;
ch.descriptionLoaded = false;
conn.requestChannelInfo(ch.id);
}
@Override
public void onChannelMoved(ChannelMovedEvent e) {
ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid"));
if (ch != null) {
int parent = has(e, "cpid") ? e.getInt("cpid") : ch.parentId;
int order = has(e, "order") ? e.getInt("order") : ch.order;
conn.getModel().relinkChannel(ch.id, parent, order);
if (conn.isConnected()) {
conn.sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER,
SoundEvent.CHANNEL_MOVED_BY_OTHER), conn.channelVars(ch.id, e.get("invokername")));
conn.log("Channel \"" + conn.channelLink(ch.id) + "\" was moved by " + invokerName(e));
}
conn.ui.onModelChanged();
}
}
@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) {
ClientEntry c = conn.getModel().getClient(e.getClientId());
if (c != null) c.addServerGroup(e.getServerGroupId());
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) + ".");
conn.ui.onModelChanged();
}
@Override
public void onServerGroupClientDeleted(ServerGroupClientDeletedEvent e) {
int clientId = safeInt(e, "clid");
ClientEntry c = conn.getModel().getClient(clientId);
if (c != null) c.removeServerGroup(e.getServerGroupId());
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) + ".");
conn.ui.onModelChanged();
}
@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 name for a log line, as a link, falling back to its id once it has left. */
private String clientLogName(int clientId) {
return conn.clientLink(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"),
safeInt(e, "type"), safeInt(e, "n_member_addp"), safeInt(e, "n_member_removep"));
}
@Override
public void onPermissionList(PermissionListEvent e) {
conn.getModel().putPermissionName(e.get("permname"));
}
@Override
public void onClientNeededPermissions(ClientNeededPermissionsEvent e) {
conn.getModel().putSelfPermissionValue(safeInt(e, "permid"), safeInt(e, "permvalue"));
}
@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);
else if ("notifychannelpermlist".equals(e.getCommand())) conn.channels.onPermListEntry(e);
}
// ---- helpers ----
/** Who caused an event, linked where the server named them; "the server" when nobody did. */
private String invokerName(BaseEvent e) {
String name = TeamspeakConnection.orEmpty(e.get("invokername"));
if (name.isEmpty()) return "the server";
return conn.clientLink(safeInt(e, "invokerid"),
TeamspeakConnection.orEmpty(e.get("invokeruid")), 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

@@ -9,11 +9,26 @@ public final class Group {
public final long iconId; public final long iconId;
/** Display order among groups; lower comes first. */ /** Display order among groups; lower comes first. */
public final int sortId; public final int sortId;
/** TS3 group type: 0 = template, 1 = regular, 2 = query (server groups only). */
public final int type;
/** Power needed to add a member to this group. */
public final int neededMemberAddPower;
/** Power needed to remove a member from this group. */
public final int neededMemberRemovePower;
public Group(int id, String name, long iconId, int sortId) { public Group(int id, String name, long iconId, int sortId, int type,
int neededMemberAddPower, int neededMemberRemovePower) {
this.id = id; this.id = id;
this.name = name; this.name = name;
this.iconId = iconId; this.iconId = iconId;
this.sortId = sortId; this.sortId = sortId;
this.type = type;
this.neededMemberAddPower = neededMemberAddPower;
this.neededMemberRemovePower = neededMemberRemovePower;
}
/** Regular, user-assignable groups exclude templates and query-only groups. */
public boolean isRegular() {
return type == 1;
} }
} }

View File

@@ -6,6 +6,7 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -29,6 +30,12 @@ public final class IconRepository {
/** Icons at or below this id are bundled defaults rather than server uploads. */ /** Icons at or below this id are bundled defaults rather than server uploads. */
public static final int MAX_BUNDLED_ID = 1000; public static final int MAX_BUNDLED_ID = 1000;
/**
* The default group icons shipped with the client, in the order TeamSpeak's own icon
* packs list them. These are what the icon chooser offers as "local" icons.
*/
public static final List<Long> BUNDLED_IDS = List.of(100L, 200L, 300L, 500L, 600L);
/** Downloads {@code /icon_<id>} from the connected server's file repository. */ /** Downloads {@code /icon_<id>} from the connected server's file repository. */
public interface Fetcher { public interface Fetcher {
byte[] fetchIcon(long iconId) throws Exception; byte[] fetchIcon(long iconId) throws Exception;

View File

@@ -18,13 +18,29 @@ public final class ServerModel {
private final Map<Integer, ClientEntry> clients = new LinkedHashMap<>(); private final Map<Integer, ClientEntry> clients = new LinkedHashMap<>();
private final Map<Integer, Group> serverGroups = new LinkedHashMap<>(); private final Map<Integer, Group> serverGroups = new LinkedHashMap<>();
private final Map<Integer, Group> channelGroups = new LinkedHashMap<>(); private final Map<Integer, Group> channelGroups = new LinkedHashMap<>();
/**
* Permission id -> name, learned from the server's {@code permissionlist} response.
* Entries carry no id of their own: TS3 numbers them by position in the response,
* and the empty "group_id_end" separator records marking category boundaries don't
* count toward that position, so only {@link #putPermissionName} advances it.
*/
private final Map<Integer, String> permissionNames = new LinkedHashMap<>();
private int nextPermissionId;
/** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */
private final Map<Integer, Integer> selfPermissionValues = new LinkedHashMap<>();
private String serverName = "TeamSpeak Server"; private String serverName = "TeamSpeak Server";
/** The channel group everyone starts in, e.g. "Guest" — not worth offering to (re)assign. */
private int defaultChannelGroupId;
public synchronized void clear() { public synchronized void clear() {
channels.clear(); channels.clear();
clients.clear(); clients.clear();
serverGroups.clear(); serverGroups.clear();
channelGroups.clear(); channelGroups.clear();
permissionNames.clear();
nextPermissionId = 0;
selfPermissionValues.clear();
defaultChannelGroupId = 0;
} }
// ---- groups ---- // ---- groups ----
@@ -78,6 +94,64 @@ public final class ServerModel {
return g == null ? null : g.name; return g == null ? null : g.name;
} }
/** Regular (non-template, non-query) server groups, ordered for display. */
public synchronized List<Group> allServerGroups() {
return regularGroups(serverGroups);
}
/** Regular (non-template, non-query) channel groups, ordered for display. */
public synchronized List<Group> allChannelGroups() {
return regularGroups(channelGroups);
}
private static List<Group> regularGroups(Map<Integer, Group> groups) {
List<Group> list = new ArrayList<>();
for (Group g : groups.values()) {
if (g.isRegular()) list.add(g);
}
list.sort(Comparator.comparingInt((Group g) -> g.sortId).thenComparingInt(g -> g.id));
return list;
}
// ---- permissions ----
/** Records the next named entry of a {@code permissionlist} response; ignores separators. */
public synchronized void putPermissionName(String name) {
if (name == null || name.isEmpty()) return;
permissionNames.put(nextPermissionId, name);
nextPermissionId++;
}
public synchronized void putSelfPermissionValue(int permId, int value) {
selfPermissionValues.put(permId, value);
}
/**
* The local client's resolved value for a named permission (e.g.
* {@code "i_group_needed_member_add_power"}), or {@code 0} if it isn't known
* yet (the {@code permissionlist} request is still in flight, or the server
* never reported a non-default value for it).
*/
/** Whether the server's {@code permissionlist} has arrived, so ids can be named at all. */
public synchronized boolean hasPermissionNames() {
return !permissionNames.isEmpty();
}
/** The name the server gave permission id {@code id}, or {@code null} if unknown. */
public synchronized String permissionName(int id) {
return permissionNames.get(id);
}
public synchronized int selfPermissionValue(String name) {
for (Map.Entry<Integer, String> entry : permissionNames.entrySet()) {
if (entry.getValue().equals(name)) {
Integer value = selfPermissionValues.get(entry.getKey());
if (value != null) return value;
}
}
return 0;
}
public synchronized String getServerName() { public synchronized String getServerName() {
return serverName; return serverName;
} }
@@ -86,6 +160,14 @@ public final class ServerModel {
if (name != null && !name.isEmpty()) this.serverName = name; if (name != null && !name.isEmpty()) this.serverName = name;
} }
public synchronized int defaultChannelGroupId() {
return defaultChannelGroupId;
}
public synchronized void setDefaultChannelGroupId(int id) {
this.defaultChannelGroupId = id;
}
// ---- channels ---- // ---- channels ----
public synchronized ChannelNode putChannel(int id, String name, int parentId, int order) { public synchronized ChannelNode putChannel(int id, String name, int parentId, int order) {
@@ -129,6 +211,41 @@ public final class ServerModel {
c.order = newOrder; c.order = newOrder;
} }
/**
* The channels sharing a parent with {@code channelId}, in the order they appear in the
* tree and excluding the channel itself — what the editor offers as places to sort after.
*/
public synchronized List<ChannelNode> siblingsOf(int channelId) {
ChannelNode channel = channels.get(channelId);
if (channel == null) return new ArrayList<>();
// Ordered with the channel still in place: the order links form a chain, and
// pulling a link out of it first would strand everything below.
List<ChannelNode> siblings = childrenOf(channel.parentId);
siblings.remove(channel);
return siblings;
}
/**
* The channels directly below {@code parentId} (0 being the top level), in the order
* they appear in the tree — what a new channel can be sorted after.
*/
public synchronized List<ChannelNode> childrenOf(int parentId) {
List<ChannelNode> children = new ArrayList<>();
for (ChannelNode c : channels.values()) {
if (c.parentId == parentId) children.add(c);
}
sortSiblings(children);
return children;
}
/** The channel of that exact name directly below {@code parentId}, or {@code null}. */
public synchronized ChannelNode findChannelByName(int parentId, String name) {
for (ChannelNode c : channels.values()) {
if (c.parentId == parentId && c.name.equals(name)) return c;
}
return null;
}
/** The "/"-separated path of a channel from the root, e.g. {@code "Lobby/Games"}. */ /** The "/"-separated path of a channel from the root, e.g. {@code "Lobby/Games"}. */
public synchronized String channelPath(int id) { public synchronized String channelPath(int id) {
ChannelNode c = channels.get(id); ChannelNode c = channels.get(id);

View File

@@ -52,6 +52,8 @@ public final class FileTransferManager {
/** TeamSpeak error id returned by {@code ftgetfilelist} for an empty directory. */ /** TeamSpeak error id returned by {@code ftgetfilelist} for an empty directory. */
private static final int ERROR_DATABASE_EMPTY_RESULT = 0x0501; private static final int ERROR_DATABASE_EMPTY_RESULT = 0x0501;
/** How long to wait for the server to finish listing a directory. */
private static final long LIST_TIMEOUT_MS = 15_000;
private final LocalTeamspeakClientSocket socket; private final LocalTeamspeakClientSocket socket;
/** Host we are connected to, used when the server reports no dedicated file-transfer host. */ /** Host we are connected to, used when the server reports no dedicated file-transfer host. */
@@ -70,6 +72,14 @@ public final class FileTransferManager {
* routes back to the waiting transfer thread. * routes back to the waiting transfer thread.
*/ */
private final Map<Integer, CompletableFuture<Map<String, String>>> pendingInits = new ConcurrentHashMap<>(); private final Map<Integer, CompletableFuture<Map<String, String>>> pendingInits = new ConcurrentHashMap<>();
/**
* The {@code ftgetfilelist} in progress. The server answers that command with a
* {@code notifyfilelist} event per entry and a closing {@code notifyfilelistfinished}
* rather than with rows of the command's own reply, so the entries are collected here
* until the listing ends. One listing may be in flight at a time.
*/
private volatile FileListRequest fileListRequest;
private final TS3Listener ftEventListener = new TS3Listener() { private final TS3Listener ftEventListener = new TS3Listener() {
@Override @Override
public void onUnknownEvent(UnknownTeamspeakEvent e) { public void onUnknownEvent(UnknownTeamspeakEvent e) {
@@ -88,34 +98,49 @@ public final class FileTransferManager {
/** /**
* Lists the files and subdirectories directly under {@code path} in the given * Lists the files and subdirectories directly under {@code path} in the given
* channel's repository. Returns an empty list for an empty directory. * channel's repository. Returns an empty list for an empty directory.
*
* <p>Blocks until the server has sent the whole listing, so it must not be called from
* the event thread that delivers it.
*/ */
public List<RemoteFile> list(int channelId, String channelPassword, String path) public List<RemoteFile> list(int channelId, String channelPassword, String path)
throws Exception { throws Exception {
String directory = path == null || path.isEmpty() ? "/" : path;
SingleCommand cmd = new SingleCommand("ftgetfilelist", ProtocolRole.CLIENT, SingleCommand cmd = new SingleCommand("ftgetfilelist", ProtocolRole.CLIENT,
new CommandSingleParameter("cid", Integer.toString(channelId)), new CommandSingleParameter("cid", Integer.toString(channelId)),
new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword), new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword),
new CommandSingleParameter("path", path == null || path.isEmpty() ? "/" : path)); new CommandSingleParameter("path", directory));
List<RemoteFile> files = new ArrayList<>(); FileListRequest request = new FileListRequest(channelId, directory);
Iterable<SingleCommand> rows; fileListRequest = request;
try { try {
rows = socket.executeCommand(cmd).get(); socket.executeCommand(cmd).complete();
// The command's reply only acknowledges it; the entries are events, and the
// server marks their end with notifyfilelistfinished.
request.finished.get(LIST_TIMEOUT_MS, TimeUnit.MILLISECONDS);
} catch (com.github.manevolent.ts3j.command.CommandException e) { } catch (com.github.manevolent.ts3j.command.CommandException e) {
if (e.getErrorId() == ERROR_DATABASE_EMPTY_RESULT) return files; if (e.getErrorId() != ERROR_DATABASE_EMPTY_RESULT) throw e;
throw e; } catch (TimeoutException e) {
throw new IOException("The server did not finish listing " + directory);
} finally {
fileListRequest = null;
} }
for (SingleCommand row : rows) { return new ArrayList<>(request.files);
Map<String, String> m = row.toMap(); }
String name = m.get("name");
if (name == null || name.isEmpty()) continue; /** Collects one {@code notifyfilelist} entry for the listing in flight. */
files.add(new RemoteFile( private void collectListEntry(Map<String, String> row) {
name, FileListRequest request = fileListRequest;
m.getOrDefault("path", path), if (request == null) return;
parseLong(m.get("size")), String cid = row.get("cid");
"0".equals(m.get("type")), if (cid != null && !cid.isEmpty() && parseLong(cid) != request.channelId) return;
parseLong(m.get("datetime")))); String name = row.get("name");
} if (name == null || name.isEmpty()) return;
return files; request.files.add(new RemoteFile(
name,
row.getOrDefault("path", request.path),
parseLong(row.get("size")),
"0".equals(row.get("type")),
parseLong(row.get("datetime"))));
} }
/** Creates a new directory at {@code dirPath} (a full path such as {@code /new}). */ /** Creates a new directory at {@code dirPath} (a full path such as {@code /new}). */
@@ -351,6 +376,15 @@ public final class FileTransferManager {
String command = e.getCommand(); String command = e.getCommand();
if (command == null) return; if (command == null) return;
Map<String, String> map = e.getMap(); Map<String, String> map = e.getMap();
if (command.equals("notifyfilelist")) {
collectListEntry(map);
return;
}
if (command.equals("notifyfilelistfinished")) {
FileListRequest request = fileListRequest;
if (request != null) request.finished.complete(null);
return;
}
Integer ftfid = tryParseInt(map.get("clientftfid")); Integer ftfid = tryParseInt(map.get("clientftfid"));
if (ftfid == null) return; if (ftfid == null) return;
CompletableFuture<Map<String, String>> future = pendingInits.get(ftfid); CompletableFuture<Map<String, String>> future = pendingInits.get(ftfid);
@@ -454,4 +488,17 @@ public final class FileTransferManager {
String m = r.getMessage(); String m = r.getMessage();
return m != null ? m : r.getClass().getSimpleName(); return m != null ? m : r.getClass().getSimpleName();
} }
/** One in-flight {@code ftgetfilelist} and the entries received for it so far. */
private static final class FileListRequest {
final int channelId;
final String path;
final List<RemoteFile> files = new java.util.concurrent.CopyOnWriteArrayList<>();
final CompletableFuture<Void> finished = new CompletableFuture<>();
FileListRequest(int channelId, String path) {
this.channelId = channelId;
this.path = path;
}
}
} }

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

@@ -37,6 +37,10 @@ public final class BBCode {
/** CSS class on links that leave the client, so they are visibly not an identity. */ /** CSS class on links that leave the client, so they are visibly not an identity. */
public static final String EXTERNAL_LINK_CLASS = "extlink"; public static final String EXTERNAL_LINK_CLASS = "extlink";
/** A single {@code [url=href]label[/url]} pair, the only markup the log carries. */
private static final Pattern URL_LINK =
Pattern.compile("(?i)\\[url=([^\\]]+)](.*?)\\[/url]");
private BBCode() { private BBCode() {
} }
@@ -70,6 +74,29 @@ public final class BBCode {
return out.toString(); return out.toString();
} }
/**
* Renders text carrying {@code [url=…]…[/url]} links and nothing else: everything
* outside those tags stays literal. The server log is written this way, so that a
* name happening to contain bracket markup is shown as it is rather than rendered.
*/
public static String linksToHtml(String input) {
if (input == null || input.isEmpty()) return "";
StringBuilder out = new StringBuilder();
Matcher m = URL_LINK.matcher(input);
int last = 0;
while (m.find()) {
out.append(escape(input.substring(last, m.start())));
String href = m.group(1);
if (SAFE_URL.matcher(href).matches()) {
out.append(link(href)).append(escape(m.group(2))).append(linkClose(href));
} else {
out.append(escape(m.group()));
}
last = m.end();
}
return out.append(escape(input.substring(last))).toString();
}
/** Escapes plain text for HTML without interpreting any BBCode. */ /** Escapes plain text for HTML without interpreting any BBCode. */
public static String escape(String s) { public static String escape(String s) {
if (s == null) return ""; if (s == null) return "";

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

@@ -0,0 +1,40 @@
package com.ts3client.text;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The server log's renderer: it turns the client's own links into anchors and leaves
* everything else — names carrying bracket markup included — as plain text.
*/
class BBCodeLinksTest {
@Test
void rendersClientAndChannelLinks() {
String log = TsLink.clientBBCode(7, "uid=", "Bob") + " connected to channel \""
+ TsLink.channelBBCode(3, "Lobby") + "\"";
String html = BBCode.linksToHtml(log);
assertTrue(html.contains("href=\"client://7/uid=~Bob\""), html);
assertTrue(html.contains(">Bob</a>"), html);
assertTrue(html.contains("href=\"channel://3/Lobby\""), html);
assertTrue(html.contains(">Lobby</a>"), html);
}
@Test
void leavesOtherMarkupLiteral() {
assertEquals("[b]not bold[/b] &amp; &lt;i&gt;", BBCode.linksToHtml("[b]not bold[/b] & <i>"));
}
@Test
void keepsBracketsInsideALabelLiteral() {
String html = BBCode.linksToHtml(TsLink.channelBBCode(3, "[cspacer]---"));
assertTrue(html.contains(">[cspacer]---</a>"), html);
}
@Test
void rejectsHrefsThatAreNotLinks() {
assertEquals("[url=javascript:alert(1)]x[/url]", BBCode.linksToHtml("[url=javascript:alert(1)]x[/url]"));
}
}

View File

@@ -23,6 +23,7 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<ts3j.version>1.0.3</ts3j.version> <ts3j.version>1.0.3</ts3j.version>
<jsvg.version>2.1.0</jsvg.version> <jsvg.version>2.1.0</jsvg.version>
<flatlaf.version>3.7.2</flatlaf.version>
<surefire.version>3.5.6</surefire.version> <surefire.version>3.5.6</surefire.version>
</properties> </properties>
@@ -50,6 +51,11 @@
<artifactId>jsvg</artifactId> <artifactId>jsvg</artifactId>
<version>${jsvg.version}</version> <version>${jsvg.version}</version>
</dependency> </dependency>
<dependency>
<groupId>com.formdev</groupId>
<artifactId>flatlaf</artifactId>
<version>${flatlaf.version}</version>
</dependency>
<dependency> <dependency>
<groupId>com.ts3client</groupId> <groupId>com.ts3client</groupId>
<artifactId>ts3-client-core</artifactId> <artifactId>ts3-client-core</artifactId>

View File

@@ -35,6 +35,10 @@
<groupId>com.github.weisj</groupId> <groupId>com.github.weisj</groupId>
<artifactId>jsvg</artifactId> <artifactId>jsvg</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.formdev</groupId>
<artifactId>flatlaf</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId> <artifactId>junit-jupiter</artifactId>
@@ -84,6 +88,15 @@
<include>**</include> <include>**</include>
</includes> </includes>
</filter> </filter>
<!-- FlatLaf instantiates its UI delegates by name and reads its
themes from .properties resources; neither is reachable
statically, so minimizeJar must not touch it. -->
<filter>
<artifact>com.formdev:flatlaf</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter> <filter>
<artifact>*:*</artifact> <artifact>*:*</artifact>
<excludes> <excludes>

View File

@@ -2,14 +2,14 @@ package com.ts3client;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import com.ts3client.ui.IconTheme; import com.ts3client.ui.IconTheme;
import com.ts3client.ui.LookAndFeelManager;
import com.ts3client.ui.MainFrame; import com.ts3client.ui.MainFrame;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import javax.swing.UIManager;
/** /**
* Application entry point. Loads settings, applies the system look-and-feel and * Application entry point. Loads settings, installs the look-and-feel and shows the
* shows the main window on the Swing event dispatch thread. * main window on the Swing event dispatch thread.
*/ */
public final class Main { public final class Main {
@@ -23,11 +23,7 @@ public final class Main {
final Settings settings = Settings.load(); final Settings settings = Settings.load();
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
try { LookAndFeelManager.install(settings.appearance);
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (Exception ignored) {
// fall back to cross-platform L&F
}
IconTheme.get().reload(settings); IconTheme.get().reload(settings);
new MainFrame(settings).setVisible(true); new MainFrame(settings).setVisible(true);
}); });

View File

@@ -0,0 +1,200 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelSettings;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.ButtonGroup;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSpinner;
import javax.swing.JTextField;
import javax.swing.SpinnerNumberModel;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
/**
* The channel editor's "Advanced" tab: the phonetic name, the delete delay of a temporary
* channel, voice encryption, and the two client limits.
*/
final class ChannelAdvancedPanel extends JPanel implements ChannelDialog.Tab {
/** TeamSpeak's ceiling for {@code channel_delete_delay}, in seconds. */
private static final int MAX_DELETE_DELAY = 604800;
private final JTextField phoneticName = new JTextField();
private final JSpinner deleteDelay =
Spinners.compact(new JSpinner(new SpinnerNumberModel(0, 0, MAX_DELETE_DELAY, 1)));
private final JButton deleteDelayMax = new JButton("max");
private final JCheckBox encrypted = new JCheckBox("Voice Data encrypted");
private final JRadioButton maxUsersUnlimited = new JRadioButton("Unlimited");
private final JRadioButton maxUsersLimited = new JRadioButton("Limited");
private final JSpinner maxUsers = Spinners.compact(new JSpinner(new SpinnerNumberModel(16, 0, 65535, 1)));
private final JRadioButton familyInherited = new JRadioButton("Inherited");
private final JRadioButton familyUnlimited = new JRadioButton("Unlimited");
private final JRadioButton familyLimited = new JRadioButton("Limited");
private final JSpinner familyMaxUsers =
Spinners.compact(new JSpinner(new SpinnerNumberModel(16, 0, 65535, 1)));
ChannelAdvancedPanel() {
setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
phoneticName.setToolTipText("Set phonetic nickname used for this channel by text-to-speech.");
deleteDelay.setToolTipText("<html><p>Delay in seconds after temporary channels are deleted"
+ " after the last user left the channel.</p></html>");
deleteDelayMax.setToolTipText("Set delete delay to maximum value");
deleteDelayMax.addActionListener(a -> deleteDelay.setValue(MAX_DELETE_DELAY));
encrypted.setToolTipText("Voice data in this channel will be encrypted.");
ButtonGroup users = new ButtonGroup();
users.add(maxUsersUnlimited);
users.add(maxUsersLimited);
maxUsersUnlimited.setToolTipText("Unlimited number of clients can join this channel.");
maxUsersLimited.setToolTipText("Limit number of clients in this channel.");
maxUsersUnlimited.addActionListener(a -> syncLimits());
maxUsersLimited.addActionListener(a -> syncLimits());
ButtonGroup family = new ButtonGroup();
family.add(familyInherited);
family.add(familyUnlimited);
family.add(familyLimited);
familyInherited.setToolTipText("Inherit the client limit by parent channel.");
familyUnlimited.setToolTipText("The channel subtree can be joined by unlimited number of clients.");
familyLimited.setToolTipText("Limit the number of clients in the channel subtree.");
for (JRadioButton radio : new JRadioButton[]{familyInherited, familyUnlimited, familyLimited}) {
radio.addActionListener(a -> syncLimits());
}
JPanel other = otherSettings();
other.setAlignmentX(0f);
fixHeight(other);
add(other);
add(Box.createVerticalStrut(6));
JPanel limits = new JPanel();
limits.setLayout(new BoxLayout(limits, BoxLayout.X_AXIS));
limits.add(limitBox("Max Users", new JRadioButton[]{maxUsersUnlimited, maxUsersLimited}, maxUsers));
limits.add(Box.createHorizontalStrut(8));
limits.add(limitBox("Family Max Users",
new JRadioButton[]{familyInherited, familyUnlimited, familyLimited}, familyMaxUsers));
limits.setAlignmentX(0f);
fixHeight(limits);
add(limits);
add(Box.createVerticalGlue());
}
/** The delete delay only means anything for a channel that disappears on its own. */
void setChannelType(ChannelSettings.Type type) {
boolean temporary = type == ChannelSettings.Type.TEMPORARY;
deleteDelay.setEnabled(temporary);
deleteDelayMax.setEnabled(temporary);
}
@Override
public void read(ChannelSettings settings) {
phoneticName.setText(settings.phoneticName);
deleteDelay.setValue(Math.min(MAX_DELETE_DELAY, Math.max(0, settings.deleteDelay)));
encrypted.setSelected(settings.encrypted);
if (settings.maxClientsUnlimited) maxUsersUnlimited.setSelected(true);
else maxUsersLimited.setSelected(true);
maxUsers.setValue(settings.maxClients);
if (settings.familyInherited) familyInherited.setSelected(true);
else if (settings.familyUnlimited) familyUnlimited.setSelected(true);
else familyLimited.setSelected(true);
familyMaxUsers.setValue(settings.maxFamilyClients);
setChannelType(settings.type);
syncLimits();
}
@Override
public void write(ChannelSettings settings) {
settings.phoneticName = phoneticName.getText();
settings.deleteDelay = (Integer) deleteDelay.getValue();
settings.encrypted = encrypted.isSelected();
settings.maxClientsUnlimited = maxUsersUnlimited.isSelected();
settings.maxClients = (Integer) maxUsers.getValue();
settings.familyInherited = familyInherited.isSelected();
settings.familyUnlimited = familyUnlimited.isSelected();
settings.maxFamilyClients = (Integer) familyMaxUsers.getValue();
}
private void syncLimits() {
maxUsers.setEnabled(maxUsersLimited.isSelected());
familyMaxUsers.setEnabled(familyLimited.isSelected());
}
private JPanel otherSettings() {
JPanel panel = new JPanel(new GridBagLayout());
panel.setBorder(BorderFactory.createTitledBorder("Other Settings"));
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(3, 4, 3, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
c.gridx = 0;
c.gridy = 0;
panel.add(new JLabel("Phonetic Name:"), c);
c.gridx = 1;
c.weightx = 1;
panel.add(phoneticName, c);
c.gridx = 0;
c.gridy = 1;
c.weightx = 0;
panel.add(new JLabel("Delete delay:"), c);
JPanel delay = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
delay.add(deleteDelay);
delay.add(deleteDelayMax);
c.gridx = 1;
c.weightx = 1;
panel.add(delay, c);
c.gridx = 1;
c.gridy = 2;
panel.add(encrypted, c);
return panel;
}
/**
* A limit box: its choices stacked, with the count beside the last of them —
* "Limited", the only choice it belongs to.
*/
private JPanel limitBox(String title, JRadioButton[] choices, JSpinner spinner) {
JPanel box = new JPanel();
box.setLayout(new BoxLayout(box, BoxLayout.Y_AXIS));
box.setBorder(BorderFactory.createTitledBorder(title));
for (int i = 0; i < choices.length; i++) {
JRadioButton radio = choices[i];
radio.setAlignmentX(0f);
if (i < choices.length - 1) {
box.add(radio);
continue;
}
JPanel row = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0));
row.setAlignmentX(0f);
row.add(radio);
row.add(spinner);
box.add(row);
}
box.add(Box.createVerticalGlue());
return box;
}
/** Keeps a section at its natural height, so the free space collects at the bottom. */
private static void fixHeight(JPanel panel) {
panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, panel.getPreferredSize().height));
}
}

View File

@@ -0,0 +1,166 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelSettings;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.ButtonGroup;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSlider;
import java.awt.BorderLayout;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
/**
* The channel editor's "Audio" tab: the codec preset, or the codec and quality picked by
* hand, with the bandwidth one talking client then costs.
*/
final class ChannelAudioPanel extends JPanel implements ChannelDialog.Tab {
/** Codec names in TeamSpeak's own id order, which is what the combo box index is. */
private static final String[] CODEC_NAMES = {
"Speex Narrowband", "Speex Wideband", "Speex Ultra-Wideband",
"CELT Mono", "Opus Voice", "Opus Music"};
/**
* Bandwidth per talking client in KiB/s at quality 0 and 10, per codec, as published by
* TeamSpeak. The steps in between are interpolated: the client shows a figure per
* quality step, and only the two ends of each codec's range are documented.
*/
private static final double[][] BANDWIDTH_RANGE = {
{2.49, 5.22}, {2.69, 7.37}, {2.73, 7.57}, {6.10, 13.92}, {2.73, 7.71}, {3.08, 11.87}};
private static final int CODEC_OPUS_VOICE = 4;
private static final int CODEC_OPUS_MUSIC = 5;
private final JRadioButton voiceMobile = new JRadioButton("Voice Mobile");
private final JRadioButton voiceDesktop = new JRadioButton("Voice Desktop");
private final JRadioButton music = new JRadioButton("Music");
private final JRadioButton custom = new JRadioButton("Custom");
private final JComboBox<String> codec = new JComboBox<>(CODEC_NAMES);
private final JSlider quality = new JSlider(0, 10, 6);
private final JLabel qualityValue = new JLabel("6");
private final JLabel bandwidth = new JLabel();
private final JPanel customSettings = new JPanel(new GridBagLayout());
ChannelAudioPanel() {
super(new BorderLayout(8, 8));
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
ButtonGroup presets = new ButtonGroup();
presets.add(voiceMobile);
presets.add(voiceDesktop);
presets.add(music);
presets.add(custom);
voiceMobile.addActionListener(a -> applyPreset(CODEC_OPUS_VOICE, 4));
voiceDesktop.addActionListener(a -> applyPreset(CODEC_OPUS_VOICE, 6));
music.addActionListener(a -> applyPreset(CODEC_OPUS_MUSIC, 6));
custom.addActionListener(a -> setCustomEnabled(true));
codec.addActionListener(a -> updateBandwidth());
quality.setMajorTickSpacing(1);
quality.setPaintTicks(true);
quality.setSnapToTicks(true);
quality.addChangeListener(e -> updateBandwidth());
add(presetBox(), BorderLayout.WEST);
add(customBox(), BorderLayout.CENTER);
add(bandwidthRow(), BorderLayout.SOUTH);
}
@Override
public void read(ChannelSettings settings) {
codec.setSelectedIndex(Math.max(0, Math.min(CODEC_NAMES.length - 1, settings.codec)));
quality.setValue(Math.max(0, Math.min(10, settings.codecQuality)));
if (settings.codec == CODEC_OPUS_VOICE && settings.codecQuality == 4) voiceMobile.setSelected(true);
else if (settings.codec == CODEC_OPUS_VOICE && settings.codecQuality == 6) voiceDesktop.setSelected(true);
else if (settings.codec == CODEC_OPUS_MUSIC && settings.codecQuality == 6) music.setSelected(true);
else custom.setSelected(true);
setCustomEnabled(custom.isSelected());
updateBandwidth();
}
@Override
public void write(ChannelSettings settings) {
settings.codec = codec.getSelectedIndex();
settings.codecQuality = quality.getValue();
}
private void applyPreset(int codecId, int qualityValue) {
codec.setSelectedIndex(codecId);
quality.setValue(qualityValue);
setCustomEnabled(false);
updateBandwidth();
}
private void setCustomEnabled(boolean enabled) {
customSettings.setEnabled(enabled);
for (java.awt.Component child : customSettings.getComponents()) child.setEnabled(enabled);
}
private void updateBandwidth() {
qualityValue.setText(Integer.toString(quality.getValue()));
int index = Math.max(0, Math.min(BANDWIDTH_RANGE.length - 1, codec.getSelectedIndex()));
double[] range = BANDWIDTH_RANGE[index];
double value = range[0] + (range[1] - range[0]) * quality.getValue() / 10.0;
bandwidth.setText(String.format("%.2f KiB/s", value));
}
private JPanel presetBox() {
JPanel box = new JPanel();
box.setLayout(new BoxLayout(box, BoxLayout.Y_AXIS));
box.setBorder(BorderFactory.createTitledBorder("Presets"));
for (JRadioButton radio : new JRadioButton[]{voiceMobile, voiceDesktop, music, custom}) {
radio.setAlignmentX(0f);
box.add(radio);
}
box.add(Box.createVerticalGlue());
return box;
}
private JPanel customBox() {
customSettings.setBorder(BorderFactory.createTitledBorder("Custom Settings"));
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(4, 4, 4, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
c.gridx = 0;
c.gridy = 0;
customSettings.add(new JLabel("Codec:"), c);
c.gridx = 1;
c.weightx = 1;
c.gridwidth = 2;
customSettings.add(codec, c);
c.gridwidth = 1;
c.gridx = 0;
c.gridy = 1;
c.weightx = 0;
customSettings.add(new JLabel("Quality:"), c);
c.gridx = 1;
c.weightx = 1;
customSettings.add(quality, c);
c.gridx = 2;
c.weightx = 0;
customSettings.add(qualityValue, c);
c.gridx = 0;
c.gridy = 2;
c.weighty = 1;
customSettings.add(Box.createVerticalGlue(), c);
return customSettings;
}
private JPanel bandwidthRow() {
JPanel row = new JPanel(new BorderLayout(6, 0));
row.add(new JLabel("Bandwidth usage:"), BorderLayout.WEST);
row.add(bandwidth, BorderLayout.CENTER);
return row;
}
}

View File

@@ -0,0 +1,433 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ChannelSettings;
import com.ts3client.net.TeamspeakConnection;
import javax.swing.BorderFactory;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPasswordField;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JTabbedPane;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Window;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.List;
import java.util.Map;
/**
* TeamSpeak's channel editor: the channel's name, icon, password, topic and description
* above four tabs holding everything else. The official client uses the same dialog to
* create a channel and to edit one, and so does this.
*
* <p>When editing, the channel's properties are not in the tree model — the tree only
* carries what the server pushes for display — so the dialog opens empty and fills itself
* from a {@code channelinfo}, with its own permissions arriving separately. Saving sends
* only what was actually changed, because a {@code channeledit} carrying one property the
* client may not modify is refused as a whole.
*
* <p>When creating, there is nothing to read: the dialog starts from the defaults its
* caller chose and sends a {@code channelcreate} carrying whatever deviates from a plain
* new channel, for the same reason.
*/
final class ChannelDialog extends JDialog {
/** A tab that maps a part of the channel's settings onto its controls. */
interface Tab {
void read(ChannelSettings settings);
void write(ChannelSettings settings);
}
/** How long, and how often, to keep looking for a channel icon that is still downloading. */
private static final int ICON_RETRY_MS = 500;
private static final int ICON_RETRIES = 20;
/** What the password field shows for an existing password, which is never readable. */
private static final String PASSWORD_PLACEHOLDER = "••••••••";
private static final String NAME_TOOLTIP = "<html>Name of this channel displayed in the tree."
+ "<table>"
+ "<tr><td style='white-space:nowrap'>Syntax: \"<strong>[?Spacer#]Text</strong>\"</td></tr>"
+ "<tr><td style='white-space:nowrap'>Where \"?\" stands for an alignment"
+ " (r=right, c=center, l=left),</td></tr>"
+ "<tr><td style='white-space:nowrap'>\"*\" will repeat the text to fill the whole line.</td></tr>"
+ "<tr><td style='white-space:nowrap'>Change \"#\" to get a unique channel name.</td></tr>"
+ "<tr><td style='white-space:nowrap'>[lSpacer0] a left aligned text</td></tr>"
+ "<tr><td style='white-space:nowrap'>[cSpacer1] a centered text</td></tr>"
+ "<tr><td style='white-space:nowrap'>[rSpacer2] a right aligned text</td></tr>"
+ "<tr><td style='white-space:nowrap'>Use one of the three-character-blocks as text for a"
+ " special spacer: \"---\", \"...\", \"-.-\", \"___\", \"-..\"</td></tr>"
+ "</table></html>";
private final TeamspeakConnection conn;
private final GroupIcons groupIcons;
/** The channel being edited, or {@code null} when a new one is being created. */
private final ChannelNode channel;
/** Where a new channel goes; 0 is the top level. Unused when editing. */
private final int parentId;
private final JTextField name = new JTextField();
private final JPasswordField password = new JPasswordField();
private final JTextField topic = new JTextField();
private final JTextArea description = new JTextArea(5, 40);
private final JButton iconButton = new JButton();
private final JButton ok = new JButton("OK");
private final ChannelStandardPanel standardPanel;
private final ChannelAudioPanel audioPanel = new ChannelAudioPanel();
private final ChannelPermissionsPanel permissionsPanel = new ChannelPermissionsPanel();
private final ChannelAdvancedPanel advancedPanel = new ChannelAdvancedPanel();
/** The tabs backed by {@link ChannelSettings}; the permissions tab has its own source. */
private final List<Tab> settingsTabs;
/** The channel as the server last described it; the baseline every change is measured against. */
private ChannelSettings original;
private long iconId;
private boolean passwordEdited;
/** The editor for an existing channel. */
static ChannelDialog toEdit(Window owner, TeamspeakConnection conn, GroupIcons groupIcons,
ChannelNode channel) {
return new ChannelDialog(owner, conn, groupIcons, "Edit Channel: " + channel.name,
channel, channel.parentId, null);
}
/**
* The editor for a channel that does not exist yet.
*
* @param parentId the channel it goes below, 0 being the top level
* @param defaults what the dialog opens with, e.g. the channel type its caller prefers
*/
static ChannelDialog toCreate(Window owner, TeamspeakConnection conn, GroupIcons groupIcons,
String title, int parentId, ChannelSettings defaults) {
return new ChannelDialog(owner, conn, groupIcons, title, null, parentId, defaults);
}
private ChannelDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, String title,
ChannelNode channel, int parentId, ChannelSettings defaults) {
super(owner, title, ModalityType.APPLICATION_MODAL);
this.conn = conn;
this.groupIcons = groupIcons;
this.channel = channel;
this.parentId = parentId;
this.standardPanel = new ChannelStandardPanel(channel != null
? conn.getModel().siblingsOf(channel.id) : conn.getModel().childrenOf(parentId));
standardPanel.onTypeChanged(advancedPanel::setChannelType);
settingsTabs = List.of(standardPanel, audioPanel, advancedPanel);
JTabbedPane tabs = new JTabbedPane();
tabs.addTab("Standard", standardPanel);
tabs.addTab("Audio", audioPanel);
tabs.addTab("Permissions", permissionsPanel);
tabs.addTab("Advanced", advancedPanel);
// The tabs need no more than their natural height, so they sit at the bottom
// with the buttons and every spare pixel goes to the description instead.
JPanel bottom = new JPanel(new BorderLayout(0, 6));
bottom.add(tabs, BorderLayout.CENTER);
bottom.add(buttons(), BorderLayout.SOUTH);
getContentPane().setLayout(new BorderLayout(0, 6));
getContentPane().add(header(), BorderLayout.CENTER);
getContentPane().add(bottom, BorderLayout.SOUTH);
setEnabledForLoading(false);
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(new Dimension(560, 620));
setMinimumSize(new Dimension(480, 520));
setLocationRelativeTo(owner);
if (channel != null) load();
else prepareNew(defaults);
}
// ---- layout ----
private JPanel header() {
JPanel panel = new JPanel(new GridBagLayout());
panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 0, 8));
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(3, 4, 3, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
name.setToolTipText(NAME_TOOLTIP);
password.setToolTipText("Optional password for this channel.");
topic.setToolTipText("Optional topic for this channel, displayed in the info area on the right.");
password.getDocument().addDocumentListener(new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) {
passwordEdited = true;
}
@Override
public void removeUpdate(DocumentEvent e) {
passwordEdited = true;
}
@Override
public void changedUpdate(DocumentEvent e) {
passwordEdited = true;
}
});
c.gridx = 0;
c.gridy = 0;
panel.add(new JLabel("Name:"), c);
c.gridx = 1;
c.weightx = 1;
panel.add(name, c);
c.gridx = 2;
c.weightx = 0;
panel.add(iconField(), c);
c.gridx = 0;
c.gridy = 1;
panel.add(new JLabel("Password:"), c);
c.gridx = 1;
c.gridwidth = 2;
c.weightx = 1;
panel.add(password, c);
c.gridwidth = 1;
c.gridx = 0;
c.gridy = 2;
c.weightx = 0;
panel.add(new JLabel("Topic:"), c);
c.gridx = 1;
c.gridwidth = 2;
c.weightx = 1;
panel.add(topic, c);
c.gridwidth = 1;
c.gridx = 0;
c.gridy = 3;
c.weightx = 0;
panel.add(new JLabel("Description:"), c);
JButton popOut = new JButton("Edit…");
popOut.setToolTipText("Tear off description editor");
popOut.addActionListener(a -> editDescription());
c.gridx = 1;
c.gridwidth = 2;
c.fill = GridBagConstraints.NONE;
panel.add(popOut, c);
description.setLineWrap(true);
description.setWrapStyleWord(true);
c.gridx = 0;
c.gridy = 4;
c.gridwidth = 3;
c.weightx = 1;
c.weighty = 1;
c.fill = GridBagConstraints.BOTH;
panel.add(new JScrollPane(description), c);
return panel;
}
private JPanel iconField() {
iconButton.setToolTipText("Set channel icon.");
iconButton.setPreferredSize(new Dimension(26, 24));
iconButton.setMargin(new Insets(1, 1, 1, 1));
iconButton.addActionListener(a -> chooseIcon());
iconButton.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
if (e.isPopupTrigger()) showIconMenu(e);
}
@Override
public void mouseReleased(MouseEvent e) {
if (e.isPopupTrigger()) showIconMenu(e);
}
});
JPanel row = new JPanel(new BorderLayout(4, 0));
row.add(new JLabel("Icon:"), BorderLayout.WEST);
row.add(iconButton, BorderLayout.CENTER);
return row;
}
private JPanel buttons() {
JButton cancel = new JButton("Cancel");
ok.addActionListener(a -> save());
cancel.addActionListener(a -> dispose());
JPanel panel = new JPanel(new BorderLayout());
panel.setBorder(BorderFactory.createEmptyBorder(0, 8, 8, 8));
JPanel right = new JPanel();
right.add(ok);
right.add(cancel);
panel.add(right, BorderLayout.EAST);
getRootPane().setDefaultButton(ok);
return panel;
}
// ---- loading ----
private void load() {
conn.requestChannelSettings(channel.id, (settings, error) -> SwingUtilities.invokeLater(() -> {
if (error != null) {
JOptionPane.showMessageDialog(this, "Could not read the channel: " + error,
"Error", JOptionPane.ERROR_MESSAGE);
dispose();
return;
}
apply(settings);
}));
conn.requestChannelPermissions(channel.id, (permissions, error) -> SwingUtilities.invokeLater(() -> {
if (error != null) permissionsPanel.showUnavailable("No permission to view channel permissions.");
else permissionsPanel.read(permissions);
}));
}
/**
* Opens the dialog on a channel that does not exist yet. Its permissions start empty
* and editable — they are applied to the channel once the server has created it.
*/
private void prepareNew(ChannelSettings defaults) {
permissionsPanel.read(Map.of());
apply(defaults);
name.selectAll();
}
private void apply(ChannelSettings settings) {
original = settings;
name.setText(settings.name);
topic.setText(settings.topic);
description.setText(settings.description);
description.setCaretPosition(0);
if (settings.hasPassword) password.setText(PASSWORD_PLACEHOLDER);
passwordEdited = false;
setIconId(settings.iconId);
for (Tab tab : settingsTabs) tab.read(settings);
setEnabledForLoading(true);
name.requestFocusInWindow();
}
private void setEnabledForLoading(boolean loaded) {
ok.setEnabled(loaded);
name.setEnabled(loaded);
password.setEnabled(loaded);
topic.setEnabled(loaded);
description.setEnabled(loaded);
iconButton.setEnabled(loaded);
}
// ---- icon ----
private void setIconId(long id) {
setIconId(id, ICON_RETRIES);
}
/**
* @param retries how many more times to look for an icon that is still downloading;
* one that never arrives (deleted, or not ours to read) simply stays blank
*/
private void setIconId(long id, int retries) {
iconId = id;
ImageIcon icon = groupIcons.icon(id);
iconButton.setIcon(icon);
if (icon != null || id == 0 || retries <= 0) return;
Timer retry = new Timer(ICON_RETRY_MS, null);
retry.setRepeats(false);
retry.addActionListener(e -> {
if (iconId == id) setIconId(id, retries - 1);
});
retry.start();
}
private void chooseIcon() {
IconChooserDialog chooser = new IconChooserDialog(this, conn, groupIcons);
chooser.setVisible(true);
if (chooser.isAccepted()) setIconId(chooser.getIconId());
}
private void showIconMenu(MouseEvent e) {
JPopupMenu menu = new JPopupMenu();
JMenuItem edit = new JMenuItem("Edit Icon");
edit.addActionListener(a -> chooseIcon());
JMenuItem remove = new JMenuItem("Remove Icon");
remove.setEnabled(iconId != 0);
remove.addActionListener(a -> setIconId(0));
menu.add(edit);
menu.add(remove);
menu.show(e.getComponent(), e.getX(), e.getY());
}
// ---- description ----
private void editDescription() {
DescriptionEditorDialog editor = new DescriptionEditorDialog(this, description.getText());
editor.setVisible(true);
if (editor.isAccepted()) description.setText(editor.getDescription());
}
// ---- saving ----
private void save() {
if (original == null) return;
ChannelSettings edited = original.copy();
edited.name = name.getText().trim();
edited.topic = topic.getText();
edited.description = description.getText();
edited.password = passwordEdited ? new String(password.getPassword()) : null;
edited.iconId = iconId;
for (Tab tab : settingsTabs) tab.write(edited);
if (edited.name.isEmpty()) {
JOptionPane.showMessageDialog(this, "The channel needs a name.",
"Error", JOptionPane.ERROR_MESSAGE);
return;
}
if (channel == null) {
ok.setEnabled(false);
conn.createChannel(parentId, edited.creationParameters(), permissionsPanel.changed(),
(channelId, error) -> SwingUtilities.invokeLater(
() -> finish(error, "Could not create the channel: ")));
return;
}
Map<String, String> changes = edited.changesFrom(original);
Map<String, Integer> setPermissions = permissionsPanel.changed();
List<String> clearedPermissions = permissionsPanel.cleared();
if (changes.isEmpty() && setPermissions.isEmpty() && clearedPermissions.isEmpty()) {
dispose();
return;
}
ok.setEnabled(false);
conn.applyChannelEdit(channel.id, changes, setPermissions, clearedPermissions,
error -> SwingUtilities.invokeLater(() -> finish(error, "Could not save the channel: ")));
}
/** Closes the dialog, or reports why the server would not have it and lets the user retry. */
private void finish(String error, String errorPrefix) {
if (error == null) {
dispose();
return;
}
ok.setEnabled(true);
JOptionPane.showMessageDialog(this, errorPrefix + error, "Error", JOptionPane.ERROR_MESSAGE);
}
}

View File

@@ -1,6 +1,7 @@
package com.ts3client.ui; package com.ts3client.ui;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.ChannelSettings;
import javax.swing.JMenuItem; import javax.swing.JMenuItem;
import javax.swing.JPopupMenu; import javax.swing.JPopupMenu;
@@ -20,6 +21,18 @@ final class ChannelMenu {
join.addActionListener(a -> actions.joinChannel(channel.id)); join.addActionListener(a -> actions.joinChannel(channel.id));
menu.add(join); menu.add(join);
menu.addSeparator(); menu.addSeparator();
// Both start out temporary: a channel made from another channel's menu is
// usually a throwaway one, and a permanent channel is a deliberate choice.
JMenuItem create = new JMenuItem("Create Channel", Icons.of("CHANNEL_CREATE"));
create.addActionListener(a -> actions.createChannel(null, ChannelSettings.Type.TEMPORARY));
menu.add(create);
JMenuItem createSub = new JMenuItem("Create Sub-Channel", Icons.of("CHANNEL_CREATE_SUB"));
createSub.addActionListener(a -> actions.createChannel(channel, ChannelSettings.Type.TEMPORARY));
menu.add(createSub);
JMenuItem edit = new JMenuItem("Edit Channel", Icons.of("CHANNEL_EDIT"));
edit.addActionListener(a -> actions.editChannel(channel));
menu.add(edit);
menu.addSeparator();
addSubscriptionItems(menu, channel, actions); addSubscriptionItems(menu, channel, actions);
JMenuItem files = new JMenuItem("Browse files", Icons.of("FILETRANSFER")); JMenuItem files = new JMenuItem("Browse files", Icons.of("FILETRANSFER"));
files.addActionListener(a -> actions.browseFiles(channel)); files.addActionListener(a -> actions.browseFiles(channel));

View File

@@ -0,0 +1,148 @@
package com.ts3client.ui;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.SpinnerNumberModel;
import java.awt.Color;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* The channel editor's "Permissions" tab: the needed-power permissions set on the channel
* itself, which is the subset of channel permissions TeamSpeak surfaces here.
*
* <p>These are not channel properties but permissions, so they are read with
* {@code channelpermlist} and written with {@code channeladdperm}. A power left at zero is
* not a permission the channel carries: clearing a spinner back to zero removes the
* permission again so the channel inherits it, which is how the official client behaves.
*/
final class ChannelPermissionsPanel extends JPanel {
/** The regular powers, in the order the official dialog lists them. */
private static final String[][] REGULAR = {
{"i_channel_needed_join_power", "Join:"},
{"i_channel_needed_subscribe_power", "Subscribe:"},
{"i_channel_needed_description_view_power", "Desc. View:"},
{"i_channel_needed_modify_power", "Modify:"},
{"i_channel_needed_delete_power", "Delete:"}};
private static final String[][] FILE_TRANSFER = {
{"i_ft_needed_file_browse_power", "Browse:"},
{"i_ft_needed_file_upload_power", "Upload:"},
{"i_ft_needed_file_download_power", "Download:"},
{"i_ft_needed_file_rename_power", "Rename:"},
{"i_ft_needed_directory_create_power", "Dir. Create:"}};
private final Map<String, JSpinner> spinners = new LinkedHashMap<>();
/** The values the server reported, to tell an edited power from an untouched one. */
private final Map<String, Integer> original = new LinkedHashMap<>();
private final JLabel status = new JLabel(" ");
ChannelPermissionsPanel() {
setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
JPanel groups = new JPanel();
groups.setLayout(new BoxLayout(groups, BoxLayout.X_AXIS));
groups.add(group("Regular Needed Powers", REGULAR));
groups.add(Box.createHorizontalStrut(8));
groups.add(group("File Transfer Needed Powers", FILE_TRANSFER));
groups.setAlignmentX(0f);
add(groups);
status.setAlignmentX(0f);
status.setBorder(BorderFactory.createEmptyBorder(6, 2, 0, 2));
add(status);
add(Box.createVerticalGlue());
setEditable(false);
}
/** Fills the spinners from a {@code channelpermlist} result and enables editing. */
void read(Map<String, Integer> permissions) {
original.clear();
for (Map.Entry<String, JSpinner> entry : spinners.entrySet()) {
int value = permissions.getOrDefault(entry.getKey(), 0);
original.put(entry.getKey(), value);
entry.getValue().setValue(value);
}
setEditable(true);
setStatus(" ", false);
}
/** Greys the tab out with a reason, e.g. when the server refuses to show the permissions. */
void showUnavailable(String message) {
setEditable(false);
setStatus(message, true);
}
/** The permissions whose power was changed to a non-zero value. */
Map<String, Integer> changed() {
Map<String, Integer> changed = new LinkedHashMap<>();
for (Map.Entry<String, JSpinner> entry : spinners.entrySet()) {
int value = (Integer) entry.getValue().getValue();
if (value != 0 && value != original.getOrDefault(entry.getKey(), 0)) {
changed.put(entry.getKey(), value);
}
}
return changed;
}
/** The permissions cleared back to zero, which are removed from the channel. */
List<String> cleared() {
List<String> cleared = new ArrayList<>();
for (Map.Entry<String, JSpinner> entry : spinners.entrySet()) {
int value = (Integer) entry.getValue().getValue();
if (value == 0 && original.getOrDefault(entry.getKey(), 0) != 0) cleared.add(entry.getKey());
}
return cleared;
}
void setStatus(String message, boolean error) {
status.setText(message == null || message.isEmpty() ? " " : message);
status.setForeground(error ? Color.RED.darker() : Theme.chatSystem());
}
private void setEditable(boolean editable) {
for (JSpinner spinner : spinners.values()) spinner.setEnabled(editable);
}
private JPanel group(String title, String[][] permissions) {
JPanel panel = new JPanel(new GridBagLayout());
panel.setBorder(BorderFactory.createTitledBorder(title));
GridBagConstraints c = new GridBagConstraints();
c.insets = new Insets(3, 4, 3, 4);
c.anchor = GridBagConstraints.WEST;
c.fill = GridBagConstraints.HORIZONTAL;
int row = 0;
for (String[] permission : permissions) {
JSpinner spinner = Spinners.compact(new JSpinner(new SpinnerNumberModel(0, 0, 9999, 1)));
spinners.put(permission[0], spinner);
c.gridx = 0;
c.gridy = row;
c.weightx = 0;
panel.add(new JLabel(permission[1]), c);
c.gridx = 1;
c.weightx = 1;
c.fill = GridBagConstraints.NONE;
panel.add(spinner, c);
c.fill = GridBagConstraints.HORIZONTAL;
row++;
}
// Keeps the rows at the top of the group instead of centred in whatever height
// the tab happens to give it.
c.gridx = 0;
c.gridy = row;
c.weighty = 1;
panel.add(Box.createGlue(), c);
return panel;
}
}

View File

@@ -0,0 +1,195 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ChannelSettings;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.ButtonGroup;
import javax.swing.DefaultComboBoxModel;
import javax.swing.JCheckBox;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JRadioButton;
import javax.swing.JSeparator;
import javax.swing.JSpinner;
import javax.swing.SpinnerNumberModel;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.util.List;
import java.util.function.Consumer;
/**
* The channel editor's "Standard" tab: how long the channel lives, whether it is the
* server's default one, where it sorts among its siblings, and its moderation setting.
*/
final class ChannelStandardPanel extends JPanel implements ChannelDialog.Tab {
private final JRadioButton temporary = new JRadioButton("Temporary");
private final JRadioButton semiPermanent = new JRadioButton("Semi-Permanent");
private final JRadioButton permanent = new JRadioButton("Permanent");
private final JCheckBox defaultChannel = new JCheckBox("Default Channel");
private final JComboBox<SortEntry> sortAfter = new JComboBox<>();
private final JSpinner talkPower = new JSpinner(new SpinnerNumberModel(0, 0, 9999, 1));
private Consumer<ChannelSettings.Type> typeListener;
/**
* @param siblings the channels this one shares a parent with, in tree order and
* excluding the channel being edited
*/
ChannelStandardPanel(List<ChannelNode> siblings) {
super(new BorderLayout(8, 8));
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
temporary.setToolTipText("Channel will be deleted when the last user left.");
semiPermanent.setToolTipText("Channel exists until server is restarted.");
permanent.setToolTipText("Channel will exist until manually deleted.");
ButtonGroup types = new ButtonGroup();
types.add(temporary);
types.add(semiPermanent);
types.add(permanent);
for (JRadioButton radio : new JRadioButton[]{temporary, semiPermanent, permanent}) {
radio.addActionListener(a -> fireTypeChanged());
}
defaultChannel.setToolTipText("<html>The default channel is the place where new clients join on login."
+ "<br>There can be only one default channel for the whole server.</html>");
DefaultComboBoxModel<SortEntry> order = new DefaultComboBoxModel<>();
order.addElement(new SortEntry(0, "(first)"));
for (ChannelNode sibling : siblings) order.addElement(new SortEntry(sibling.id, sibling.name));
sortAfter.setModel(order);
sortAfter.setToolTipText("Channel will be sorted below this channel.");
sortAfter.setPreferredSize(new Dimension(200, sortAfter.getPreferredSize().height));
Spinners.compact(talkPower);
add(typeBox(), BorderLayout.WEST);
add(rightColumn(), BorderLayout.CENTER);
}
/** Notified whenever the channel type changes, so the delete delay can follow it. */
void onTypeChanged(Consumer<ChannelSettings.Type> listener) {
this.typeListener = listener;
}
@Override
public void read(ChannelSettings settings) {
switch (settings.type) {
case TEMPORARY:
temporary.setSelected(true);
break;
case SEMI_PERMANENT:
semiPermanent.setSelected(true);
break;
default:
permanent.setSelected(true);
break;
}
defaultChannel.setSelected(settings.defaultChannel);
// The default channel cannot simply stop being one; another has to take over.
defaultChannel.setEnabled(!settings.defaultChannel);
select(settings.order);
talkPower.setValue(settings.neededTalkPower);
fireTypeChanged();
}
@Override
public void write(ChannelSettings settings) {
settings.type = selectedType();
settings.defaultChannel = defaultChannel.isSelected();
SortEntry entry = (SortEntry) sortAfter.getSelectedItem();
settings.order = entry == null ? 0 : entry.channelId;
settings.neededTalkPower = (Integer) talkPower.getValue();
}
private ChannelSettings.Type selectedType() {
if (temporary.isSelected()) return ChannelSettings.Type.TEMPORARY;
if (semiPermanent.isSelected()) return ChannelSettings.Type.SEMI_PERMANENT;
return ChannelSettings.Type.PERMANENT;
}
private void fireTypeChanged() {
if (typeListener != null) typeListener.accept(selectedType());
}
private void select(int channelId) {
for (int i = 0; i < sortAfter.getItemCount(); i++) {
if (sortAfter.getItemAt(i).channelId == channelId) {
sortAfter.setSelectedIndex(i);
return;
}
}
sortAfter.setSelectedIndex(0);
}
private JPanel typeBox() {
JPanel box = new JPanel();
box.setLayout(new BoxLayout(box, BoxLayout.Y_AXIS));
box.setBorder(BorderFactory.createTitledBorder("Channel Type"));
for (JRadioButton radio : new JRadioButton[]{temporary, semiPermanent, permanent}) {
radio.setAlignmentX(0f);
box.add(radio);
}
JSeparator line = new JSeparator();
line.setAlignmentX(0f);
line.setMaximumSize(new Dimension(Integer.MAX_VALUE, 8));
box.add(Box.createVerticalStrut(4));
box.add(line);
box.add(Box.createVerticalStrut(4));
defaultChannel.setAlignmentX(0f);
box.add(defaultChannel);
box.add(Box.createVerticalGlue());
return box;
}
private JPanel rightColumn() {
JPanel sort = new JPanel(new BorderLayout());
sort.setBorder(BorderFactory.createTitledBorder("Sort This Channel After:"));
sort.add(sortAfter, BorderLayout.CENTER);
JPanel moderation = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2));
moderation.setBorder(BorderFactory.createTitledBorder("Moderation"));
JLabel label = new JLabel("Needed Talk Power:");
label.setToolTipText("Talk Power required to speak in this channel.");
moderation.add(label);
moderation.add(talkPower);
// Both groups keep their natural height; the filler below soaks up the rest, so
// they sit at the top instead of stretching over the whole tab.
JPanel column = new JPanel(new GridBagLayout());
GridBagConstraints c = new GridBagConstraints();
c.gridx = 0;
c.fill = GridBagConstraints.HORIZONTAL;
c.weightx = 1;
c.insets = new Insets(0, 0, 6, 0);
column.add(sort, c);
column.add(moderation, c);
c.weighty = 1;
c.fill = GridBagConstraints.BOTH;
column.add(Box.createGlue(), c);
return column;
}
/** One entry of the "sort after" list: a sibling channel, or the top of the list. */
private static final class SortEntry {
final int channelId;
final String label;
SortEntry(int channelId, String label) {
this.channelId = channelId;
this.label = label;
}
@Override
public String toString() {
return label;
}
}
}

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;
@@ -15,8 +16,6 @@ import javax.swing.SwingUtilities;
import javax.swing.event.HyperlinkEvent; import javax.swing.event.HyperlinkEvent;
import javax.swing.text.BadLocationException; import javax.swing.text.BadLocationException;
import javax.swing.text.html.HTMLDocument; import javax.swing.text.html.HTMLDocument;
import javax.swing.text.html.HTMLEditorKit;
import javax.swing.text.html.StyleSheet;
import java.awt.BorderLayout; import java.awt.BorderLayout;
import java.awt.Component; import java.awt.Component;
import java.awt.Cursor; import java.awt.Cursor;
@@ -25,6 +24,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 +62,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;
@@ -69,7 +73,8 @@ public final class ChatPanel extends JPanel {
public ChatPanel() { public ChatPanel() {
super(new BorderLayout()); super(new BorderLayout());
tabs.setFont(Theme.UI_FONT); tabs.setFont(Theme.uiFont());
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 +82,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 +137,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;
@@ -143,10 +184,20 @@ public final class ChatPanel extends JPanel {
/** System notices always land in the server tab. */ /** System notices always land in the server tab. */
public void appendSystem(String text) { public void appendSystem(String text) {
edt(() -> serverTab.appendLine("<span style=\"color:#8A8A8A\">" + stamp() edt(() -> serverTab.appendLine("<span class=\"muted\">" + stamp()
+ BBCode.escape(text) + "</span>")); + BBCode.escape(text) + "</span>"));
} }
/**
* Server-side events (client joins/leaves/moves, group changes, channel edits, …).
* These lines name clients and channels with TeamSpeak's own links, which stay
* clickable here — everything else in them is literal text.
*/
public void appendServerLog(String text) {
edt(() -> serverTab.appendLine("<span class=\"event\">" + stamp()
+ BBCode.linksToHtml(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 +231,40 @@ public final class ChatPanel extends JPanel {
} }
private void closeTab(Tab tab) { private void closeTab(Tab tab) {
privateTabs.remove(tab.clientId); if (tab.noteKey != null) {
noteTabs.remove(tab.noteKey);
if (tab.onClose != null) tab.onClose.run();
} else {
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;
@@ -205,40 +286,39 @@ public final class ChatPanel extends JPanel {
final Target target; final Target target;
final int clientId; final int clientId;
final String title; final String title;
final JEditorPane log = new JEditorPane(); final JEditorPane log = HtmlStyles.pane("font-family:sans-serif; font-size:11px; margin:4px 6px;");
final JScrollPane scroll; final JScrollPane scroll;
private final HTMLDocument doc; /** 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 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();
StyleSheet css = new StyleSheet();
css.addStyleSheet(kit.getStyleSheet());
css.addRule("body { font-family:sans-serif; font-size:11px; color:#202020; margin:4px 6px; }");
css.addRule("a { color:" + hex(Theme.CHAT_NAME) + "; text-decoration:none; }");
// Client references look exactly like a message author's name.
css.addRule("a." + BBCode.IDENTITY_LINK_CLASS
+ " { color:" + hex(Theme.CHAT_NAME) + "; font-weight:bold; text-decoration:none; }");
// External links are underlined so they can't be mistaken for an identity.
css.addRule("a." + BBCode.EXTERNAL_LINK_CLASS
+ " { color:" + hex(Theme.LINK) + "; text-decoration:underline; }");
kit.setStyleSheet(css);
log.setEditorKit(kit);
log.setEditable(false);
log.setBackground(Theme.CHAT_BG);
log.setText("<html><body><div id=\"chatlog\"></div></body></html>"); log.setText("<html><body><div id=\"chatlog\"></div></body></html>");
doc = (HTMLDocument) log.getDocument();
log.addHyperlinkListener(e -> { log.addHyperlinkListener(e -> {
if (e.getEventType() != HyperlinkEvent.EventType.ACTIVATED) return; if (e.getEventType() != HyperlinkEvent.EventType.ACTIVATED) return;
onLink(e.getDescription()); onLink(e.getDescription());
}); });
scroll = new JScrollPane(log); scroll = new JScrollPane(log);
HtmlStyles.fillViewport(log);
scroll.setBorder(BorderFactory.createEmptyBorder()); scroll.setBorder(BorderFactory.createEmptyBorder());
} }
@@ -259,13 +339,15 @@ 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.uiFont());
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.uiBold());
close.setForeground(Theme.CHAT_SYSTEM); close.setForeground(Theme.chatSystem());
close.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); close.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
close.setToolTipText("Close chat"); close.setToolTipText("Close chat");
close.addMouseListener(new MouseAdapter() { close.addMouseListener(new MouseAdapter() {
@@ -289,8 +371,8 @@ public final class ChatPanel extends JPanel {
void setUnread(boolean unread) { void setUnread(boolean unread) {
if (titleLabel == null) return; if (titleLabel == null) return;
titleLabel.setFont(unread ? Theme.UI_BOLD : Theme.UI_FONT); titleLabel.setFont(unread ? Theme.uiBold() : Theme.uiFont());
titleLabel.setForeground(unread ? Theme.ACCENT : null); titleLabel.setForeground(unread ? Theme.accent() : null);
} }
void appendMessage(int fromId, String from, String text) { void appendMessage(int fromId, String from, String text) {
@@ -298,13 +380,20 @@ public final class ChatPanel extends JPanel {
String sender = fromId > 0 String sender = fromId > 0
? "<a class=\"" + BBCode.IDENTITY_LINK_CLASS + "\" href=\"" ? "<a class=\"" + BBCode.IDENTITY_LINK_CLASS + "\" href=\""
+ BBCode.escape(TsLink.clientHref(fromId, "", from)) + "\">" + name + "</a>" + BBCode.escape(TsLink.clientHref(fromId, "", from)) + "\">" + name + "</a>"
: "<b style=\"color:" + hex(Theme.CHAT_NAME) + "\">" + name + "</b>"; : "<span class=\"name\">" + name + "</span>";
appendLine("<span style=\"color:#8A8A8A\">" + stamp() + "</span>" appendLine("<span class=\"muted\">" + stamp() + "</span>"
+ 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 {
HTMLDocument doc = (HTMLDocument) log.getDocument();
doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>"); doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>");
log.setCaretPosition(doc.getLength()); log.setCaretPosition(doc.getLength());
} catch (BadLocationException | IOException ignored) { } catch (BadLocationException | IOException ignored) {
@@ -312,8 +401,4 @@ public final class ChatPanel extends JPanel {
if (tabs.getSelectedComponent() != scroll) setUnread(true); if (tabs.getSelectedComponent() != scroll) setUnread(true);
} }
} }
private static String hex(java.awt.Color c) {
return String.format("#%06X", c.getRGB() & 0xFFFFFF);
}
} }

View File

@@ -1,9 +1,15 @@
package com.ts3client.ui; package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry; import com.ts3client.net.ClientEntry;
import com.ts3client.net.Group;
import com.ts3client.net.ServerModel;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JMenu;
import javax.swing.JMenuItem; import javax.swing.JMenuItem;
import javax.swing.JPopupMenu; import javax.swing.JPopupMenu;
import java.util.List;
/** /**
* The context menu for a client, shared by the server tree and the clickable * The context menu for a client, shared by the server tree and the clickable
@@ -14,7 +20,13 @@ final class ClientMenu {
private ClientMenu() { private ClientMenu() {
} }
static JPopupMenu build(ClientEntry client, boolean self, ServerTreePanel.Actions actions) { /**
* @param fromTree whether this menu was opened from the server tree itself, as
* opposed to a client link elsewhere (chat log, etc.) — "Find
* Client in Channel Tree" is redundant in the former case.
*/
static JPopupMenu build(ClientEntry client, boolean self, boolean fromTree, ServerModel model,
GroupIcons groupIcons, ServerTreePanel.Actions actions) {
JPopupMenu menu = new JPopupMenu(); JPopupMenu menu = new JPopupMenu();
if (!self) { if (!self) {
JMenuItem pm = new JMenuItem("Open text chat", Icons.of("PLAYER_CHAT")); JMenuItem pm = new JMenuItem("Open text chat", Icons.of("PLAYER_CHAT"));
@@ -44,15 +56,117 @@ final class ClientMenu {
menu.add(me); menu.add(me);
} }
menu.addSeparator(); menu.addSeparator();
menu.add(buildServerGroupMenu(client, model, groupIcons, actions));
menu.add(buildChannelGroupMenu(client, model, groupIcons, actions));
menu.addSeparator();
if (!fromTree) {
JMenuItem findInTree = new JMenuItem("Find Client in Channel Tree", Icons.of("CHANNEL_SWITCH"));
findInTree.setEnabled(isVisible(client, model));
findInTree.addActionListener(a -> actions.findClientInTree(client));
menu.add(findInTree);
}
JMenuItem description = new JMenuItem("Change Description", Icons.of("EDIT"));
description.addActionListener(a -> actions.changeClientDescription(client));
menu.add(description);
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);
if (!self) { if (!self) {
menu.addSeparator(); menu.addSeparator();
JMenuItem joinChannel = new JMenuItem("Join Channel of Client", Icons.of("CHANNEL_SWITCH"));
joinChannel.setEnabled(canJoinChannelOf(client, model));
joinChannel.addActionListener(a -> actions.joinChannel(client.channelId));
menu.add(joinChannel);
JMenuItem moveHere = new JMenuItem("Move Client to own Channel", Icons.of("MOVE_CLIENT_TO_OWN_CHANNEL")); JMenuItem moveHere = new JMenuItem("Move Client to own Channel", Icons.of("MOVE_CLIENT_TO_OWN_CHANNEL"));
moveHere.addActionListener(a -> actions.moveClientToOwnChannel(client)); moveHere.addActionListener(a -> actions.moveClientToOwnChannel(client));
menu.add(moveHere); menu.add(moveHere);
} }
return menu; return menu;
} }
/**
* Whether the client's channel is actually rendered in the tree — it won't be if
* we're not subscribed to it (e.g. it needs more subscribe power than we have).
*/
private static boolean isVisible(ClientEntry client, ServerModel model) {
ChannelNode channel = model.getChannel(client.channelId);
return channel != null && channel.subscribed;
}
/**
* Whether we can expect a join to succeed: the channel must be visible to us, and
* not already full. This can't account for a join-power permission requirement —
* unlike group membership, TS3 doesn't expose a channel's join-power threshold as
* a plain property, only as a permission resolved through channel/channel-group/
* server-group inheritance, which isn't something a regular client can query.
*/
private static boolean canJoinChannelOf(ClientEntry client, ServerModel model) {
ChannelNode channel = model.getChannel(client.channelId);
if (channel == null || !channel.subscribed) return false;
return channel.maxClients < 0 || channel.clients.size() < channel.maxClients;
}
private static JMenu buildServerGroupMenu(ClientEntry client, ServerModel model, GroupIcons groupIcons,
ServerTreePanel.Actions actions) {
JMenu menu = new JMenu("Set Server Groups");
menu.setIcon(Icons.of("PERMISSIONS_SERVER_GROUPS"));
JMenuItem dialog = new JMenuItem("Server Groups Dialog...");
dialog.addActionListener(a -> actions.showServerGroupsDialog(client));
menu.add(dialog);
menu.addSeparator();
int addPower = model.selfPermissionValue("i_group_needed_member_add_power");
int removePower = model.selfPermissionValue("i_group_needed_member_remove_power");
for (Group g : model.allServerGroups()) {
boolean assigned = contains(client.serverGroupIds, g.id);
if (!canAssign(g, assigned, addPower, removePower)) continue;
JCheckBoxMenuItem item = new JCheckBoxMenuItem(g.name, groupIcons.iconOf(g));
item.setSelected(assigned);
item.addActionListener(a -> actions.setClientServerGroup(client, g, !assigned));
menu.add(item);
}
return menu;
}
private static JMenu buildChannelGroupMenu(ClientEntry client, ServerModel model, GroupIcons groupIcons,
ServerTreePanel.Actions actions) {
JMenu menu = new JMenu("Set Channel Group");
menu.setIcon(Icons.of("PERMISSIONS_CHANNEL_GROUPS"));
int addPower = model.selfPermissionValue("i_group_needed_member_add_power");
int removePower = model.selfPermissionValue("i_group_needed_member_remove_power");
int defaultGroupId = model.defaultChannelGroupId();
for (Group g : model.allChannelGroups()) {
if (g.id == defaultGroupId) continue;
boolean assigned = client.channelGroupId == g.id;
if (!canAssign(g, assigned, addPower, removePower)) continue;
JCheckBoxMenuItem item = new JCheckBoxMenuItem(g.name, groupIcons.iconOf(g));
item.setSelected(assigned);
item.addActionListener(a -> actions.setClientChannelGroup(client, g));
menu.add(item);
}
return menu;
}
/**
* Whether the local client has enough power to (un)assign this group: the add
* power always gates the checkbox, and the remove power additionally gates
* unassigning an already-held group. {@code -1} is TS3's "unlimited" sentinel on
* either side: an unlimited local power always passes, and a group that needs
* unlimited power can only be touched by a local client that has it.
*/
static boolean canAssign(Group g, boolean assigned, int addPower, int removePower) {
return hasPower(g.neededMemberAddPower, addPower)
&& (!assigned || hasPower(g.neededMemberRemovePower, removePower));
}
private static boolean hasPower(int needed, int own) {
if (own == -1) return true;
if (needed == -1) return false;
return own >= needed;
}
private static boolean contains(int[] ids, int id) {
for (int i : ids) if (i == id) return true;
return false;
}
} }

View File

@@ -63,7 +63,7 @@ public final class ConnectionInfoDialog extends JDialog {
this.clientId = clientId; this.clientId = clientId;
JPanel content = new JPanel(new BorderLayout(0, 10)); JPanel content = new JPanel(new BorderLayout(0, 10));
content.setBackground(Theme.WINDOW_BG); content.setBackground(Theme.windowBg());
content.setBorder(BorderFactory.createEmptyBorder(12, 14, 12, 14)); content.setBorder(BorderFactory.createEmptyBorder(12, 14, 12, 14));
content.add(buildSummary(), BorderLayout.NORTH); content.add(buildSummary(), BorderLayout.NORTH);
content.add(buildTabs(), BorderLayout.CENTER); content.add(buildTabs(), BorderLayout.CENTER);
@@ -92,7 +92,7 @@ public final class ConnectionInfoDialog extends JDialog {
private JPanel buildSummary() { private JPanel buildSummary() {
JPanel grid = new JPanel(new GridBagLayout()); JPanel grid = new JPanel(new GridBagLayout());
grid.setBackground(Theme.WINDOW_BG); grid.setBackground(Theme.windowBg());
int row = 0; int row = 0;
addRow(grid, row++, "Address", addressValue); addRow(grid, row++, "Address", addressValue);
addRow(grid, row++, "Client version", versionValue); addRow(grid, row++, "Client version", versionValue);
@@ -106,8 +106,8 @@ public final class ConnectionInfoDialog extends JDialog {
private JTabbedPane buildTabs() { private JTabbedPane buildTabs() {
JTabbedPane tabs = new JTabbedPane(); JTabbedPane tabs = new JTabbedPane();
tabs.setFont(Theme.UI_FONT); tabs.setFont(Theme.uiFont());
tabs.setBackground(Theme.WINDOW_BG); tabs.setBackground(Theme.windowBg());
tabs.addTab("Total", totalTab); tabs.addTab("Total", totalTab);
tabs.addTab("Speech", speechTab); tabs.addTab("Speech", speechTab);
tabs.addTab("Keep Alive", keepAliveTab); tabs.addTab("Keep Alive", keepAliveTab);
@@ -118,7 +118,7 @@ public final class ConnectionInfoDialog extends JDialog {
private JPanel buildButtons() { private JPanel buildButtons() {
JPanel bar = new JPanel(); JPanel bar = new JPanel();
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS)); bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
bar.setBackground(Theme.WINDOW_BG); bar.setBackground(Theme.windowBg());
bar.add(Box.createHorizontalGlue()); bar.add(Box.createHorizontalGlue());
JButton close = new JButton("Close"); JButton close = new JButton("Close");
close.addActionListener(e -> dispose()); close.addActionListener(e -> dispose());
@@ -128,8 +128,8 @@ public final class ConnectionInfoDialog extends JDialog {
private static JLabel value() { private static JLabel value() {
JLabel l = new JLabel(""); JLabel l = new JLabel("");
l.setFont(Theme.UI_FONT); l.setFont(Theme.uiFont());
l.setForeground(Theme.TREE_TEXT); l.setForeground(Theme.treeText());
return l; return l;
} }
@@ -140,7 +140,7 @@ public final class ConnectionInfoDialog extends JDialog {
lc.anchor = GridBagConstraints.WEST; lc.anchor = GridBagConstraints.WEST;
lc.insets = new Insets(2, 0, 2, 16); lc.insets = new Insets(2, 0, 2, 16);
JLabel key = new JLabel(label); JLabel key = new JLabel(label);
key.setFont(Theme.UI_FONT); key.setFont(Theme.uiFont());
key.setForeground(new Color(0x5A6B7B)); key.setForeground(new Color(0x5A6B7B));
grid.add(key, lc); grid.add(key, lc);
@@ -255,7 +255,7 @@ public final class ConnectionInfoDialog extends JDialog {
KindTab() { KindTab() {
super(new GridBagLayout()); super(new GridBagLayout());
setBackground(Theme.WINDOW_BG); setBackground(Theme.windowBg());
setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12)); setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
int row = 0; int row = 0;
addRow(this, row++, "Packet loss", packetLoss); addRow(this, row++, "Packet loss", packetLoss);

View File

@@ -0,0 +1,171 @@
package com.ts3client.ui;
import com.ts3client.text.BBCode;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.JButton;
import javax.swing.JColorChooser;
import javax.swing.JDialog;
import javax.swing.JEditorPane;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.JToggleButton;
import javax.swing.JToolBar;
import java.awt.BorderLayout;
import java.awt.CardLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Window;
/**
* The description editor TeamSpeak tears off the channel dialog: the raw BBCode plus
* the handful of formatting buttons it offers (bold, italic, underline, colour) and a
* preview that renders the text the way the info panel will.
*
* <p>Formatting works on the selection, wrapping it in the tag pair; with nothing
* selected the pair is inserted and the caret placed between the two halves, so typing
* continues inside it.
*/
final class DescriptionEditorDialog extends JDialog {
private static final String EDIT_CARD = "edit";
private static final String PREVIEW_CARD = "preview";
private final JTextArea area = new JTextArea();
private final JEditorPane preview = new JEditorPane("text/html", "");
private final CardLayout cards = new CardLayout();
private final JPanel body = new JPanel(cards);
private final JToggleButton previewButton = new JToggleButton("Preview");
private boolean accepted;
DescriptionEditorDialog(Window owner, String description) {
super(owner, "Channel Description", ModalityType.APPLICATION_MODAL);
area.setText(description);
area.setLineWrap(true);
area.setWrapStyleWord(true);
area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
area.setCaretPosition(0);
preview.setEditable(false);
preview.setBackground(Theme.chatBg());
preview.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
body.add(new JScrollPane(area), EDIT_CARD);
body.add(new JScrollPane(preview), PREVIEW_CARD);
JLabel hint = new JLabel("Press Button to \"Preview\" the changes in channel info.");
hint.setForeground(Theme.chatSystem());
hint.setBorder(BorderFactory.createEmptyBorder(4, 6, 0, 6));
getContentPane().setLayout(new BorderLayout());
getContentPane().add(buildToolbar(), BorderLayout.NORTH);
getContentPane().add(body, BorderLayout.CENTER);
JPanel south = new JPanel(new BorderLayout());
south.add(hint, BorderLayout.NORTH);
south.add(buildButtons(), BorderLayout.SOUTH);
getContentPane().add(south, BorderLayout.SOUTH);
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(new Dimension(520, 380));
setMinimumSize(new Dimension(360, 260));
setLocationRelativeTo(owner);
}
/** @return whether the user confirmed; {@link #getDescription()} then holds the new text */
boolean isAccepted() {
return accepted;
}
String getDescription() {
return area.getText();
}
private JToolBar buildToolbar() {
JToolBar bar = new JToolBar();
bar.setFloatable(false);
bar.add(tagButton("B", "Bold", Font.BOLD, "[b]", "[/b]"));
bar.add(tagButton("I", "Italic", Font.ITALIC, "[i]", "[/i]"));
JButton underline = tagButton("<html><u>U</u></html>", "Underline", Font.PLAIN, "[u]", "[/u]");
bar.add(underline);
JButton color = new JButton("Color");
color.setToolTipText("Color");
color.addActionListener(a -> chooseColor());
bar.add(square(color));
bar.add(Box.createHorizontalGlue());
previewButton.setToolTipText("Show the description as the info panel renders it");
previewButton.addActionListener(a -> showPreview(previewButton.isSelected()));
previewButton.setMaximumSize(previewButton.getPreferredSize());
bar.add(previewButton);
return bar;
}
private JPanel buildButtons() {
JButton ok = new JButton("OK");
JButton cancel = new JButton("Cancel");
ok.addActionListener(a -> {
accepted = true;
dispose();
});
cancel.addActionListener(a -> dispose());
JPanel panel = new JPanel(new BorderLayout());
JPanel right = new JPanel();
right.add(ok);
right.add(cancel);
panel.add(right, BorderLayout.EAST);
getRootPane().setDefaultButton(ok);
return panel;
}
private JButton tagButton(String text, String tip, int style, String open, String close) {
JButton button = new JButton(text);
button.setToolTipText(tip);
button.setFont(button.getFont().deriveFont(style));
button.addActionListener(a -> wrapSelection(open, close));
return square(button);
}
/**
* Pins a toolbar button to its natural size: the tool bar lays its children out along a
* box, which would otherwise let one of them soak up all the free width.
*/
private static JButton square(JButton button) {
Dimension size = new Dimension(Math.max(28, button.getPreferredSize().width),
button.getPreferredSize().height);
button.setPreferredSize(size);
button.setMaximumSize(size);
return button;
}
private void chooseColor() {
Color chosen = JColorChooser.showDialog(this, "Color", Color.BLACK);
if (chosen == null) return;
wrapSelection(String.format("[color=#%02x%02x%02x]",
chosen.getRed(), chosen.getGreen(), chosen.getBlue()), "[/color]");
}
/** Wraps the selection (or the caret) in a BBCode tag pair and returns focus to the text. */
private void wrapSelection(String open, String close) {
if (previewButton.isSelected()) showPreview(false);
int start = area.getSelectionStart();
int end = area.getSelectionEnd();
String selected = area.getSelectedText();
area.replaceRange(open + (selected == null ? "" : selected) + close, start, end);
area.setCaretPosition(start + open.length() + (selected == null ? 0 : selected.length()));
area.requestFocusInWindow();
}
private void showPreview(boolean on) {
previewButton.setSelected(on);
if (on) {
preview.setText("<html><body style=\"font-family:sans-serif;font-size:9pt\">"
+ BBCode.toHtml(area.getText()) + "</body></html>");
preview.setCaretPosition(0);
}
cards.show(body, on ? PREVIEW_CARD : EDIT_CARD);
}
}

View File

@@ -26,13 +26,14 @@ import java.awt.Insets;
import java.io.File; import java.io.File;
/** /**
* Options page for icon packs: which pack the user interface is drawn with, where * Options page for how the client looks: the light or dark look-and-feel, and the icon
* to look for more of them, and a viewer showing everything the pack contains. * pack the user interface is drawn with where to look for more of them, and a viewer
* showing everything the active pack contains.
* *
* <p>Picking a pack applies it right away so the change can be seen in the window * <p>Both apply right away so the change can be seen in the window behind the dialog;
* behind the dialog; cancelling puts the previous one back. * cancelling puts the previous ones back.
*/ */
final class IconPackPanel extends JPanel { final class DesignPanel extends JPanel {
/** Size of the tiles in the icon viewer. */ /** Size of the tiles in the icon viewer. */
private static final int PREVIEW_SIZE = 32; private static final int PREVIEW_SIZE = 32;
@@ -44,18 +45,22 @@ final class IconPackPanel extends JPanel {
private final Settings settings; private final Settings settings;
private final String originalPackId; private final String originalPackId;
private final String originalPackDir; private final String originalPackDir;
private final Settings.Appearance originalAppearance;
private final JComboBox<Settings.Appearance> appearanceCombo =
new JComboBox<>(Settings.Appearance.values());
private final JComboBox<Object> packCombo = new JComboBox<>(); private final JComboBox<Object> packCombo = new JComboBox<>();
private final JLabel packInfo = new JLabel(); private final JLabel packInfo = new JLabel();
private final JTextField packDirField; private final JTextField packDirField;
private final DefaultListModel<String> previewModel = new DefaultListModel<>(); private final DefaultListModel<String> previewModel = new DefaultListModel<>();
private final JList<String> preview = new JList<>(previewModel); private final JList<String> preview = new JList<>(previewModel);
IconPackPanel(Settings settings) { DesignPanel(Settings settings) {
super(new BorderLayout(0, 8)); super(new BorderLayout(0, 8));
this.settings = settings; this.settings = settings;
this.originalPackId = settings.iconPack; this.originalPackId = settings.iconPack;
this.originalPackDir = settings.iconPackDir; this.originalPackDir = settings.iconPackDir;
this.originalAppearance = settings.appearance;
this.packDirField = new JTextField(settings.iconPackDir, 18); this.packDirField = new JTextField(settings.iconPackDir, 18);
setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
@@ -65,18 +70,23 @@ final class IconPackPanel extends JPanel {
reloadPacks(); reloadPacks();
} }
/** Copies the chosen pack into the settings; the caller saves them. */ /** Copies the chosen appearance and pack into the settings; the caller saves them. */
void apply() { void apply() {
settings.appearance = selectedAppearance();
settings.iconPackDir = packDirField.getText().trim(); settings.iconPackDir = packDirField.getText().trim();
Object selected = packCombo.getSelectedItem(); Object selected = packCombo.getSelectedItem();
settings.iconPack = selected instanceof IconPack pack ? pack.id() : ""; settings.iconPack = selected instanceof IconPack pack ? pack.id() : "";
} }
/** Puts back the pack the dialog started with, for a cancelled edit. */ /** Puts back the appearance and pack the dialog started with, for a cancelled edit. */
void revert() { void revert() {
settings.iconPack = originalPackId; settings.iconPack = originalPackId;
settings.iconPackDir = originalPackDir; settings.iconPackDir = originalPackDir;
IconTheme.get().reload(settings); IconTheme.get().reload(settings);
if (settings.appearance != originalAppearance) {
settings.appearance = originalAppearance;
LookAndFeelManager.switchTo(originalAppearance);
}
} }
// ---- layout ---- // ---- layout ----
@@ -100,7 +110,21 @@ final class IconPackPanel extends JPanel {
} }
}); });
appearanceCombo.setToolTipText("Light or dark window colours");
appearanceCombo.setSelectedItem(settings.appearance);
appearanceCombo.setRenderer(new DefaultListCellRenderer() {
@Override
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
boolean selected, boolean focus) {
super.getListCellRendererComponent(list, value, index, selected, focus);
setText(value == Settings.Appearance.DARK ? "Dark" : "Light");
return this;
}
});
appearanceCombo.addActionListener(e -> onAppearanceSelected());
int row = 0; int row = 0;
addRow(p, c, row++, new JLabel("Theme:"), appearanceCombo);
addRow(p, c, row++, new JLabel("Icon pack:"), packCombo); addRow(p, c, row++, new JLabel("Icon pack:"), packCombo);
c.gridx = 1; c.gridx = 1;
c.gridy = row++; c.gridy = row++;
@@ -159,6 +183,18 @@ final class IconPackPanel extends JPanel {
onPackSelected(); onPackSelected();
} }
private Settings.Appearance selectedAppearance() {
Object selected = appearanceCombo.getSelectedItem();
return selected instanceof Settings.Appearance a ? a : Settings.Appearance.LIGHT;
}
private void onAppearanceSelected() {
Settings.Appearance chosen = selectedAppearance();
if (chosen == settings.appearance) return;
settings.appearance = chosen;
LookAndFeelManager.switchTo(chosen);
}
private void onPackSelected() { private void onPackSelected() {
Object selected = packCombo.getSelectedItem(); Object selected = packCombo.getSelectedItem();
IconPack pack = selected instanceof IconPack p ? p : null; IconPack pack = selected instanceof IconPack p ? p : null;

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,284 @@
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.Point;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.List;
import java.util.function.Predicate;
/**
* 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;
/** The row the pointer is over, or -1 when it is over none. */
private int hoverRow = -1;
private Predicate<TreePath> pathEditable = path -> false;
DropIndicatorTree(TreeModel treeModel, ServerModel model, GroupIcons groupIcons) {
super(treeModel);
this.model = model;
this.groupIcons = groupIcons;
MouseAdapter hover = new MouseAdapter() {
@Override
public void mouseMoved(MouseEvent e) {
setHoverRow(rowAt(e.getY()));
}
@Override
public void mouseDragged(MouseEvent e) {
setHoverRow(-1);
}
@Override
public void mouseExited(MouseEvent e) {
setHoverRow(-1);
}
};
addMouseMotionListener(hover);
addMouseListener(hover);
// The selected row is filled across the full width below, before the rows
// themselves are drawn, so this component must not clear its own background.
setOpaque(false);
}
/**
* The row a point falls on, going by its vertical band alone: a row reaches
* across the whole width, not just as far as its label.
*/
int rowAt(int y) {
int row = getClosestRowForLocation(0, y);
if (row < 0) return -1;
Rectangle bounds = getRowBounds(row);
return bounds != null && y >= bounds.y && y < bounds.y + bounds.height ? row : -1;
}
/** The path a point falls on, by the same rule as {@link #rowAt(int)}. */
TreePath pathAt(int y) {
int row = rowAt(y);
return row < 0 ? null : getPathForRow(row);
}
/**
* Re-reads the row the pointer is over. Scrolling moves the rows under a pointer
* that never moved itself, so the enclosing scroll pane calls this on every scroll.
*/
void refreshHoverRow() {
Point pointer = getMousePosition();
setHoverRow(pointer == null ? -1 : rowAt(pointer.y));
}
private void setHoverRow(int row) {
if (row == hoverRow) return;
repaintRow(hoverRow);
hoverRow = row;
repaintRow(row);
}
private void repaintRow(int row) {
if (row < 0) return;
Rectangle bounds = getRowBounds(row);
if (bounds != null) repaint(0, bounds.y, getWidth(), bounds.height);
}
/** Which rows an inline editor may open on; nothing, until one is installed. */
void setPathEditable(Predicate<TreePath> editable) {
this.pathEditable = editable;
}
@Override
public boolean isPathEditable(TreePath path) {
return isEditable() && pathEditable.test(path);
}
/** 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) {
Rectangle clip = g.getClipBounds();
g.setColor(getBackground());
g.fillRect(clip.x, clip.y, clip.width, clip.height);
paintSelection(g);
paintHover(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();
}
/**
* Fills the selected row across the full width, in the colour the cell renderer
* puts behind the label itself, so the selection covers the whole line rather
* than stopping where the name ends.
*/
private void paintSelection(Graphics g) {
int[] rows = getSelectionRows();
if (rows == null) return;
Rectangle visible = getVisibleRect();
g.setColor(Theme.treeSelection());
for (int row : rows) {
Rectangle bounds = getRowBounds(row);
if (bounds != null) g.fillRect(visible.x, bounds.y, visible.width, bounds.height);
}
}
/**
* Washes the row under the pointer across the full width it occupies — the same
* area a click on it acts on. A selected row keeps its selection colour, and
* spacers, which nothing can be done with, are left alone.
*/
private void paintHover(Graphics g) {
if (hoverRow < 0 || getDropLocation() != null || isRowSelected(hoverRow)) return;
Rectangle bounds = getRowBounds(hoverRow);
TreePath path = getPathForRow(hoverRow);
if (bounds == null || path == null || isSpacer(path)) return;
Rectangle visible = getVisibleRect();
g.setColor(Theme.hover());
g.fillRect(visible.x, bounds.y, visible.width, bounds.height);
}
private boolean isSpacer(TreePath path) {
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
return obj instanceof ChannelNode && Spacers.isSpacer(((ChannelNode) obj).name);
}
/** 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

@@ -66,7 +66,7 @@ public final class FileBrowserDialog extends JDialog {
this.channelPassword = ""; this.channelPassword = "";
JPanel content = new JPanel(new BorderLayout(0, 8)); JPanel content = new JPanel(new BorderLayout(0, 8));
content.setBackground(Theme.WINDOW_BG); content.setBackground(Theme.windowBg());
content.setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12)); content.setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
content.add(buildToolbar(), BorderLayout.NORTH); content.add(buildToolbar(), BorderLayout.NORTH);
content.add(buildTable(), BorderLayout.CENTER); content.add(buildTable(), BorderLayout.CENTER);
@@ -85,7 +85,7 @@ public final class FileBrowserDialog extends JDialog {
private JPanel buildToolbar() { private JPanel buildToolbar() {
JPanel bar = new JPanel(); JPanel bar = new JPanel();
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS)); bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
bar.setBackground(Theme.WINDOW_BG); bar.setBackground(Theme.windowBg());
upButton.addActionListener(e -> navigateUp()); upButton.addActionListener(e -> navigateUp());
JButton refresh = new JButton("Refresh", Icons.of("FILE_REFRESH")); JButton refresh = new JButton("Refresh", Icons.of("FILE_REFRESH"));
@@ -111,13 +111,13 @@ public final class FileBrowserDialog extends JDialog {
bar.add(Box.createHorizontalStrut(6)); bar.add(Box.createHorizontalStrut(6));
bar.add(deleteButton); bar.add(deleteButton);
pathLabel.setFont(Theme.UI_FONT); pathLabel.setFont(Theme.uiFont());
pathLabel.setForeground(Theme.TREE_TEXT); pathLabel.setForeground(Theme.treeText());
return bar; return bar;
} }
private JScrollPane buildTable() { private JScrollPane buildTable() {
table.setFont(Theme.UI_FONT); table.setFont(Theme.uiFont());
table.setRowHeight(20); table.setRowHeight(20);
table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
table.setFillsViewportHeight(true); table.setFillsViewportHeight(true);
@@ -137,17 +137,17 @@ public final class FileBrowserDialog extends JDialog {
} }
}); });
JScrollPane scroll = new JScrollPane(table); JScrollPane scroll = new JScrollPane(table);
scroll.getViewport().setBackground(Theme.TREE_BG); scroll.getViewport().setBackground(Theme.treeBg());
return scroll; return scroll;
} }
private JScrollPane buildTransfers() { private JScrollPane buildTransfers() {
transfersPanel.setLayout(new BoxLayout(transfersPanel, BoxLayout.Y_AXIS)); transfersPanel.setLayout(new BoxLayout(transfersPanel, BoxLayout.Y_AXIS));
transfersPanel.setBackground(Theme.WINDOW_BG); transfersPanel.setBackground(Theme.windowBg());
JScrollPane scroll = new JScrollPane(transfersPanel); JScrollPane scroll = new JScrollPane(transfersPanel);
scroll.setBorder(BorderFactory.createTitledBorder("Transfers")); scroll.setBorder(BorderFactory.createTitledBorder("Transfers"));
scroll.setPreferredSize(new Dimension(10, 120)); scroll.setPreferredSize(new Dimension(10, 120));
scroll.getViewport().setBackground(Theme.WINDOW_BG); scroll.getViewport().setBackground(Theme.windowBg());
return scroll; return scroll;
} }
@@ -326,10 +326,10 @@ public final class FileBrowserDialog extends JDialog {
TransferRow() { TransferRow() {
setLayout(new BorderLayout(8, 0)); setLayout(new BorderLayout(8, 0));
setBackground(Theme.WINDOW_BG); setBackground(Theme.windowBg());
setBorder(BorderFactory.createEmptyBorder(3, 2, 3, 2)); setBorder(BorderFactory.createEmptyBorder(3, 2, 3, 2));
setMaximumSize(new Dimension(Integer.MAX_VALUE, 44)); setMaximumSize(new Dimension(Integer.MAX_VALUE, 44));
label.setFont(Theme.UI_FONT); label.setFont(Theme.uiFont());
bar.setStringPainted(true); bar.setStringPainted(true);
add(label, BorderLayout.NORTH); add(label, BorderLayout.NORTH);
add(bar, BorderLayout.CENTER); add(bar, BorderLayout.CENTER);

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

@@ -0,0 +1,108 @@
package com.ts3client.ui;
import com.ts3client.text.BBCode;
import javax.swing.JEditorPane;
import javax.swing.JViewport;
import javax.swing.SwingUtilities;
import javax.swing.text.html.HTMLEditorKit;
import javax.swing.text.html.StyleSheet;
import java.awt.Color;
/**
* The HTML panes showing TeamSpeak text — chat logs, client and channel information —
* and the theme-dependent stylesheet they share.
*
* <p>Text that is already on screen keeps the colours its stylesheet was built with, so
* a look-and-feel change re-installs the editor kit and parses the pane's content again
* instead of only recolouring what is written afterwards.
*/
final class HtmlStyles {
private static final String BODY_STYLE = "HtmlStyles.bodyStyle";
private HtmlStyles() {
}
/**
* @param bodyStyle extra CSS for {@code body}, e.g. font and margins; the text colour
* is added by the theme
* @return a read-only HTML pane whose stylesheet follows the current theme
*/
static JEditorPane pane(String bodyStyle) {
JEditorPane pane = new JEditorPane() {
@Override
public void updateUI() {
super.updateUI();
restyle(this);
}
};
pane.putClientProperty(BODY_STYLE, bodyStyle);
pane.setEditable(false);
installKit(pane, null, false);
return pane;
}
/**
* Paints the scroll pane {@code pane} sits in with the pane's own background: text
* only reaches as far as it goes, and the empty space below it would otherwise show
* the window's colour instead of the log's.
*/
static void fillViewport(JEditorPane pane) {
if (pane.getParent() instanceof JViewport viewport) {
viewport.setBackground(pane.getBackground());
}
}
/** Re-themes {@code pane}, keeping the text it shows. */
private static void restyle(JEditorPane pane) {
// Also runs from JEditorPane's constructor, before the pane is one of ours.
if (pane.getClientProperty(BODY_STYLE) == null) return;
int length = pane.getDocument().getLength();
String html = length == 0 ? null : pane.getText();
// A chat log sits at its newest line; keep it there rather than jumping to the top.
boolean atEnd = pane.getCaretPosition() >= length;
// Not during the look-and-feel update that got us here: the pane is rebuilding its
// views, and replacing the document underneath it would race with that.
SwingUtilities.invokeLater(() -> installKit(pane, html, atEnd));
}
private static void installKit(JEditorPane pane, String html, boolean atEnd) {
HTMLEditorKit kit = new HTMLEditorKit();
StyleSheet css = new StyleSheet();
css.addStyleSheet(kit.getStyleSheet());
Object bodyStyle = pane.getClientProperty(BODY_STYLE);
css.addRule("body { " + (bodyStyle == null ? "" : bodyStyle + " ")
+ "color:" + hex(Theme.chatText()) + "; }");
// Secondary text (timestamps, labels, placeholders), an author's name and the lines
// about what happened on the server.
css.addRule(".muted { color:" + hex(Theme.chatSystem()) + "; }");
css.addRule(".name { color:" + hex(Theme.chatName()) + "; font-weight:bold; }");
css.addRule(".event { color:" + hex(Theme.chatEvent()) + "; }");
css.addRule("a { color:" + hex(Theme.chatName()) + "; text-decoration:none; }");
// Client references look exactly like a message author's name.
css.addRule("a." + BBCode.IDENTITY_LINK_CLASS
+ " { color:" + hex(Theme.chatName()) + "; font-weight:bold; text-decoration:none; }");
// Channel (and other TeamSpeak protocol) references stand out of a line the same
// way a client's name does.
css.addRule("a." + BBCode.TS_LINK_CLASS
+ " { color:" + hex(Theme.chatName()) + "; font-weight:bold; text-decoration:none; }");
// External links are underlined so they can't be mistaken for an identity.
css.addRule("a." + BBCode.EXTERNAL_LINK_CLASS
+ " { color:" + hex(Theme.link()) + "; text-decoration:underline; }");
kit.setStyleSheet(css);
pane.setEditorKit(kit);
fillViewport(pane);
if (html != null) {
pane.setText(html);
pane.setCaretPosition(atEnd ? pane.getDocument().getLength() : 0);
}
}
private static String hex(Color c) {
return String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue());
}
}

View File

@@ -0,0 +1,270 @@
package com.ts3client.ui;
import com.ts3client.net.IconRepository;
import com.ts3client.net.TeamspeakConnection;
import javax.swing.BorderFactory;
import javax.swing.DefaultListModel;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JFileChooser;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.ListSelectionModel;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import javax.swing.filechooser.FileNameExtensionFilter;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Window;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.io.File;
/**
* TeamSpeak's icon viewer: the icons uploaded to this virtual server on one side (which
* can be added to and removed from here) and the ones the icon pack ships on the other.
*
* <p>Server icons arrive through the {@link IconRepository}, which downloads them in the
* background, so the lists are simply repainted until every icon has turned up.
*/
final class IconChooserDialog extends JDialog {
/** How often the lists are repainted while icons are still being downloaded. */
private static final int REFRESH_MS = 300;
/** Give up repainting once nothing has arrived for this long. */
private static final int REFRESH_TIMEOUT_MS = 20_000;
private final TeamspeakConnection conn;
private final GroupIcons groupIcons;
private final DefaultListModel<Long> remoteModel = new DefaultListModel<>();
private final JList<Long> remoteList = new JList<>(remoteModel);
private final JList<Long> localList = new JList<>(new DefaultListModel<>());
private final JButton deleteButton = new JButton("Delete");
private final JButton selectButton = new JButton("Select");
private final JLabel status = new JLabel(" ");
private long chosenIconId;
private boolean accepted;
IconChooserDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons) {
super(owner, "Icons", ModalityType.APPLICATION_MODAL);
this.conn = conn;
this.groupIcons = groupIcons;
configure(remoteList);
configure(localList);
DefaultListModel<Long> localModel = (DefaultListModel<Long>) localList.getModel();
for (Long id : IconRepository.BUNDLED_IDS) localModel.addElement(id);
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT,
group("Remote", remoteList, remoteButtons()),
group("Local", localList, localFooter()));
split.setResizeWeight(0.6);
status.setBorder(BorderFactory.createEmptyBorder(2, 8, 2, 8));
status.setForeground(Theme.chatSystem());
getContentPane().setLayout(new BorderLayout());
getContentPane().add(split, BorderLayout.CENTER);
JPanel south = new JPanel(new BorderLayout());
south.add(status, BorderLayout.NORTH);
south.add(buttons(), BorderLayout.SOUTH);
getContentPane().add(south, BorderLayout.SOUTH);
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(new Dimension(520, 360));
setLocationRelativeTo(owner);
loadRemoteIcons();
startRefreshing();
}
/** @return whether an icon was picked; {@link #getIconId()} then holds it */
boolean isAccepted() {
return accepted;
}
long getIconId() {
return chosenIconId;
}
private void configure(JList<Long> list) {
list.setLayoutOrientation(JList.HORIZONTAL_WRAP);
list.setVisibleRowCount(-1);
list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
list.setFixedCellWidth(32);
list.setFixedCellHeight(32);
list.setCellRenderer(new IconCellRenderer());
list.addListSelectionListener(e -> onSelectionChanged(list));
list.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
if (e.getClickCount() == 2 && list.getSelectedValue() != null) choose(list.getSelectedValue());
}
});
}
/** Keeps one list's selection exclusive: picking on one side clears the other. */
private void onSelectionChanged(JList<Long> list) {
JList<Long> other = list == remoteList ? localList : remoteList;
if (list.getSelectedValue() != null) other.clearSelection();
deleteButton.setEnabled(remoteList.getSelectedValue() != null);
selectButton.setEnabled(selectedIcon() != 0);
}
private long selectedIcon() {
Long remote = remoteList.getSelectedValue();
if (remote != null) return remote;
Long local = localList.getSelectedValue();
return local == null ? 0 : local;
}
private JPanel group(String title, JList<Long> list, Component footer) {
JPanel panel = new JPanel(new BorderLayout(0, 4));
panel.setBorder(BorderFactory.createTitledBorder(title));
panel.add(new JScrollPane(list), BorderLayout.CENTER);
panel.add(footer, BorderLayout.SOUTH);
return panel;
}
private Component remoteButtons() {
JButton upload = new JButton("Upload");
upload.addActionListener(a -> uploadIcon());
deleteButton.setEnabled(false);
deleteButton.addActionListener(a -> deleteSelectedIcon());
JPanel row = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.LEFT, 4, 0));
row.add(upload);
row.add(deleteButton);
return row;
}
private Component localFooter() {
com.ts3client.gfx.IconPack active = IconTheme.get().activePack();
JLabel pack = new JLabel("Icon Pack: " + (active == null ? "none" : active.name()));
pack.setBorder(BorderFactory.createEmptyBorder(0, 4, 0, 4));
return pack;
}
private JPanel buttons() {
selectButton.setEnabled(false);
selectButton.addActionListener(a -> choose(selectedIcon()));
JButton cancel = new JButton("Cancel");
cancel.addActionListener(a -> dispose());
JPanel panel = new JPanel(new BorderLayout());
JPanel right = new JPanel();
right.add(selectButton);
right.add(cancel);
panel.add(right, BorderLayout.EAST);
getRootPane().setDefaultButton(selectButton);
return panel;
}
private void choose(long iconId) {
if (iconId == 0) return;
chosenIconId = iconId;
accepted = true;
dispose();
}
private void loadRemoteIcons() {
status.setText("Loading icons…");
conn.requestServerIcons((ids, error) -> SwingUtilities.invokeLater(() -> {
if (error != null) {
status.setText("Could not list the server's icons: " + error);
return;
}
status.setText(" ");
remoteModel.clear();
for (Long id : ids) remoteModel.addElement(id);
}));
}
private void uploadIcon() {
JFileChooser chooser = new JFileChooser();
chooser.setDialogTitle("Select an image to upload");
chooser.setFileFilter(new FileNameExtensionFilter("Images", "png", "jpg", "jpeg", "gif", "bmp", "svg"));
if (chooser.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) return;
File file = chooser.getSelectedFile();
status.setText("Uploading " + file.getName() + "");
conn.uploadIcon(file, (iconId, error) -> SwingUtilities.invokeLater(() -> {
if (error != null) {
status.setText(" ");
JOptionPane.showMessageDialog(this, "Error uploading icon: " + error,
"Error", JOptionPane.ERROR_MESSAGE);
return;
}
loadRemoteIcons();
startRefreshing();
}));
}
private void deleteSelectedIcon() {
Long id = remoteList.getSelectedValue();
if (id == null) return;
int answer = JOptionPane.showConfirmDialog(this,
"Permanently delete this icon from the server?", "Confirmation",
JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE);
if (answer != JOptionPane.YES_OPTION) return;
conn.deleteIcon(id, error -> SwingUtilities.invokeLater(() -> {
if (error != null) {
JOptionPane.showMessageDialog(this, "Failed to delete remote icon file: " + error,
"Error", JOptionPane.ERROR_MESSAGE);
return;
}
remoteModel.removeElement(id);
}));
}
/**
* Repaints while the repository is still fetching icons; it hands them over silently,
* so the only way to show them as they land is to keep asking.
*/
private void startRefreshing() {
Timer timer = new Timer(REFRESH_MS, null);
long deadline = System.currentTimeMillis() + REFRESH_TIMEOUT_MS;
timer.addActionListener(e -> {
remoteList.repaint();
localList.repaint();
if (!isDisplayable() || System.currentTimeMillis() > deadline || allIconsLoaded()) timer.stop();
});
timer.start();
}
private boolean allIconsLoaded() {
for (int i = 0; i < remoteModel.size(); i++) {
if (groupIcons.icon(remoteModel.get(i)) == null) return false;
}
return true;
}
/** Draws one icon, blank until the repository has it. */
private final class IconCellRenderer extends javax.swing.DefaultListCellRenderer {
@Override
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
boolean selected, boolean focused) {
super.getListCellRendererComponent(list, "", index, selected, focused);
long id = value instanceof Long ? (Long) value : 0;
ImageIcon icon = groupIcons.icon(id);
setIcon(icon);
setHorizontalAlignment(CENTER);
setToolTipText("Icon " + id);
return this;
}
}
/** Convenience for callers that only need the picked id. */
static long pick(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, long current) {
IconChooserDialog dialog = new IconChooserDialog(owner, conn, groupIcons);
dialog.setVisible(true);
return dialog.isAccepted() ? dialog.getIconId() : current;
}
}

View File

@@ -121,7 +121,7 @@ public final class Icons {
} }
public static ImageIcon clientIdle(int size) { public static ImageIcon clientIdle(int size) {
return themed("PLAYER_OFF", size, g -> paintPerson(g, Theme.IDLE_CLIENT)); return themed("PLAYER_OFF", size, g -> paintPerson(g, Theme.idleClient()));
} }
public static ImageIcon clientTalking() { public static ImageIcon clientTalking() {
@@ -129,7 +129,7 @@ public final class Icons {
} }
public static ImageIcon clientTalking(int size) { public static ImageIcon clientTalking(int size) {
return themed("PLAYER_ON", size, g -> paintPerson(g, Theme.TALKING)); return themed("PLAYER_ON", size, g -> paintPerson(g, Theme.talking()));
} }
public static ImageIcon clientAway() { public static ImageIcon clientAway() {
@@ -137,7 +137,7 @@ public final class Icons {
} }
public static ImageIcon clientAway(int size) { public static ImageIcon clientAway(int size) {
return themed("AWAY", size, g -> paintPerson(g, Theme.AWAY)); return themed("AWAY", size, g -> paintPerson(g, Theme.away()));
} }
/** A channel commander that is not talking. */ /** A channel commander that is not talking. */
@@ -151,7 +151,7 @@ public final class Icons {
} }
public static ImageIcon clientQuery() { public static ImageIcon clientQuery() {
return themed("SERVER_QUERY", g -> paintPerson(g, Theme.IDLE_CLIENT)); return themed("SERVER_QUERY", g -> paintPerson(g, Theme.idleClient()));
} }
public static ImageIcon micMuted() { public static ImageIcon micMuted() {
@@ -199,12 +199,12 @@ public final class Icons {
/** A channel commander that is not talking. */ /** A channel commander that is not talking. */
public static ImageIcon clientCommander(int size) { public static ImageIcon clientCommander(int size) {
return themed("PLAYER_COMMANDER_OFF", size, g -> paintPerson(g, Theme.IDLE_CLIENT)); return themed("PLAYER_COMMANDER_OFF", size, g -> paintPerson(g, Theme.idleClient()));
} }
/** A channel commander that is talking. */ /** A channel commander that is talking. */
public static ImageIcon clientCommanderTalking(int size) { public static ImageIcon clientCommanderTalking(int size) {
return themed("PLAYER_COMMANDER_ON", size, g -> paintPerson(g, Theme.TALKING)); return themed("PLAYER_COMMANDER_ON", size, g -> paintPerson(g, Theme.talking()));
} }
// ---- toolbar / action icons ---- // ---- toolbar / action icons ----
@@ -243,9 +243,34 @@ 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.idleClient());
g.setFont(Theme.uiFont());
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()));
} }
public static ImageIcon settings() { public static ImageIcon settings() {
@@ -304,7 +329,7 @@ public final class Icons {
} }
private static void paintMicMuted(Graphics2D g) { private static void paintMicMuted(Graphics2D g) {
g.setColor(Theme.MUTED); g.setColor(Theme.muted());
g.fillRoundRect(6, 2, 4, 7, 2, 2); g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f)); g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140); g.drawArc(4, 6, 8, 6, 200, 140);
@@ -314,7 +339,7 @@ public final class Icons {
} }
private static void paintSpeakerMuted(Graphics2D g) { private static void paintSpeakerMuted(Graphics2D g) {
g.setColor(Theme.MUTED); g.setColor(Theme.muted());
g.fillRect(2, 6, 3, 4); g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5}; int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10}; int[] ys = {6, 3, 13, 10};
@@ -325,7 +350,7 @@ public final class Icons {
/** Same shape as {@link #paintMicMuted}, but grey: unavailable, not muted by choice. */ /** Same shape as {@link #paintMicMuted}, but grey: unavailable, not muted by choice. */
private static void paintMicDisabled(Graphics2D g) { private static void paintMicDisabled(Graphics2D g) {
g.setColor(Theme.IDLE_CLIENT); g.setColor(Theme.idleClient());
g.fillRoundRect(6, 2, 4, 7, 2, 2); g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f)); g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140); g.drawArc(4, 6, 8, 6, 200, 140);
@@ -336,7 +361,7 @@ public final class Icons {
/** Same shape as {@link #paintSpeakerMuted}, but grey: unavailable, not muted by choice. */ /** Same shape as {@link #paintSpeakerMuted}, but grey: unavailable, not muted by choice. */
private static void paintSpeakerDisabled(Graphics2D g) { private static void paintSpeakerDisabled(Graphics2D g) {
g.setColor(Theme.IDLE_CLIENT); g.setColor(Theme.idleClient());
g.fillRect(2, 6, 3, 4); g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5}; int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10}; int[] ys = {6, 3, 13, 10};
@@ -348,7 +373,7 @@ public final class Icons {
/** {@link #paintMicMuted}, marked with a small dot: silenced, but not reported as muted. */ /** {@link #paintMicMuted}, marked with a small dot: silenced, but not reported as muted. */
private static void paintMicLocalMuted(Graphics2D g) { private static void paintMicLocalMuted(Graphics2D g) {
paintMicMuted(g); paintMicMuted(g);
g.setColor(Theme.ACCENT); g.setColor(Theme.accent());
g.fillOval(11, 10, 4, 4); g.fillOval(11, 10, 4, 4);
} }
@@ -362,7 +387,7 @@ public final class Icons {
} }
private static void paintDisconnect(Graphics2D g) { private static void paintDisconnect(Graphics2D g) {
g.setColor(Theme.MUTED); g.setColor(Theme.muted());
g.setStroke(new BasicStroke(2f)); g.setStroke(new BasicStroke(2f));
g.drawLine(3, 8, 8, 8); g.drawLine(3, 8, 8, 8);
g.fillRoundRect(8, 5, 5, 6, 2, 2); g.fillRoundRect(8, 5, 5, 6, 2, 2);
@@ -389,7 +414,7 @@ public final class Icons {
} }
private static void paintMicActive(Graphics2D g) { private static void paintMicActive(Graphics2D g) {
g.setColor(Theme.TALKING); g.setColor(Theme.talking());
g.fillOval(1, 1, 14, 14); g.fillOval(1, 1, 14, 14);
g.setColor(Color.WHITE); g.setColor(Color.WHITE);
g.fillRoundRect(6, 3, 4, 6, 2, 2); g.fillRoundRect(6, 3, 4, 6, 2, 2);
@@ -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));
@@ -413,7 +450,7 @@ public final class Icons {
} }
private static void paintApp(Graphics2D g) { private static void paintApp(Graphics2D g) {
g.setColor(Theme.ACCENT); g.setColor(Theme.accent());
g.fillRoundRect(1, 1, 14, 14, 4, 4); g.fillRoundRect(1, 1, 14, 14, 4, 4);
g.setColor(Color.WHITE); g.setColor(Color.WHITE);
g.setStroke(new BasicStroke(1.6f)); g.setStroke(new BasicStroke(1.6f));

View File

@@ -20,39 +20,100 @@ import java.util.List;
*/ */
public final class InfoPanel extends JScrollPane { public final class InfoPanel extends JScrollPane {
private final JEditorPane pane = new JEditorPane(); /** 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 = HtmlStyles.pane("font-family:sans-serif; font-size:11px;");
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.StyleSheet css = new javax.swing.text.html.StyleSheet();
css.addStyleSheet(kit.getStyleSheet());
css.addRule("a { color:" + hex(Theme.CHAT_NAME) + "; text-decoration:none; }");
// Client references look exactly like a message author's name.
css.addRule("a." + BBCode.IDENTITY_LINK_CLASS
+ " { color:" + hex(Theme.CHAT_NAME) + "; font-weight:bold; text-decoration:none; }");
// External links are underlined so they can't be mistaken for an identity.
css.addRule("a." + BBCode.EXTERNAL_LINK_CLASS
+ " { color:" + hex(Theme.LINK) + "; text-decoration:underline; }");
kit.setStyleSheet(css);
pane.setEditorKit(kit);
pane.setEditable(false);
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);
} }
}); });
setViewportView(pane); setViewportView(pane);
setBorder(BorderFactory.createLineBorder(new java.awt.Color(0xD0D0D0))); HtmlStyles.fillViewport(pane);
clear(); clear();
} }
@Override
public void updateUI() {
super.updateUI();
setBorder(BorderFactory.createLineBorder(Theme.border()));
}
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 class='muted'>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");
@@ -63,16 +124,16 @@ public final class InfoPanel extends JScrollPane {
if (ch.description != null && !ch.description.isEmpty()) { if (ch.description != null && !ch.description.isEmpty()) {
sb.append("<div>").append(multiline(ch.description)).append("</div>"); sb.append("<div>").append(multiline(ch.description)).append("</div>");
} else if (ch.descriptionLoaded) { } else if (ch.descriptionLoaded) {
sb.append("<i style='color:#8a8a8a'>No description.</i>"); sb.append("<i class='muted'>No description.</i>");
} else { } else {
sb.append("<i style='color:#8a8a8a'>Loading description…</i>"); sb.append("<i class='muted'>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 class='muted'>(you)</span>" : "")));
List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds); List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds);
if (serverGroups.isEmpty()) { if (serverGroups.isEmpty()) {
@@ -105,12 +166,17 @@ 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) {
pane.setText("<html><body style='font-family:sans-serif;font-size:11px;color:#202020'>" pane.setText("<html><body>" + body + "</body></html>");
+ body + "</body></html>");
pane.setCaretPosition(0); pane.setCaretPosition(0);
} }
@@ -131,7 +197,7 @@ public final class InfoPanel extends JScrollPane {
} }
private static void row(StringBuilder sb, String label, String value) { private static void row(StringBuilder sb, String label, String value) {
sb.append("<div style='margin:1px 0'><span style='color:#5a6b7b'>") sb.append("<div style='margin:1px 0'><span class='muted'>")
.append(label).append(":</span> ").append(value).append("</div>"); .append(label).append(":</span> ").append(value).append("</div>");
} }
@@ -144,7 +210,4 @@ public final class InfoPanel extends JScrollPane {
return BBCode.escape(s); return BBCode.escape(s);
} }
private static String hex(java.awt.Color c) {
return String.format("#%06X", c.getRGB() & 0xFFFFFF);
}
} }

View File

@@ -73,7 +73,7 @@ public final class LevelMeter extends JComponent {
g.fillRoundRect(0, 0, w - 1, h - 1, 6, 6); g.fillRoundRect(0, 0, w - 1, h - 1, 6, 6);
int level = dbToX(levelDb, w - 2); int level = dbToX(levelDb, w - 2);
g.setColor(transmitting ? Theme.TALKING : new Color(0x5A9BD4)); g.setColor(transmitting ? Theme.talking() : new Color(0x5A9BD4));
g.fillRoundRect(1, 1, Math.max(0, level), h - 3, 5, 5); g.fillRoundRect(1, 1, Math.max(0, level), h - 3, 5, 5);
if (showThreshold) { if (showThreshold) {

View File

@@ -0,0 +1,51 @@
package com.ts3client.ui;
import com.formdev.flatlaf.FlatDarkLaf;
import com.formdev.flatlaf.FlatLaf;
import com.formdev.flatlaf.FlatLightLaf;
import com.ts3client.config.Settings;
import java.awt.Window;
/**
* Installs FlatLaf in the requested variant and keeps {@link Theme} in step with it.
*
* <p>The client's own look-and-feel defaults — rounded controls, and the darker palette
* the dark theme is built on — live beside this class as FlatLaf properties files, which
* FlatLaf loads on top of the theme's own.
*/
public final class LookAndFeelManager {
/** Where our own FlatLaf properties files sit. */
private static final String DEFAULTS_PACKAGE = "com.ts3client.ui.laf";
static {
// Registered once: FlatLaf keeps every registration, and install() runs again on
// each theme switch.
FlatLaf.registerCustomDefaultsSource(DEFAULTS_PACKAGE);
}
private LookAndFeelManager() {
}
/** Installs the look-and-feel for {@code appearance}. Call before building any window. */
public static void install(Settings.Appearance appearance) {
FlatLaf laf = appearance == Settings.Appearance.DARK ? new FlatDarkLaf() : new FlatLightLaf();
// Whatever look-and-feel stays installed on failure, Theme falls back to its own palette.
FlatLaf.setup(laf);
Theme.refresh();
}
/**
* Switches the look-and-feel of the running client. Components that read {@link Theme}
* while painting follow immediately; the ones that copied a colour when they were built
* are refreshed by the look-and-feel update.
*/
public static void switchTo(Settings.Appearance appearance) {
install(appearance);
FlatLaf.updateUI();
for (Window w : Window.getWindows()) {
w.repaint();
}
}
}

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
public void onConnect() {
showConnectDialog();
}
JMenu connections = new JMenu("Connections"); @Override
JMenuItem connect = new JMenuItem("Connect…", Icons.of("CONNECT")); public void onDisconnect() {
connect.setAccelerator(KeyStroke.getKeyStroke("control S")); doDisconnect();
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"); @Override
rebuildBookmarksMenu(); public void onCloseTab() {
closeTab(selected);
}
JMenu self = new JMenu("Self"); @Override
JMenuItem mute = new JMenuItem("Toggle microphone", Icons.of("CAPTURE")); public void onQuit() {
mute.addActionListener(e -> micButton.doClick()); quit();
JMenuItem deaf = new JMenuItem("Toggle speakers", Icons.of("PLAYBACK")); }
deaf.addActionListener(e -> speakerButton.doClick());
awayItem = new JCheckBoxMenuItem("Away", Icons.of("AWAY"), false); @Override
awayItem.addActionListener(e -> toggleAway()); public void onToggleMic() {
awayStatusItem = new JMenuItem("Set away status…", Icons.of("EDIT")); toolbar.clickMic();
awayStatusItem.addActionListener(e -> setAwayStatus()); }
commanderItem = new JCheckBoxMenuItem("Channel commander", Icons.of("CHANNEL_COMMANDER"), false);
commanderItem.addActionListener(e -> { @Override
if (selected != null) selected.setCommander(commanderItem.isSelected()); public void onToggleSpeaker() {
toolbar.clickSpeaker();
}
@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);
}
}); });
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() { private MainToolbar buildToolbar() {
bookmarksMenu.removeAll(); return new MainToolbar(settings, new MainToolbar.Listener() {
for (Bookmark b : bookmarks.all()) { @Override
JMenuItem item = new JMenuItem(b.displayName(), Icons.of("SERVER_GREEN")); public void onConnect() {
item.addActionListener(e -> connectToBookmark(b)); showConnectDialog();
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 onDisconnect() {
tb.setFloatable(false); doDisconnect();
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 onActivate() {
connectButton.addActionListener(e -> showConnectDialog()); moveMicrophoneToSelectedTab();
}
disconnectButton = new JButton(Icons.disconnect()); @Override
disconnectButton.setToolTipText("Disconnect"); public void onMicMuteToggle(boolean muted) {
disconnectButton.addActionListener(e -> doDisconnect()); if (selected == null) return;
selected.setMicMuted(muted);
updateToolbar();
}
activeButton = new JToggleButton(Icons.micActive()); @Override
activeButton.setToolTipText("Speak on this server (moves the microphone to this tab)"); public void onDeafenToggle(boolean deafened) {
activeButton.addActionListener(e -> { if (selected == null) return;
if (selected != null && selected.isConnected()) setMicTab(selected); 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();
}
}); });
micButton = new JToggleButton(Icons.mic());
micButton.setToolTipText("Mute / unmute microphone on this server");
micButton.addActionListener(e -> {
if (selected == null) return;
selected.setMicMuted(micButton.isSelected());
updateToolbar();
});
speakerButton = new JToggleButton(Icons.speaker());
speakerButton.setToolTipText("Deafen / undeafen (mute speakers) on this server");
speakerButton.addActionListener(e -> {
if (selected == null) return;
selected.setDeafened(speakerButton.isSelected());
updateToolbar();
});
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.toolbarBg());
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

@@ -0,0 +1,124 @@
package com.ts3client.ui;
import com.ts3client.net.ClientEntry;
import javax.swing.DefaultCellEditor;
import javax.swing.JLabel;
import javax.swing.JTextField;
import javax.swing.JTree;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.DefaultTreeCellEditor;
import javax.swing.tree.DefaultTreeCellRenderer;
import javax.swing.tree.TreePath;
import java.awt.Component;
import java.awt.event.MouseEvent;
import java.util.EventObject;
import java.util.function.IntPredicate;
/**
* Turns our own row in the server tree into a text field on a double click, the way
* TeamSpeak renames a client in place. Only that one row is editable; every other
* double click keeps its usual meaning (joining a channel, opening a chat).
*
* <p>The field opens on the current nickname with it selected, so typing replaces it,
* Enter commits and Escape leaves the name alone.
*/
final class NicknameCellEditor extends DefaultTreeCellEditor {
/** Pulls the field left by its own border, so the text sits where the label did. */
private static final int FIELD_NUDGE = 2;
/** Long enough for the click's own release to have been handled first. */
private static final int SELECT_DELAY_MS = 80;
private final JTextField field;
/** Whether a client id is our own — the tree learns ours only once connected. */
private final IntPredicate isSelf;
private NicknameCellEditor(JTree tree, DefaultTreeCellRenderer renderer, JTextField field,
IntPredicate isSelf) {
super(tree, renderer, new DefaultCellEditor(field));
this.field = field;
this.isSelf = isSelf;
}
static NicknameCellEditor create(JTree tree, DefaultTreeCellRenderer renderer, IntPredicate isSelf) {
JTextField field = new JTextField();
// Only our own row is edited, and that name is bold in the tree.
field.setFont(Theme.uiBold());
return new NicknameCellEditor(tree, renderer, field, isSelf);
}
/** Whether a path is the row this editor works on. */
boolean editsPath(TreePath path) {
ClientEntry client = clientOf(path);
return client != null && isSelf.test(client.id);
}
/**
* Editing is started by the tree's own double-click handling, which works a row by
* its whole line; Swing's would only see clicks that land on the label, and its
* click-pause-click timer would rename on clicks meant as a selection.
*/
@Override
public boolean isCellEditable(EventObject event) {
return !(event instanceof MouseEvent);
}
@Override
public Component getTreeCellEditorComponent(JTree tree, Object value, boolean isSelected,
boolean expanded, boolean leaf, int row) {
// The node itself has to reach super, whose renderer measures the row from it;
// the text it derives is the node's toString, so the nickname is filled in after.
Component c = super.getTreeCellEditorComponent(tree, value, isSelected, expanded, leaf, row);
ClientEntry client = clientOf(value);
if (client != null) {
field.setText(client.nickname);
field.selectAll();
}
SwingUtilities.invokeLater(field::requestFocusInWindow);
// The double click that opened the field ends in a release, which a text
// component turns into a caret placement — undoing any selection made before
// it. Selecting after that has been delivered is what leaves the whole name
// selected, ready to be typed over.
Timer select = new Timer(SELECT_DELAY_MS, e -> field.selectAll());
select.setRepeats(false);
select.start();
return c;
}
/**
* Keeps the row's own icon while it is being edited. The inherited version knows
* only the renderer's default leaf/branch icons, which would swap a client's state
* icon for a blank page for as long as the field is open.
*/
@Override
protected void determineOffset(JTree tree, Object value, boolean isSelected, boolean expanded,
boolean leaf, int row) {
super.determineOffset(tree, value, isSelected, expanded, leaf, row);
Component c = renderer.getTreeCellRendererComponent(tree, value, isSelected, expanded, leaf,
row, false);
if (!(c instanceof JLabel)) return;
editingIcon = ((JLabel) c).getIcon();
offset = editingIcon == null ? 0
: editingIcon.getIconWidth() + renderer.getIconTextGap() - FIELD_NUDGE;
}
/** The edited nickname, trimmed; empty when nothing usable was typed. */
String editedNickname() {
String text = field.getText();
return text == null ? "" : text.trim();
}
private static ClientEntry clientOf(TreePath path) {
return path == null ? null : clientOf(path.getLastPathComponent());
}
private static ClientEntry clientOf(Object node) {
if (!(node instanceof DefaultMutableTreeNode)) return null;
Object obj = ((DefaultMutableTreeNode) node).getUserObject();
return obj instanceof ClientEntry ? (ClientEntry) obj : null;
}
}

View File

@@ -0,0 +1,117 @@
package com.ts3client.ui;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.Group;
import com.ts3client.net.TeamspeakConnection;
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.GrayFilter;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.SwingConstants;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Every regular server group on the server, as a scrollable checkbox list. Groups
* the local client has no power to (un)assign are shown too, so the client's full
* membership is visible, but greyed out and disabled.
*/
final class ServerGroupsDialog extends JDialog {
private final TeamspeakConnection conn;
private final GroupIcons groupIcons;
private final int clientId;
private final ServerTreePanel.Actions actions;
private final JPanel list = new JPanel();
private final JLabel header = new JLabel();
/** Cached grayscale icons, keyed by the source icon's identity. */
private final Map<ImageIcon, ImageIcon> grayscale = new HashMap<>();
ServerGroupsDialog(MainFrame owner, TeamspeakConnection conn, GroupIcons groupIcons,
ClientEntry client, ServerTreePanel.Actions actions) {
super(owner, "Server Groups", true);
this.conn = conn;
this.groupIcons = groupIcons;
this.clientId = client.id;
this.actions = actions;
list.setLayout(new BoxLayout(list, BoxLayout.Y_AXIS));
JScrollPane scroll = new JScrollPane(list);
scroll.setPreferredSize(new Dimension(280, 360));
scroll.getVerticalScrollBar().setUnitIncrement(16);
header.setBorder(BorderFactory.createEmptyBorder(8, 10, 4, 10));
header.setHorizontalAlignment(SwingConstants.LEFT);
JButton close = new JButton("Close");
close.addActionListener(a -> dispose());
JPanel buttons = new JPanel();
buttons.add(close);
getRootPane().setDefaultButton(close);
getContentPane().setLayout(new BorderLayout());
getContentPane().add(header, BorderLayout.NORTH);
getContentPane().add(scroll, BorderLayout.CENTER);
getContentPane().add(buttons, BorderLayout.SOUTH);
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
refresh();
pack();
setLocationRelativeTo(owner);
}
/** Rebuilds the list from the current model state; closes the dialog if the client left. */
void refresh() {
ClientEntry client = conn.getModel().getClient(clientId);
if (client == null) {
dispose();
return;
}
header.setText(client.nickname);
int addPower = conn.getModel().selfPermissionValue("i_group_needed_member_add_power");
int removePower = conn.getModel().selfPermissionValue("i_group_needed_member_remove_power");
List<Group> groups = conn.getModel().allServerGroups();
list.removeAll();
for (Group g : groups) {
boolean assigned = contains(client.serverGroupIds, g.id);
boolean allowed = ClientMenu.canAssign(g, assigned, addPower, removePower);
ImageIcon icon = groupIcons.iconOf(g);
JCheckBox box = new JCheckBox(g.name, allowed ? icon : grayscale(icon));
box.setSelected(assigned);
box.setEnabled(allowed);
box.setAlignmentX(0f);
if (allowed) {
box.addActionListener(a -> actions.setClientServerGroup(client, g, box.isSelected()));
}
list.add(box);
}
list.revalidate();
list.repaint();
}
private ImageIcon grayscale(ImageIcon icon) {
if (icon == null) return null;
return grayscale.computeIfAbsent(icon,
i -> new ImageIcon(GrayFilter.createDisabledImage(i.getImage())));
}
private static boolean contains(int[] ids, int id) {
for (int i : ids) if (i == id) return true;
return false;
}
}

View File

@@ -0,0 +1,28 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelSettings;
import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
/**
* The context menu for the server itself — the tree's root row.
*/
final class ServerMenu {
private ServerMenu() {
}
static JPopupMenu build(ServerTreePanel.Actions actions) {
JPopupMenu menu = new JPopupMenu();
JMenuItem create = new JMenuItem("Create Channel", Icons.of("CHANNEL_CREATE"));
create.addActionListener(a -> actions.createChannel(null, ChannelSettings.Type.PERMANENT));
menu.add(create);
JMenuItem spacer = new JMenuItem("Create Spacer",
Icons.ofAny("CHANNEL_CREATE_SPACER", "CHANNEL_CREATE"));
spacer.setToolTipText("Create a new spacer");
spacer.addActionListener(a -> actions.createSpacer());
menu.add(spacer);
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, groupIcons);
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);
} }
} leftColumn.revalidate();
@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);
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,161 @@
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();
treeActions.refreshGroupsDialog();
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);
@@ -133,7 +155,7 @@ final class ServerTabPane extends JPanel {
cell.setBorder(BorderFactory.createEmptyBorder(1, 0, 1, 0)); cell.setBorder(BorderFactory.createEmptyBorder(1, 0, 1, 0));
JLabel label = new JLabel(tab.title(), hasMic ? Icons.micActiveSmall() : null, JLabel.LEADING); JLabel label = new JLabel(tab.title(), hasMic ? Icons.micActiveSmall() : null, JLabel.LEADING);
label.setFont(Theme.UI_FONT); label.setFont(Theme.uiFont());
label.setToolTipText(hasMic ? "Speaking on this server" : tab.status()); label.setToolTipText(hasMic ? "Speaking on this server" : tab.status());
// A label with a tooltip swallows mouse events, so select the tab explicitly. // A label with a tooltip swallows mouse events, so select the tab explicitly.
label.addMouseListener(new MouseAdapter() { label.addMouseListener(new MouseAdapter() {
@@ -143,14 +165,16 @@ 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.uiFont());
close.setFocusable(false); close.setFocusable(false);
close.setBorder(BorderFactory.createEmptyBorder()); close.setBorder(BorderFactory.createEmptyBorder());
close.setContentAreaFilled(false); close.setContentAreaFilled(false);
close.setMargin(new Insets(0, 0, 0, 0)); close.setMargin(new Insets(0, 0, 0, 0));
close.setForeground(Theme.CHAT_SYSTEM); close.setForeground(Theme.chatSystem());
close.setPreferredSize(new Dimension(14, 14)); close.setPreferredSize(new Dimension(14, 14));
close.setToolTipText("Close this connection"); close.setToolTipText("Close this connection");
close.addActionListener(e -> listener.closeTab(tab)); close.addActionListener(e -> listener.closeTab(tab));

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,296 @@
package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ChannelSettings;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.Group;
import com.ts3client.net.TeamspeakConnection;
import com.ts3client.text.TsLink;
import javax.swing.JOptionPane;
import javax.swing.SwingUtilities;
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 final GroupIcons groupIcons;
private ServerTreePanel treePanel;
private Object currentSelection;
/** The one open {@link ServerGroupsDialog}, if any, refreshed whenever the model changes. */
private ServerGroupsDialog groupsDialog;
ServerTabTreeActions(MainFrame host, ServerTab tab, TeamspeakConnection conn,
ChatPanel chatPanel, InfoPanel infoPanel, GroupIcons groupIcons) {
this.host = host;
this.tab = tab;
this.conn = conn;
this.chatPanel = chatPanel;
this.infoPanel = infoPanel;
this.groupIcons = groupIcons;
}
void attach(ServerTreePanel treePanel) {
this.treePanel = treePanel;
}
/** Called by {@link ServerTabConnectionEvents} whenever the model changes, to live-refresh an open dialog. */
void refreshGroupsDialog() {
if (groupsDialog != null) groupsDialog.refresh();
}
/** 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(), false, conn.getModel(), groupIcons, 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 setClientServerGroup(ClientEntry client, Group group, boolean assign) {
if (conn.isConnected()) conn.setClientServerGroup(client.databaseId, group.id, assign);
}
@Override
public void setClientChannelGroup(ClientEntry client, Group group) {
if (conn.isConnected()) conn.setClientChannelGroup(client.databaseId, client.channelId, group.id);
}
@Override
public void showServerGroupsDialog(ClientEntry client) {
if (!conn.isConnected()) return;
groupsDialog = new ServerGroupsDialog(host, conn, groupIcons, client, this);
groupsDialog.setVisible(true);
groupsDialog = null;
}
/**
* Reads the description from the server before prompting, since the model only
* carries one for clients whose info has been looked at.
*/
@Override
public void changeClientDescription(ClientEntry client) {
if (!conn.isConnected()) return;
conn.requestClientDescription(client.id, (description, error) -> SwingUtilities.invokeLater(() -> {
if (error != null) {
JOptionPane.showMessageDialog(host, "Could not read the description: " + error,
"Error", JOptionPane.ERROR_MESSAGE);
return;
}
String edited = (String) JOptionPane.showInputDialog(host,
"Description for " + client.nickname + ":", "Change Description",
JOptionPane.PLAIN_MESSAGE, Icons.of("EDIT"), null, description);
if (edited == null || edited.equals(description)) return;
conn.setClientDescription(client.id, edited, failure -> SwingUtilities.invokeLater(() -> {
if (failure != null) {
JOptionPane.showMessageDialog(host, "Could not change the description: " + failure,
"Error", JOptionPane.ERROR_MESSAGE);
}
}));
}));
}
@Override
public void renameSelf(String nickname) {
host.changeNickname(nickname);
}
@Override
public void setChannelSubscribed(ChannelNode channel, boolean family, boolean subscribed) {
if (conn.isConnected()) conn.setChannelsSubscribed(channel.familyIds(family), subscribed);
}
@Override
public void editChannel(ChannelNode channel) {
if (!conn.isConnected()) return;
ChannelDialog.toEdit(host, conn, groupIcons, channel).setVisible(true);
}
@Override
public void createChannel(ChannelNode parent, ChannelSettings.Type type) {
if (!conn.isConnected()) return;
ChannelSettings defaults = new ChannelSettings();
defaults.type = type;
ChannelDialog.toCreate(host, conn, groupIcons,
parent == null ? "Create Channel" : "Create Sub-Channel of " + parent.name,
parent == null ? 0 : parent.id, defaults).setVisible(true);
}
@Override
public void createSpacer() {
if (!conn.isConnected()) return;
ChannelSettings defaults = new ChannelSettings();
// Spacers are cosmetic root channels, so they outlive everyone and, being channels,
// need a name no other channel has — which is what the tag's number is for.
defaults.name = Spacers.freeName(conn.getModel().childrenOf(0));
ChannelDialog.toCreate(host, conn, groupIcons, "Create Spacer", 0, defaults)
.setVisible(true);
}
@Override
public boolean isConnected() {
return conn.isConnected();
}
@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,92 @@
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;
import java.util.function.IntPredicate;
/**
* 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 {
/** Whether a client id is our own, which the panel learns on connecting. */
private final IntPredicate isSelf;
ServerTreeCellRenderer(IntPredicate isSelf) {
this.isSelf = isSelf;
}
@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.treeBg());
setBackgroundSelectionColor(Theme.treeSelection());
setBorderSelectionColor(Theme.treeSelection());
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.idleClient());
setFont(Theme.uiFont());
} else {
setText(c.name);
setIcon(iconFor(c));
setForeground(Theme.channelText());
setFont(Theme.uiBold());
}
} 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));
// Talking shows in the client's icon, as it does in TeamSpeak. Marking the
// name as well would also re-measure the row mid-speech, which leaves the
// nickname clipped to an ellipsis for as long as it lasts. Our own name is
// bold throughout, so its width never changes either.
setForeground(Theme.treeText());
setFont(isSelf.test(cl.id) ? Theme.uiBold() : Theme.uiFont());
} else {
// root / server
setText(String.valueOf(obj));
setIcon(Icons.server());
setForeground(Theme.serverText());
setFont(Theme.uiBold());
}
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

@@ -1,34 +1,20 @@
package com.ts3client.ui; package com.ts3client.ui;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.ChannelSettings;
import com.ts3client.net.ClientEntry; import com.ts3client.net.ClientEntry;
import com.ts3client.net.Group;
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.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;
@@ -60,6 +46,23 @@ public final class ServerTreePanel extends JScrollPane {
/** Moves a client into the channel we are currently in. */ /** Moves a client into the channel we are currently in. */
void moveClientToOwnChannel(ClientEntry client); void moveClientToOwnChannel(ClientEntry client);
/** Opens the channel editor for a channel. */
void editChannel(ChannelNode channel);
/**
* Opens the channel creator.
*
* @param parent the channel the new one goes below, or {@code null} for a top-level one
* @param type the channel type to start with
*/
void createChannel(ChannelNode parent, ChannelSettings.Type type);
/** Opens the channel creator pre-filled as a spacer, which is always top-level. */
void createSpacer();
/** Whether the server is there to act on at all. */
boolean isConnected();
/** Open the file repository browser for a channel. */ /** Open the file repository browser for a channel. */
void browseFiles(ChannelNode channel); void browseFiles(ChannelNode channel);
@@ -72,6 +75,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);
@@ -85,19 +91,47 @@ public final class ServerTreePanel extends JScrollPane {
* siblings, or 0 to place it first * siblings, or 0 to place it first
*/ */
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. */ /** Assigns or removes a server group for a client. */
private static final DataFlavor NODE_FLAVOR = new DataFlavor( void setClientServerGroup(ClientEntry client, Group group, boolean assign);
DataFlavor.javaJVMLocalObjectMimeType + ";class=java.lang.Object",
"TeamSpeak tree node"); /** Assigns a channel group for a client in its current channel. */
void setClientChannelGroup(ClientEntry client, Group group);
/** Opens the full server-groups list dialog for a client. */
void showServerGroupsDialog(ClientEntry client);
/** Edits a client's description — our own included. */
void changeClientDescription(ClientEntry client);
/** A new nickname for ourselves, typed into the tree row. */
void renameSelf(String nickname);
}
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); /**
* The nodes only mirror the connection's model, so an edited row is not written
* back into the tree: the new nickname goes to the server, and the row follows
* once it reports the rename.
*/
private final DefaultTreeModel treeModel = new DefaultTreeModel(root) {
@Override
public void valueForPathChanged(TreePath path, Object newValue) {
String nickname = String.valueOf(newValue).trim();
Object edited = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
// The server answers a rename to the name already held with "nickname is
// already in use", so a field closed unchanged is simply left alone.
if (edited instanceof ClientEntry && nickname.equals(((ClientEntry) edited).nickname)) return;
if (!nickname.isEmpty()) actions.renameSelf(nickname);
}
};
/** 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;
private final NicknameCellEditor nicknameEditor;
private int selfClientId = -1; private int selfClientId = -1;
public ServerTreePanel(ServerModel model, GroupIcons groupIcons, Actions actions) { public ServerTreePanel(ServerModel model, GroupIcons groupIcons, Actions actions) {
@@ -105,7 +139,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
@@ -117,22 +151,31 @@ public final class ServerTreePanel extends JScrollPane {
ui.setRightChildIndent(10); ui.setRightChildIndent(10);
} }
tree.setRowHeight(20); tree.setRowHeight(20);
tree.setBackground(Theme.TREE_BG); tree.setBackground(Theme.treeBg());
tree.setFont(Theme.UI_FONT); tree.setFont(Theme.uiFont());
tree.setCellRenderer(new Renderer()); ServerTreeCellRenderer renderer = new ServerTreeCellRenderer(id -> id == selfClientId);
tree.setCellRenderer(renderer);
nicknameEditor = NicknameCellEditor.create(tree, renderer, id -> id == selfClientId);
tree.setCellEditor(nicknameEditor);
tree.setEditable(true);
tree.setPathEditable(nicknameEditor::editsPath);
tree.setInvokesStopCellEditing(true);
setViewportView(tree); setViewportView(tree);
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
// pixels a scroll would reuse are stale; repaint the whole viewport instead. // pixels a scroll would reuse are stale; repaint the whole viewport instead.
getViewport().setScrollMode(JViewport.SIMPLE_SCROLL_MODE); getViewport().setScrollMode(JViewport.SIMPLE_SCROLL_MODE);
// Scrolling slides the rows past a pointer that has not moved, so the outlined
// row has to be worked out again.
getViewport().addChangeListener(e -> tree.refreshHoverRow());
// Within the tree a drag moves the client or channel; dropped elsewhere it // Within the tree a drag moves the client or channel; dropped elsewhere it
// 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) {
@@ -144,6 +187,7 @@ public final class ServerTreePanel extends JScrollPane {
tree.addMouseListener(new MouseAdapter() { tree.addMouseListener(new MouseAdapter() {
@Override @Override
public void mousePressed(MouseEvent e) { public void mousePressed(MouseEvent e) {
selectRowUnder(e);
maybePopup(e); maybePopup(e);
} }
@@ -159,7 +203,11 @@ public final class ServerTreePanel extends JScrollPane {
if (obj instanceof ChannelNode && !Spacers.isSpacer(((ChannelNode) obj).name)) { if (obj instanceof ChannelNode && !Spacers.isSpacer(((ChannelNode) obj).name)) {
actions.joinChannel(((ChannelNode) obj).id); actions.joinChannel(((ChannelNode) obj).id);
} else if (obj instanceof ClientEntry) { } else if (obj instanceof ClientEntry) {
actions.openPrivateChat((ClientEntry) obj); if (((ClientEntry) obj).id == selfClientId) {
tree.startEditingAtPath(tree.pathAt(e.getY()));
} else {
actions.openPrivateChat((ClientEntry) obj);
}
} }
} else if (SwingUtilities.isMiddleMouseButton(e) && obj instanceof ClientEntry) { } else if (SwingUtilities.isMiddleMouseButton(e) && obj instanceof ClientEntry) {
actions.showConnectionInfo((ClientEntry) obj); actions.showConnectionInfo((ClientEntry) obj);
@@ -168,12 +216,37 @@ public final class ServerTreePanel extends JScrollPane {
}); });
} }
/**
* Selects the row a press landed on. A row acts on its whole line, but Swing's own
* hit testing stops at the end of the label, so a press further right would leave
* the selection where it was.
*/
private void selectRowUnder(MouseEvent e) {
if (!SwingUtilities.isLeftMouseButton(e) && !e.isPopupTrigger()) return;
TreePath path = tree.pathAt(e.getY());
if (path == null || path.equals(tree.getSelectionPath())) return;
Rectangle bounds = tree.getPathBounds(path);
// Left of the label is the expand handle, which Swing works the row without selecting it.
if (bounds != null && e.getX() < bounds.x) return;
tree.setSelectionPath(path);
}
public void setSelfClientId(int id) { public void setSelfClientId(int id) {
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.pathAt(e.getY());
if (path == null) return null; if (path == null) return null;
DefaultMutableTreeNode n = (DefaultMutableTreeNode) path.getLastPathComponent(); DefaultMutableTreeNode n = (DefaultMutableTreeNode) path.getLastPathComponent();
return n.getUserObject(); return n.getUserObject();
@@ -181,7 +254,7 @@ public final class ServerTreePanel extends JScrollPane {
private void maybePopup(MouseEvent e) { private void maybePopup(MouseEvent e) {
if (!e.isPopupTrigger()) return; if (!e.isPopupTrigger()) return;
TreePath path = tree.getPathForLocation(e.getX(), e.getY()); TreePath path = tree.pathAt(e.getY());
if (path == null) return; if (path == null) return;
tree.setSelectionPath(path); tree.setSelectionPath(path);
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
@@ -189,11 +262,15 @@ public final class ServerTreePanel extends JScrollPane {
showClientMenu((ClientEntry) obj, e); showClientMenu((ClientEntry) obj, e);
} else if (obj instanceof ChannelNode) { } else if (obj instanceof ChannelNode) {
showChannelMenu((ChannelNode) obj, e); showChannelMenu((ChannelNode) obj, e);
} else if (path.getPathCount() == 1 && actions.isConnected()) {
// The root row is the server itself, whose user object is just its name.
ServerMenu.build(actions).show(tree, e.getX(), e.getY());
} }
} }
private void showClientMenu(ClientEntry client, MouseEvent e) { private void showClientMenu(ClientEntry client, MouseEvent e) {
ClientMenu.build(client, client.id == selfClientId, actions).show(tree, e.getX(), e.getY()); ClientMenu.build(client, client.id == selfClientId, true, model, groupIcons, actions)
.show(tree, e.getX(), e.getY());
} }
private void showChannelMenu(ChannelNode channel, MouseEvent e) { private void showChannelMenu(ChannelNode channel, MouseEvent e) {
@@ -201,10 +278,12 @@ 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 ---- @Override
public void updateUI() {
private static DefaultMutableTreeNode nodeOf(TreePath path) { super.updateUI();
return path == null ? null : (DefaultMutableTreeNode) path.getLastPathComponent(); // The rows stop at the last channel, so the space below them belongs to the tree
// rather than to the window behind it.
if (getViewport() != null) getViewport().setBackground(Theme.treeBg());
} }
/** The path of the tree node showing {@code target}, or {@code null}. */ /** The path of the tree node showing {@code target}, or {@code null}. */
@@ -218,342 +297,42 @@ 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() {
root.setUserObject(model.getServerName()); Object selected = selectedUserObject();
root.removeAllChildren(); rebuilding = true;
List<ChannelNode> roots = model.buildTree(); try {
for (ChannelNode c : roots) { root.setUserObject(model.getServerName());
root.add(buildChannel(c)); root.removeAllChildren();
} List<ChannelNode> roots = model.buildTree();
treeModel.reload(); for (ChannelNode c : roots) {
for (int i = 0; i < tree.getRowCount(); i++) { root.add(buildChannel(c));
tree.expandRow(i); }
treeModel.reload();
for (int i = 0; i < tree.getRowCount(); 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 +372,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 DesignPanel designPanel;
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);
designPanel = new DesignPanel(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", designPanel);
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.
@@ -592,7 +128,7 @@ public final class SettingsDialog extends JDialog {
private void apply() { private void apply() {
writeAudioSettings(settings); writeAudioSettings(settings);
notificationsPanel.apply(); notificationsPanel.apply();
iconPackPanel.apply(); designPanel.apply();
clientVersionPanel.apply(); clientVersionPanel.apply();
hotkeysPanel.apply(); hotkeysPanel.apply();
settings.save(); settings.save();
@@ -620,134 +156,32 @@ public final class SettingsDialog extends JDialog {
/** Leaves without applying, putting back the settings that preview themselves live. */ /** Leaves without applying, putting back the settings that preview themselves live. */
private void cancel() { private void cancel() {
notificationsPanel.revert(); notificationsPanel.revert();
iconPackPanel.revert(); designPanel.revert();
close(); close();
} }
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

@@ -1,5 +1,10 @@
package com.ts3client.ui; package com.ts3client.ui;
import com.ts3client.net.ChannelNode;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -21,6 +26,9 @@ public final class Spacers {
} }
} }
/** The tag's identifying part, which is what keeps two spacers' names apart. */
private static final Pattern TAG = Pattern.compile("^\\[(?:\\*|[lcr])?spacer([^\\]]*)\\]");
private static final Pattern PATTERN = private static final Pattern PATTERN =
Pattern.compile("^\\[(\\*|[lcr])?spacer[^\\]]*\\](.*)$"); Pattern.compile("^\\[(\\*|[lcr])?spacer[^\\]]*\\](.*)$");
@@ -41,6 +49,22 @@ public final class Spacers {
return parse(channelName) != null; return parse(channelName) != null;
} }
/**
* A spacer name no channel among {@code siblings} is using: {@code [spacerN]} with the
* lowest free N. TeamSpeak needs channel names to be unique, and the number in the tag
* is how spacers — which usually all read the same — get away with it.
*/
public static String freeName(List<ChannelNode> siblings) {
Set<String> taken = new HashSet<>();
for (ChannelNode sibling : siblings) {
Matcher m = TAG.matcher(sibling.name == null ? "" : sibling.name);
if (m.find()) taken.add(m.group(1));
}
for (int n = 0; ; n++) {
if (taken.add(Integer.toString(n))) return "[spacer" + n + "]";
}
}
/** Builds the visible label for a spacer at roughly the given character width. */ /** Builds the visible label for a spacer at roughly the given character width. */
public static String render(Spacer s, int width) { public static String render(Spacer s, int width) {
String caption = s.caption == null ? "" : s.caption; String caption = s.caption == null ? "" : s.caption;

View File

@@ -0,0 +1,27 @@
package com.ts3client.ui;
import javax.swing.JSpinner;
import java.awt.Dimension;
/** Shared shaping for the numeric spinners the channel editor is full of. */
final class Spinners {
/** Width that fits a five-digit power without swallowing the rest of a form row. */
private static final int WIDTH = 80;
private Spinners() {
}
/**
* Keeps a spinner to a sensible width and drops the grouping separator: these hold
* permission powers and client counts, which TeamSpeak shows as plain numbers.
*/
static JSpinner compact(JSpinner spinner) {
JSpinner.NumberEditor editor = new JSpinner.NumberEditor(spinner, "#");
spinner.setEditor(editor);
Dimension size = new Dimension(WIDTH, spinner.getPreferredSize().height);
spinner.setPreferredSize(size);
spinner.setMaximumSize(size);
return spinner;
}
}

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.statusBg());
setBorder(BorderFactory.createEmptyBorder(3, 8, 3, 8));
statusLabel.setFont(Theme.uiFont());
codecLabel.setFont(Theme.uiFont());
codecLabel.setForeground(Theme.chatSystem());
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

@@ -1,37 +1,156 @@
package com.ts3client.ui; package com.ts3client.ui;
import com.formdev.flatlaf.ui.FlatUIUtils;
import javax.swing.UIManager;
import java.awt.Color; import java.awt.Color;
import java.awt.Font; import java.awt.Font;
/** Central palette + fonts approximating the light TeamSpeak 3 look. */ /**
* The palette and fonts the client paints with, on top of the active look-and-feel.
*
* <p>Surfaces (window, tree, chat background) and fonts come straight from the
* look-and-feel, so the client follows whatever FlatLaf theme is installed. The
* status colours TeamSpeak gives meaning to — talking, away, muted, channel and
* server names — are ours, in a light and a dark variant; {@link #refresh()} picks
* the variant matching the installed theme.
*/
public final class Theme { public final class Theme {
public static final Color WINDOW_BG = new Color(0xF0F0F0); private static boolean dark;
public static final Color TREE_BG = new Color(0xFFFFFF);
public static final Color TREE_SELECTION = new Color(0xCFE3FB);
public static final Color TREE_TEXT = new Color(0x1E1E1E);
public static final Color CHANNEL_TEXT = new Color(0x21486B);
public static final Color SERVER_TEXT = new Color(0x123456);
public static final Color TALKING = new Color(0x33B35A);
public static final Color IDLE_CLIENT = new Color(0x6E7B87);
public static final Color AWAY = new Color(0xC98A1B);
public static final Color MUTED = new Color(0xC0392B);
public static final Color TOOLBAR_BG = new Color(0xE6E9ED);
public static final Color STATUS_BG = new Color(0xE6E9ED);
public static final Color ACCENT = new Color(0x2C7BE5);
public static final Color CHAT_BG = new Color(0xFAFAFA);
public static final Color CHAT_SYSTEM = new Color(0x8A8A8A);
public static final Color CHAT_NAME = new Color(0x2C7BE5);
public static final Color CHAT_TEXT = new Color(0x202020);
/** Links that leave the client, distinct from the colour used for identities. */
public static final Color LINK = new Color(0x1A5FB4);
public static final Font UI_FONT = new Font("SansSerif", Font.PLAIN, 12);
public static final Font UI_BOLD = new Font("SansSerif", Font.BOLD, 12);
private Theme() { private Theme() {
} }
/** Re-reads the installed look-and-feel; call after changing it. */
static void refresh() {
Color bg = UIManager.getColor("Panel.background");
dark = bg != null && luminance(bg) < 0.5;
}
public static boolean isDark() {
return dark;
}
// ---- fonts ----
public static Font uiFont() {
Font f = UIManager.getFont("Label.font");
return f != null ? f : new Font("SansSerif", Font.PLAIN, 12);
}
public static Font uiBold() {
return uiFont().deriveFont(Font.BOLD);
}
// ---- surfaces, from the look-and-feel ----
public static Color windowBg() {
return ui("Panel.background", 0xF0F0F0, 0x282828);
}
public static Color toolbarBg() {
return ui("ToolBar.background", 0xE6E9ED, 0x282828);
}
public static Color statusBg() {
return windowBg();
}
public static Color treeBg() {
return ui("Tree.background", 0xFFFFFF, 0x1E1E1E);
}
public static Color treeText() {
return ui("Tree.foreground", 0x1E1E1E, 0xE0E0E0);
}
public static Color treeSelection() {
return ui("Tree.selectionBackground", 0xCFE3FB, 0x364B6F);
}
/**
* Behind the row the pointer is over: the same wash the menu bar puts behind a hovered
* menu. The look-and-feel states it as a shift of whatever surface it sits on, and the
* menu bar resolves it against the window, so this does too — a row hover and a menu
* hover then carry the very same colour.
*/
public static Color hover() {
return FlatUIUtils.deriveColor(ui("MenuBar.hoverBackground", 0xE6E6E6, 0x3D3D3D), windowBg());
}
public static Color chatBg() {
return ui("TextPane.background", 0xFAFAFA, 0x1E1E1E);
}
public static Color border() {
return ui("Component.borderColor", 0xD0D0D0, 0x3E3E3E);
}
public static Color chatText() {
return ui("TextPane.foreground", 0x202020, 0xE0E0E0);
}
// ---- status colours ----
public static Color channelText() {
return pick(0x21486B, 0xFFFFFF);
}
/** The colour of a chat line about what happened on the server. */
public static Color chatEvent() {
return pick(0x21486B, 0x8FBCE6);
}
public static Color serverText() {
return pick(0x123456, 0xDCE8F5);
}
public static Color talking() {
return pick(0x33B35A, 0x4CC479);
}
public static Color idleClient() {
return pick(0x6E7B87, 0x9AA6B2);
}
public static Color away() {
return pick(0xC98A1B, 0xE0A83C);
}
public static Color muted() {
return pick(0xC0392B, 0xE05C4C);
}
public static Color accent() {
return pick(0x2C7BE5, 0x5C9BEE);
}
public static Color chatSystem() {
return pick(0x8A8A8A, 0x9A9A9A);
}
public static Color chatName() {
return accent();
}
/** Links that leave the client, distinct from the colour used for identities. */
public static Color link() {
return pick(0x1A5FB4, 0x6EA8FF);
}
private static Color pick(int light, int darkRgb) {
return new Color(dark ? darkRgb : light);
}
private static Color ui(String key, int lightFallback, int darkFallback) {
// Returned as the look-and-feel's own UIResource colour, so components painted with
// it are recoloured automatically when the look-and-feel is switched.
Color c = UIManager.getColor(key);
return c != null ? c : pick(lightFallback, darkFallback);
}
private static double luminance(Color c) {
return (0.2126 * c.getRed() + 0.7152 * c.getGreen() + 0.0722 * c.getBlue()) / 255.0;
}
} }

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

View File

@@ -0,0 +1,17 @@
#
# A darker dark theme than FlatLaf's own, keyed to the toolbar's #282828.
#
# Three levels of surface: the window chrome (toolbar, panels, status bar) at
# @background, the tab strips one step below it, and the content the client shows
# (channel tree, chat log, text fields, lists) one step below those.
#
@background = #282828
@foreground = #e0e0e0
@componentBackground = #1e1e1e
@menuBackground = #232323
# Selected channel tree row, and every other selection with it.
@selectionBackground = #364b6f
TabbedPane.background = #232323

View File

@@ -0,0 +1,14 @@
#
# Look-and-feel defaults for both themes. Loaded by FlatLaf itself, on top of the
# theme's own properties; see LookAndFeelManager.
#
# Softer corners than FlatLaf's default, and a tab strip with room to breathe.
Component.arc = 6
Button.arc = 6
Component.focusWidth = 1
TabbedPane.tabHeight = 26
# A read-only editor pane is a chat log or an information view here, not a disabled
# input, so it keeps the content background instead of the window's.
EditorPane.inactiveBackground = @componentBackground

2
ts3j

Submodule ts3j updated: 0e5724b877...49f2ce874d