Compare commits

...

19 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
53 changed files with 4008 additions and 233 deletions

1
.gitignore vendored
View File

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

View File

@@ -27,6 +27,12 @@ public final class Settings {
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. */
public enum VadMode {
/** Speech-probability detector only. */
@@ -125,6 +131,9 @@ public final class Settings {
/** Extra folder to look for icon packs in, on top of the well-known locations. */
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;
@@ -218,6 +227,7 @@ public final class Settings {
soundPackDir = props.getProperty("soundPackDir", soundPackDir);
iconPack = props.getProperty("iconPack", iconPack);
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);
@@ -259,6 +269,7 @@ public final class Settings {
props.setProperty("soundPackDir", soundPackDir);
props.setProperty("iconPack", iconPack);
props.setProperty("iconPackDir", iconPackDir);
props.setProperty("appearance", appearance.name());
props.setProperty("showStatusBar", Boolean.toString(showStatusBar));
props.setProperty("showMasterVolumeSlider", Boolean.toString(showMasterVolumeSlider));
notifications.store(props);
@@ -273,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) {
if (v == null) return def;
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 String nickname;
public String uniqueId = "";
public int databaseId;
public int type; // 0 = normal voice client, 1 = server-query
public int talkPower;
@@ -39,4 +40,28 @@ public final class ClientEntry {
public boolean isQuery() {
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

@@ -43,6 +43,7 @@ final class ConnectionEventHandler implements TS3Listener {
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());
@@ -58,8 +59,7 @@ final class ConnectionEventHandler implements TS3Listener {
if (e.getClientId() == conn.getSelfClientId()) {
announceOwnRemoval(safeInt(e, "reasonid"), e);
} else {
ClientEntry leaving = conn.getModel().getClient(e.getClientId());
String name = leaving != null ? leaving.nickname : "Client " + e.getClientId();
String name = conn.clientLink(e.getClientId());
announceClientLeft(e);
logClientLeft(e, name);
}
@@ -78,7 +78,7 @@ final class ConnectionEventHandler implements TS3Listener {
announceOwnMove(safeInt(e, "reasonid"), e);
} else {
announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId());
logClientMoved(e, c.nickname, from, e.getTargetChannelId());
logClientMoved(e, conn.clientLink(c.id), from, e.getTargetChannelId());
}
conn.ui.onModelChanged();
}
@@ -86,21 +86,22 @@ final class ConnectionEventHandler implements TS3Listener {
/** A client became visible to us, logged the way native TS3's server tab does. */
private void logClientEntered(ClientJoinEvent e) {
String name = e.getClientNickname();
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.channelName(e.getClientFromId()) + "\"");
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.channelName(e.getClientFromId())
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.channelName(e.getClientTargetId())
+ "\", coming from channel \"" + conn.channelName(e.getClientFromId()) + "\"");
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.channelName(e.getClientTargetId()) + "\"");
conn.log(name + " connected to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
}
}
@@ -119,14 +120,14 @@ final class ConnectionEventHandler implements TS3Listener {
conn.log(name + " was banned from the server by " + invokerName(e) + suffix);
break;
case REASON_CHANNEL_KICK:
conn.log(name + " left: was kicked to channel \"" + conn.channelName(e.getClientTargetId())
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.channelName(e.getClientTargetId()) + "\"");
conn.log(name + " left, heading to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
break;
case REASON_SWITCHED:
conn.log(name + " left, switched to channel \"" + conn.channelName(e.getClientTargetId()) + "\"");
conn.log(name + " left, switched to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
break;
default:
conn.log(name + " disconnected");
@@ -139,16 +140,16 @@ final class ConnectionEventHandler implements TS3Listener {
String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")";
switch (safeInt(e, "reasonid")) {
case REASON_MOVED:
conn.log(name + " was moved from channel \"" + conn.channelName(fromChannel) + "\" to \""
+ conn.channelName(toChannel) + "\" by " + invokerName(e));
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.channelName(fromChannel) + "\" to \""
+ conn.channelName(toChannel) + "\" by " + invokerName(e) + suffix);
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.channelName(fromChannel) + "\" to \""
+ conn.channelName(toChannel) + "\"");
conn.log(name + " switched from channel \"" + conn.channelLink(fromChannel) + "\" to \""
+ conn.channelLink(toChannel) + "\"");
}
}
@@ -291,6 +292,10 @@ final class ConnectionEventHandler implements TS3Listener {
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);
@@ -306,7 +311,7 @@ final class ConnectionEventHandler implements TS3Listener {
if (renamed) {
conn.sound(safeInt(e, "invokerid") == conn.getSelfClientId()
? SoundEvent.CLIENT_RENAMED_BY_YOU : SoundEvent.CLIENT_RENAMED_BY_OTHER, vars);
conn.log(oldName + " is now known as " + c.nickname);
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);
@@ -340,7 +345,7 @@ final class ConnectionEventHandler implements TS3Listener {
if (conn.isConnected()) {
conn.sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER,
SoundEvent.CHANNEL_CREATED_BY_OTHER), conn.channelVars(cid, e.get("invokername")));
conn.log("Channel \"" + name + "\" was created by " + invokerName(e));
conn.log("Channel \"" + conn.channelLink(cid) + "\" was created by " + invokerName(e));
}
conn.ui.onModelChanged();
}
@@ -364,6 +369,7 @@ final class ConnectionEventHandler implements TS3Listener {
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
@@ -372,12 +378,25 @@ final class ConnectionEventHandler implements TS3Listener {
: byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU,
SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER),
conn.channelVars(ch.id, e.get("invokername")));
conn.log("Channel \"" + ch.name + "\" was edited by " + invokerName(e));
conn.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"));
@@ -388,7 +407,7 @@ final class ConnectionEventHandler implements TS3Listener {
if (conn.isConnected()) {
conn.sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER,
SoundEvent.CHANNEL_MOVED_BY_OTHER), conn.channelVars(ch.id, e.get("invokername")));
conn.log("Channel \"" + ch.name + "\" was moved by " + invokerName(e));
conn.log("Channel \"" + conn.channelLink(ch.id) + "\" was moved by " + invokerName(e));
}
conn.ui.onModelChanged();
}
@@ -423,6 +442,8 @@ final class ConnectionEventHandler implements TS3Listener {
@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,
@@ -432,11 +453,14 @@ final class ConnectionEventHandler implements TS3Listener {
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,
@@ -446,6 +470,7 @@ final class ConnectionEventHandler implements TS3Listener {
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
@@ -465,10 +490,9 @@ final class ConnectionEventHandler implements TS3Listener {
conn.ui.onModelChanged();
}
/** A client's nickname for a log line, falling back to its id once it has left. */
/** 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) {
ClientEntry c = conn.getModel().getClient(clientId);
return c != null ? c.nickname : "Client " + clientId;
return conn.clientLink(clientId);
}
/** Picks the event variant matching who caused the change: us, another client, or the server. */
@@ -509,7 +533,18 @@ final class ConnectionEventHandler implements TS3Listener {
}
private static Group toGroup(BaseEvent e, int id) {
return new Group(id, e.get("name"), TeamspeakConnection.safeLong(e, "iconid"), safeInt(e, "sortid"));
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
@@ -544,13 +579,17 @@ final class ConnectionEventHandler implements TS3Listener {
@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 ----
private static String invokerName(BaseEvent e) {
/** 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"));
return name.isEmpty() ? "the server" : name;
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) {

View File

@@ -9,11 +9,26 @@ public final class Group {
public final long iconId;
/** Display order among groups; lower comes first. */
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.name = name;
this.iconId = iconId;
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.InputStream;
import java.nio.file.Files;
import java.util.List;
import java.util.Map;
import java.util.Set;
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. */
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. */
public interface Fetcher {
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, Group> serverGroups = 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";
/** The channel group everyone starts in, e.g. "Guest" — not worth offering to (re)assign. */
private int defaultChannelGroupId;
public synchronized void clear() {
channels.clear();
clients.clear();
serverGroups.clear();
channelGroups.clear();
permissionNames.clear();
nextPermissionId = 0;
selfPermissionValues.clear();
defaultChannelGroupId = 0;
}
// ---- groups ----
@@ -78,6 +94,64 @@ public final class ServerModel {
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() {
return serverName;
}
@@ -86,6 +160,14 @@ public final class ServerModel {
if (name != null && !name.isEmpty()) this.serverName = name;
}
public synchronized int defaultChannelGroupId() {
return defaultChannelGroupId;
}
public synchronized void setDefaultChannelGroupId(int id) {
this.defaultChannelGroupId = id;
}
// ---- channels ----
public synchronized ChannelNode putChannel(int id, String name, int parentId, int order) {
@@ -129,6 +211,41 @@ public final class ServerModel {
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"}. */
public synchronized String channelPath(int id) {
ChannelNode c = channels.get(id);

View File

@@ -19,6 +19,7 @@ import com.ts3client.net.filetransfer.FileTransferManager;
import com.ts3client.net.filetransfer.RemoteFile;
import com.ts3client.sound.SoundEvent;
import com.ts3client.sound.SoundNotifier;
import com.ts3client.text.TsLink;
import java.io.File;
import java.net.InetSocketAddress;
@@ -27,6 +28,10 @@ import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
/**
@@ -36,8 +41,8 @@ import java.util.function.Consumer;
*/
public final class TeamspeakConnection implements TS3Listener {
/** Upper bound for a downloaded group icon; anything larger is not an icon. */
private static final int MAX_ICON_BYTES = 1024 * 1024;
/** Upper bound for a group icon; anything larger is not an icon. Also used by {@link ChannelAdmin}. */
static final int MAX_ICON_BYTES = 1024 * 1024;
/** The protocol's reason ids for the two flavours of <b>clientkick</b>. */
private static final int REASON_KICK_CHANNEL = 4;
@@ -58,6 +63,8 @@ public final class TeamspeakConnection implements TS3Listener {
private final ConnectionEventHandler events = new ConnectionEventHandler(this);
/** Builds {@code getconnectioninfo} snapshots; also package-private for {@link ConnectionEventHandler}. */
final ConnectionStatsCollector stats = new ConnectionStatsCollector(this);
/** Channel editing and the server's icon store; package-private for {@link ConnectionEventHandler}. */
final ChannelAdmin channels = new ChannelAdmin(this);
/** Package-private: read directly by {@link ConnectionStatsCollector}. */
LocalTeamspeakClientSocket client;
@@ -65,6 +72,12 @@ public final class TeamspeakConnection implements TS3Listener {
private VoiceOutput playback;
private LocalIdentity identity;
private FileTransferManager fileTransfers;
/**
* Runs ts3j's event callbacks. Supplying it ourselves (rather than leaving ts3j to make
* its own) is what lets {@link #awaitEventsProcessed()} tell when everything the server
* has already sent has been handled. Closed together with the socket.
*/
private ExecutorService eventExecutor;
private volatile boolean connected;
private volatile int selfClientId = -1;
@@ -231,6 +244,12 @@ public final class TeamspeakConnection implements TS3Listener {
microphone.setMutedTalkListener(this::onTalkingWhileMuted);
client = new LocalTeamspeakClientSocket();
eventExecutor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "ts3j-events");
t.setDaemon(true);
return t;
});
client.setCommandExecutorService(eventExecutor);
client.setIdentity(identity);
client.setNickname(nickname);
client.setHWID("ts3jswing-" + Integer.toHexString(nickname.hashCode()));
@@ -255,6 +274,11 @@ public final class TeamspeakConnection implements TS3Listener {
// Protocol connection established; anything past this point is best-effort.
selfClientId = client.getClientId();
// Our own row usually arrives among the enter-view events the connect
// handshake carries, i.e. before our id was known here, so it has to be
// marked once it is.
ClientEntry ourselves = model.getClient(selfClientId);
if (ourselves != null) ourselves.self = true;
fileTransfers = new FileTransferManager(client, () -> serverHost);
client.setMicrophone(microphone);
icons.retryFailed();
@@ -264,6 +288,7 @@ public final class TeamspeakConnection implements TS3Listener {
disconnectAnnounced = false;
deafened = false;
ui.onConnected();
requestPermissionNames();
ui.onStatus("Retrieving channels…");
syncAll();
@@ -396,6 +421,8 @@ public final class TeamspeakConnection implements TS3Listener {
}
// The name comes with initserver; servergetvariables never reports it.
model.setServerName(client.getServerName());
model.setDefaultChannelGroupId(
(int) safeLong(client.getServerProperties().get("virtualserver_default_channel_group")));
try {
for (Channel ch : client.listChannels()) {
model.putChannel(ch.getId(), ch.getName(), ch.getParentChannelId(), ch.getOrder());
@@ -484,6 +511,8 @@ public final class TeamspeakConnection implements TS3Listener {
VoiceOutput out = playback;
LocalTeamspeakClientSocket sock = client;
FileTransferManager ft = fileTransfers;
ExecutorService events = eventExecutor;
eventExecutor = null;
microphone = null;
playback = null;
client = null;
@@ -516,6 +545,7 @@ public final class TeamspeakConnection implements TS3Listener {
} catch (Exception ignored) {
}
}
if (events != null) events.shutdownNow();
}
// ---- self actions ----
@@ -647,6 +677,51 @@ public final class TeamspeakConnection implements TS3Listener {
}, "ts3j-move-channel").start();
}
/**
* Asks the server to (re-)send its permission definitions, which arrive as a burst
* of {@code notifypermissionlist} events — the only way to learn permission names,
* since {@code notifyclientneededpermissions} (which resolves our own power for
* them) only ever reports numeric ids. Needed for {@link ServerModel#selfPermissionValue}
* to work; harmless if it's slow or fails, since group-assignment eligibility just
* won't be known yet.
*/
private void requestPermissionNames() {
new Thread(() -> {
try {
client.executeCommand(new SingleCommand("permissionlist", ProtocolRole.CLIENT)).complete();
} catch (Exception ignored) {
// Best-effort: the menus fall back to treating unresolved permissions as 0.
}
}, "ts3j-permission-list").start();
}
/** Assigns or removes a server group for a client (by database id, as {@code servergroupaddclient} needs). */
public void setClientServerGroup(int clientDatabaseId, int groupId, boolean assign) {
new Thread(() -> {
try {
if (assign) client.serverGroupAddClient(groupId, clientDatabaseId);
else client.serverGroupRemoveClient(groupId, clientDatabaseId);
} catch (Exception e) {
error("Could not " + (assign ? "assign" : "remove") + " server group: " + rootMessage(e));
}
}, "ts3j-server-group").start();
}
/** Assigns a channel group for a client in the channel it currently sits in. */
public void setClientChannelGroup(int clientDatabaseId, int channelId, int groupId) {
new Thread(() -> {
try {
SingleCommand cmd = new SingleCommand("setclientchannelgroup", ProtocolRole.CLIENT);
cmd.add(new CommandSingleParameter("cgid", Integer.toString(groupId)));
cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId)));
cmd.add(new CommandSingleParameter("cldbid", Integer.toString(clientDatabaseId)));
client.executeCommand(cmd).complete();
} catch (Exception e) {
error("Could not set channel group: " + rootMessage(e));
}
}, "ts3j-channel-group").start();
}
/**
* Subscribes to (or unsubscribes from) a set of channels in one command. The model
* is left alone: the server answers with the subscription events that update it.
@@ -818,6 +893,181 @@ public final class TeamspeakConnection implements TS3Listener {
}, "ts3j-clientinfo").start();
}
/**
* Reads a client's description straight from the server, so an editor opens on the
* live text instead of whatever an earlier info request cached.
*/
public void requestClientDescription(int clientId, BiConsumer<String, String> callback) {
run("ts3j-client-description", callback, () -> {
Client c = client.getClientInfo(clientId);
String description = c == null ? "" : orEmpty(c.get("client_description"));
ClientEntry e = model.getClient(clientId);
if (e != null) e.description = description;
return description;
});
}
/**
* Changes a client's description. Unlike the nickname or the away state, this is not
* a {@code clientupdate} property even for ourselves: the server only takes it
* through {@code clientedit}, gated by {@code b_client_modify_own_description}.
*
* @param callback given {@code null} on success, or the failure message
*/
public void setClientDescription(int clientId, String description, Consumer<String> callback) {
boolean self = clientId == selfClientId;
run("ts3j-client-description-set", (ignored, error) -> callback.accept(error), () -> {
SingleCommand cmd = new SingleCommand("clientedit", ProtocolRole.CLIENT);
cmd.add(new CommandSingleParameter("clid", Integer.toString(clientId)));
cmd.add(new CommandSingleParameter("client_description", description));
client.executeCommand(cmd).complete();
// Only the other clients are told about our own update, so the local entry
// (and the info panel showing it) has to be caught up here.
if (self) {
ClientEntry e = model.getClient(clientId);
if (e != null) e.description = description;
ui.onInfoUpdated();
}
return null;
});
}
// ---- channel administration ----
/**
* Reads a channel's editable properties, delivering them (or a failure message) to
* {@code callback} off the UI thread.
*/
public void requestChannelSettings(int channelId, BiConsumer<ChannelSettings, String> callback) {
run("ts3j-channel-read", callback, () -> channels.readSettings(channelId));
}
/**
* Reads the permissions set directly on a channel, as name/value pairs. Fails when the
* server does not let us look at them.
*/
public void requestChannelPermissions(int channelId,
BiConsumer<Map<String, Integer>, String> callback) {
run("ts3j-channel-perms", callback, () -> channels.readPermissions(channelId));
}
/**
* Applies an edited channel: its changed properties first, then its needed-power
* permissions.
*
* @param callback given {@code null} on success, or the failure message
*/
public void applyChannelEdit(int channelId, Map<String, String> changes,
Map<String, Integer> setPermissions,
Collection<String> removePermissions,
Consumer<String> callback) {
run("ts3j-channel-edit", (ignored, error) -> callback.accept(error), () -> {
channels.edit(channelId, changes);
channels.writePermissions(channelId, setPermissions, removePermissions);
if (changes.containsKey("channel_description")) {
ChannelNode node = model.getChannel(channelId);
// the server does not notify the invoker of their own description change
if (node != null && node.descriptionLoaded) {
node.descriptionLoaded = false;
requestChannelInfo(channelId);
}
}
return null;
});
}
/**
* Creates a channel below {@code parentId} (0 being the top level) and gives it the
* channel permissions it was created with.
*
* @param callback given the new channel's id, or the failure message. A channel whose
* permissions could not be set still exists, and is reported as an error
* saying so.
*/
public void createChannel(int parentId, Map<String, String> properties,
Map<String, Integer> permissions,
BiConsumer<Integer, String> callback) {
run("ts3j-channel-create", callback, () -> {
int channelId = channels.create(parentId, properties);
try {
channels.writePermissions(channelId, permissions, List.of());
} catch (Exception e) {
throw new IllegalStateException("The channel was created, but its permissions"
+ " could not be set: " + rootMessage(e));
}
return channelId;
});
}
/** The ids of the icons uploaded to this virtual server. */
public void requestServerIcons(BiConsumer<List<Long>, String> callback) {
run("ts3j-icon-list", callback, channels::listIcons);
}
/** Uploads an image as a server icon and reports the new icon's id. */
public void uploadIcon(File source, BiConsumer<Long, String> callback) {
run("ts3j-icon-upload", callback, () -> channels.uploadIcon(source));
}
/** Deletes a server icon; {@code callback} is given {@code null} on success. */
public void deleteIcon(long iconId, Consumer<String> callback) {
run("ts3j-icon-delete", (ignored, error) -> callback.accept(error), () -> {
channels.deleteIcon(iconId);
return null;
});
}
/** Work that produces a value or an error message, run on its own thread. */
private interface Job<T> {
T call() throws Exception;
}
/**
* Runs a blocking server request in the background and hands the result — or the
* failure message — to {@code callback}, which therefore never runs on the UI thread.
*/
private <T> void run(String threadName, BiConsumer<T, String> callback, Job<T> job) {
new Thread(() -> {
if (!connected || client == null) {
callback.accept(null, "Not connected");
return;
}
try {
callback.accept(job.call(), null);
} catch (Exception e) {
callback.accept(null, rootMessage(e));
}
}, threadName).start();
}
/** The live socket. Package-private for {@link ChannelAdmin}. */
LocalTeamspeakClientSocket socket() {
LocalTeamspeakClientSocket sock = client;
if (sock == null || !connected) throw new IllegalStateException("Not connected");
return sock;
}
/** Package-private for {@link ChannelAdmin}. */
FileTransferManager fileTransfers() {
return fileTransfers;
}
/**
* Blocks until ts3j has finished dispatching every event it had already queued, so a
* caller that just saw a command complete can be sure the notifications the server sent
* alongside it have been handled. Must not be called from the event thread itself.
*/
void awaitEventsProcessed() {
ExecutorService events = eventExecutor;
if (events == null) return;
try {
events.submit(() -> {
}).get(5, TimeUnit.SECONDS);
} catch (Exception ignored) {
// A shutting-down or wedged event thread just means the caller gets what arrived.
}
}
// ---- icons ----
/** Group icons for this server: bundled defaults plus the server's own uploads. */
@@ -960,6 +1210,28 @@ public final class TeamspeakConnection implements TS3Listener {
return ch != null ? ch.name : "channel #" + channelId;
}
/**
* A channel's name as the log wants it: TeamSpeak's own {@code channel://} link, which
* the chat log turns into the same clickable entry a dragged-in channel produces. A
* channel we no longer know (a deleted one, say) is named in plain text instead.
*/
String channelLink(int channelId) {
ChannelNode ch = model.getChannel(channelId);
return ch != null ? TsLink.channelBBCode(channelId, ch.name) : channelName(channelId);
}
/** The same for a client, named by the model. */
String clientLink(int clientId) {
ClientEntry c = model.getClient(clientId);
return c != null ? clientLink(clientId, c.uniqueId, c.nickname) : "Client " + clientId;
}
/** The same for a client an event describes, which may already be gone from the model. */
String clientLink(int clientId, String uniqueId, String nickname) {
if (clientId <= 0 || nickname == null || nickname.isEmpty()) return orEmpty(nickname);
return TsLink.clientBBCode(clientId, orEmpty(uniqueId), nickname);
}
/** The placeholder values a pack may reference for an action involving a client. */
Map<String, String> clientVars(int clientId, String fallbackName) {
ClientEntry c = model.getClient(clientId);

View File

@@ -52,6 +52,8 @@ public final class FileTransferManager {
/** TeamSpeak error id returned by {@code ftgetfilelist} for an empty directory. */
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;
/** 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.
*/
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() {
@Override
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
* 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)
throws Exception {
String directory = path == null || path.isEmpty() ? "/" : path;
SingleCommand cmd = new SingleCommand("ftgetfilelist", ProtocolRole.CLIENT,
new CommandSingleParameter("cid", Integer.toString(channelId)),
new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword),
new CommandSingleParameter("path", path == null || path.isEmpty() ? "/" : path));
new CommandSingleParameter("path", directory));
List<RemoteFile> files = new ArrayList<>();
Iterable<SingleCommand> rows;
FileListRequest request = new FileListRequest(channelId, directory);
fileListRequest = request;
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) {
if (e.getErrorId() == ERROR_DATABASE_EMPTY_RESULT) return files;
throw e;
if (e.getErrorId() != ERROR_DATABASE_EMPTY_RESULT) throw e;
} catch (TimeoutException e) {
throw new IOException("The server did not finish listing " + directory);
} finally {
fileListRequest = null;
}
for (SingleCommand row : rows) {
Map<String, String> m = row.toMap();
String name = m.get("name");
if (name == null || name.isEmpty()) continue;
files.add(new RemoteFile(
return new ArrayList<>(request.files);
}
/** Collects one {@code notifyfilelist} entry for the listing in flight. */
private void collectListEntry(Map<String, String> row) {
FileListRequest request = fileListRequest;
if (request == null) return;
String cid = row.get("cid");
if (cid != null && !cid.isEmpty() && parseLong(cid) != request.channelId) return;
String name = row.get("name");
if (name == null || name.isEmpty()) return;
request.files.add(new RemoteFile(
name,
m.getOrDefault("path", path),
parseLong(m.get("size")),
"0".equals(m.get("type")),
parseLong(m.get("datetime"))));
}
return files;
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}). */
@@ -351,6 +376,15 @@ public final class FileTransferManager {
String command = e.getCommand();
if (command == null) return;
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"));
if (ftfid == null) return;
CompletableFuture<Map<String, String>> future = pendingInits.get(ftfid);
@@ -454,4 +488,17 @@ public final class FileTransferManager {
String m = r.getMessage();
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

@@ -37,6 +37,10 @@ public final class BBCode {
/** CSS class on links that leave the client, so they are visibly not an identity. */
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() {
}
@@ -70,6 +74,29 @@ public final class BBCode {
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. */
public static String escape(String s) {
if (s == null) return "";

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>
<ts3j.version>1.0.3</ts3j.version>
<jsvg.version>2.1.0</jsvg.version>
<flatlaf.version>3.7.2</flatlaf.version>
<surefire.version>3.5.6</surefire.version>
</properties>
@@ -50,6 +51,11 @@
<artifactId>jsvg</artifactId>
<version>${jsvg.version}</version>
</dependency>
<dependency>
<groupId>com.formdev</groupId>
<artifactId>flatlaf</artifactId>
<version>${flatlaf.version}</version>
</dependency>
<dependency>
<groupId>com.ts3client</groupId>
<artifactId>ts3-client-core</artifactId>

View File

@@ -35,6 +35,10 @@
<groupId>com.github.weisj</groupId>
<artifactId>jsvg</artifactId>
</dependency>
<dependency>
<groupId>com.formdev</groupId>
<artifactId>flatlaf</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -84,6 +88,15 @@
<include>**</include>
</includes>
</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>
<artifact>*:*</artifact>
<excludes>

View File

@@ -2,14 +2,14 @@ package com.ts3client;
import com.ts3client.config.Settings;
import com.ts3client.ui.IconTheme;
import com.ts3client.ui.LookAndFeelManager;
import com.ts3client.ui.MainFrame;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
/**
* Application entry point. Loads settings, applies the system look-and-feel and
* shows the main window on the Swing event dispatch thread.
* Application entry point. Loads settings, installs the look-and-feel and shows the
* main window on the Swing event dispatch thread.
*/
public final class Main {
@@ -23,11 +23,7 @@ public final class Main {
final Settings settings = Settings.load();
SwingUtilities.invokeLater(() -> {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (Exception ignored) {
// fall back to cross-platform L&F
}
LookAndFeelManager.install(settings.appearance);
IconTheme.get().reload(settings);
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;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.ChannelSettings;
import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
@@ -20,6 +21,18 @@ final class ChannelMenu {
join.addActionListener(a -> actions.joinChannel(channel.id));
menu.add(join);
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);
JMenuItem files = new JMenuItem("Browse files", Icons.of("FILETRANSFER"));
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

@@ -16,8 +16,6 @@ import javax.swing.SwingUtilities;
import javax.swing.event.HyperlinkEvent;
import javax.swing.text.BadLocationException;
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.Component;
import java.awt.Cursor;
@@ -75,7 +73,7 @@ public final class ChatPanel extends JPanel {
public ChatPanel() {
super(new BorderLayout());
tabs.setFont(Theme.UI_FONT);
tabs.setFont(Theme.uiFont());
tabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT);
addTab(serverTab, false);
addTab(channelTab, false);
@@ -186,14 +184,18 @@ public final class ChatPanel extends JPanel {
/** System notices always land in the server tab. */
public void appendSystem(String text) {
edt(() -> serverTab.appendLine("<span style=\"color:#8A8A8A\">" + stamp()
edt(() -> serverTab.appendLine("<span class=\"muted\">" + stamp()
+ BBCode.escape(text) + "</span>"));
}
/** Server-side events (client joins/leaves/moves, group changes, channel edits, …). */
/**
* 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 style=\"color:" + hex(Theme.CHANNEL_TEXT) + "\">" + stamp()
+ BBCode.escape(text) + "</span>"));
edt(() -> serverTab.appendLine("<span class=\"event\">" + stamp()
+ BBCode.linksToHtml(text) + "</span>"));
}
public void appendServerMessage(int fromId, String from, String text) {
@@ -284,13 +286,12 @@ public final class ChatPanel extends JPanel {
final Target target;
final int clientId;
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;
/** Set for a static-content tab (e.g. a moved-out description); null for a conversation. */
final String noteKey;
final Runnable onClose;
private final Icon icon;
private final HTMLDocument doc;
private JLabel titleLabel;
Tab(Target target, int clientId, String title) {
@@ -310,30 +311,14 @@ public final class ChatPanel extends JPanel {
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>");
doc = (HTMLDocument) log.getDocument();
log.addHyperlinkListener(e -> {
if (e.getEventType() != HyperlinkEvent.EventType.ACTIVATED) return;
onLink(e.getDescription());
});
scroll = new JScrollPane(log);
HtmlStyles.fillViewport(log);
scroll.setBorder(BorderFactory.createEmptyBorder());
}
@@ -355,14 +340,14 @@ public final class ChatPanel extends JPanel {
JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
p.setOpaque(false);
titleLabel = new JLabel(title, icon, JLabel.LEADING);
titleLabel.setFont(Theme.UI_FONT);
titleLabel.setFont(Theme.uiFont());
p.add(titleLabel);
dragReorder.attach(p);
dragReorder.attach(titleLabel);
if (closable) {
JLabel close = new JLabel("×");
close.setFont(Theme.UI_BOLD);
close.setForeground(Theme.CHAT_SYSTEM);
close.setFont(Theme.uiBold());
close.setForeground(Theme.chatSystem());
close.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
close.setToolTipText("Close chat");
close.addMouseListener(new MouseAdapter() {
@@ -386,8 +371,8 @@ public final class ChatPanel extends JPanel {
void setUnread(boolean unread) {
if (titleLabel == null) return;
titleLabel.setFont(unread ? Theme.UI_BOLD : Theme.UI_FONT);
titleLabel.setForeground(unread ? Theme.ACCENT : null);
titleLabel.setFont(unread ? Theme.uiBold() : Theme.uiFont());
titleLabel.setForeground(unread ? Theme.accent() : null);
}
void appendMessage(int fromId, String from, String text) {
@@ -395,8 +380,8 @@ public final class ChatPanel extends JPanel {
String sender = fromId > 0
? "<a class=\"" + BBCode.IDENTITY_LINK_CLASS + "\" href=\""
+ BBCode.escape(TsLink.clientHref(fromId, "", from)) + "\">" + name + "</a>"
: "<b style=\"color:" + hex(Theme.CHAT_NAME) + "\">" + name + "</b>";
appendLine("<span style=\"color:#8A8A8A\">" + stamp() + "</span>"
: "<span class=\"name\">" + name + "</span>";
appendLine("<span class=\"muted\">" + stamp() + "</span>"
+ sender + ": " + BBCode.toHtml(text));
}
@@ -408,6 +393,7 @@ public final class ChatPanel extends JPanel {
void appendLine(String html) {
try {
HTMLDocument doc = (HTMLDocument) log.getDocument();
doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>");
log.setCaretPosition(doc.getLength());
} catch (BadLocationException | IOException ignored) {
@@ -415,8 +401,4 @@ public final class ChatPanel extends JPanel {
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;
import com.ts3client.net.ChannelNode;
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.JPopupMenu;
import java.util.List;
/**
* The context menu for a client, shared by the server tree and the clickable
@@ -14,7 +20,13 @@ final class 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();
if (!self) {
JMenuItem pm = new JMenuItem("Open text chat", Icons.of("PLAYER_CHAT"));
@@ -44,18 +56,117 @@ final class ClientMenu {
menu.add(me);
}
menu.addSeparator();
JMenuItem findInTree = new JMenuItem("Find Client in Channel Tree", Icons.of("PLAYER_ON"));
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"));
info.addActionListener(a -> actions.showConnectionInfo(client));
menu.add(info);
if (!self) {
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"));
moveHere.addActionListener(a -> actions.moveClientToOwnChannel(client));
menu.add(moveHere);
}
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;
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.add(buildSummary(), BorderLayout.NORTH);
content.add(buildTabs(), BorderLayout.CENTER);
@@ -92,7 +92,7 @@ public final class ConnectionInfoDialog extends JDialog {
private JPanel buildSummary() {
JPanel grid = new JPanel(new GridBagLayout());
grid.setBackground(Theme.WINDOW_BG);
grid.setBackground(Theme.windowBg());
int row = 0;
addRow(grid, row++, "Address", addressValue);
addRow(grid, row++, "Client version", versionValue);
@@ -106,8 +106,8 @@ public final class ConnectionInfoDialog extends JDialog {
private JTabbedPane buildTabs() {
JTabbedPane tabs = new JTabbedPane();
tabs.setFont(Theme.UI_FONT);
tabs.setBackground(Theme.WINDOW_BG);
tabs.setFont(Theme.uiFont());
tabs.setBackground(Theme.windowBg());
tabs.addTab("Total", totalTab);
tabs.addTab("Speech", speechTab);
tabs.addTab("Keep Alive", keepAliveTab);
@@ -118,7 +118,7 @@ public final class ConnectionInfoDialog extends JDialog {
private JPanel buildButtons() {
JPanel bar = new JPanel();
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
bar.setBackground(Theme.WINDOW_BG);
bar.setBackground(Theme.windowBg());
bar.add(Box.createHorizontalGlue());
JButton close = new JButton("Close");
close.addActionListener(e -> dispose());
@@ -128,8 +128,8 @@ public final class ConnectionInfoDialog extends JDialog {
private static JLabel value() {
JLabel l = new JLabel("");
l.setFont(Theme.UI_FONT);
l.setForeground(Theme.TREE_TEXT);
l.setFont(Theme.uiFont());
l.setForeground(Theme.treeText());
return l;
}
@@ -140,7 +140,7 @@ public final class ConnectionInfoDialog extends JDialog {
lc.anchor = GridBagConstraints.WEST;
lc.insets = new Insets(2, 0, 2, 16);
JLabel key = new JLabel(label);
key.setFont(Theme.UI_FONT);
key.setFont(Theme.uiFont());
key.setForeground(new Color(0x5A6B7B));
grid.add(key, lc);
@@ -255,7 +255,7 @@ public final class ConnectionInfoDialog extends JDialog {
KindTab() {
super(new GridBagLayout());
setBackground(Theme.WINDOW_BG);
setBackground(Theme.windowBg());
setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
int row = 0;
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;
/**
* Options page for icon packs: which pack the user interface is drawn with, where
* to look for more of them, and a viewer showing everything the pack contains.
* Options page for how the client looks: the light or dark look-and-feel, and the icon
* 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
* behind the dialog; cancelling puts the previous one back.
* <p>Both apply right away so the change can be seen in the window behind the dialog;
* cancelling puts the previous ones back.
*/
final class IconPackPanel extends JPanel {
final class DesignPanel extends JPanel {
/** Size of the tiles in the icon viewer. */
private static final int PREVIEW_SIZE = 32;
@@ -44,18 +45,22 @@ final class IconPackPanel extends JPanel {
private final Settings settings;
private final String originalPackId;
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 JLabel packInfo = new JLabel();
private final JTextField packDirField;
private final DefaultListModel<String> previewModel = new DefaultListModel<>();
private final JList<String> preview = new JList<>(previewModel);
IconPackPanel(Settings settings) {
DesignPanel(Settings settings) {
super(new BorderLayout(0, 8));
this.settings = settings;
this.originalPackId = settings.iconPack;
this.originalPackDir = settings.iconPackDir;
this.originalAppearance = settings.appearance;
this.packDirField = new JTextField(settings.iconPackDir, 18);
setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
@@ -65,18 +70,23 @@ final class IconPackPanel extends JPanel {
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() {
settings.appearance = selectedAppearance();
settings.iconPackDir = packDirField.getText().trim();
Object selected = packCombo.getSelectedItem();
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() {
settings.iconPack = originalPackId;
settings.iconPackDir = originalPackDir;
IconTheme.get().reload(settings);
if (settings.appearance != originalAppearance) {
settings.appearance = originalAppearance;
LookAndFeelManager.switchTo(originalAppearance);
}
}
// ---- 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;
addRow(p, c, row++, new JLabel("Theme:"), appearanceCombo);
addRow(p, c, row++, new JLabel("Icon pack:"), packCombo);
c.gridx = 1;
c.gridy = row++;
@@ -159,6 +183,18 @@ final class IconPackPanel extends JPanel {
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() {
Object selected = packCombo.getSelectedItem();
IconPack pack = selected instanceof IconPack p ? p : null;

View File

@@ -12,9 +12,13 @@ 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
@@ -34,11 +38,84 @@ final class DropIndicatorTree extends JTree {
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. */
@@ -67,6 +144,11 @@ final class DropIndicatorTree extends JTree {
@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();
@@ -74,7 +156,7 @@ final class DropIndicatorTree extends JTree {
Graphics2D g2 = (Graphics2D) g.create();
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(Theme.ACCENT);
g2.setColor(Theme.accent());
if (highlight != null || loc.getChildIndex() < 0) {
Rectangle r = getPathBounds(highlight != null ? highlight : loc.getPath());
if (r != null) {
@@ -91,6 +173,43 @@ final class DropIndicatorTree extends JTree {
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();

View File

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

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) {
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() {
@@ -129,7 +129,7 @@ public final class Icons {
}
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() {
@@ -137,7 +137,7 @@ public final class Icons {
}
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. */
@@ -151,7 +151,7 @@ public final class Icons {
}
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() {
@@ -199,12 +199,12 @@ public final class Icons {
/** A channel commander that is not talking. */
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. */
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 ----
@@ -257,8 +257,8 @@ public final class Icons {
Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.drawImage(channel.getImage(), 0, (height - channel.getIconHeight()) / 2, null);
g.setColor(Theme.IDLE_CLIENT);
g.setFont(Theme.UI_FONT);
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);
@@ -270,7 +270,7 @@ public final class Icons {
/** The toolbar's away marker. */
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() {
@@ -329,7 +329,7 @@ public final class Icons {
}
private static void paintMicMuted(Graphics2D g) {
g.setColor(Theme.MUTED);
g.setColor(Theme.muted());
g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140);
@@ -339,7 +339,7 @@ public final class Icons {
}
private static void paintSpeakerMuted(Graphics2D g) {
g.setColor(Theme.MUTED);
g.setColor(Theme.muted());
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
@@ -350,7 +350,7 @@ public final class Icons {
/** Same shape as {@link #paintMicMuted}, but grey: unavailable, not muted by choice. */
private static void paintMicDisabled(Graphics2D g) {
g.setColor(Theme.IDLE_CLIENT);
g.setColor(Theme.idleClient());
g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140);
@@ -361,7 +361,7 @@ public final class Icons {
/** Same shape as {@link #paintSpeakerMuted}, but grey: unavailable, not muted by choice. */
private static void paintSpeakerDisabled(Graphics2D g) {
g.setColor(Theme.IDLE_CLIENT);
g.setColor(Theme.idleClient());
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
@@ -373,7 +373,7 @@ public final class Icons {
/** {@link #paintMicMuted}, marked with a small dot: silenced, but not reported as muted. */
private static void paintMicLocalMuted(Graphics2D g) {
paintMicMuted(g);
g.setColor(Theme.ACCENT);
g.setColor(Theme.accent());
g.fillOval(11, 10, 4, 4);
}
@@ -387,7 +387,7 @@ public final class Icons {
}
private static void paintDisconnect(Graphics2D g) {
g.setColor(Theme.MUTED);
g.setColor(Theme.muted());
g.setStroke(new BasicStroke(2f));
g.drawLine(3, 8, 8, 8);
g.fillRoundRect(8, 5, 5, 6, 2, 2);
@@ -414,7 +414,7 @@ public final class Icons {
}
private static void paintMicActive(Graphics2D g) {
g.setColor(Theme.TALKING);
g.setColor(Theme.talking());
g.fillOval(1, 1, 14, 14);
g.setColor(Color.WHITE);
g.fillRoundRect(6, 3, 4, 6, 2, 2);
@@ -450,7 +450,7 @@ public final class Icons {
}
private static void paintApp(Graphics2D g) {
g.setColor(Theme.ACCENT);
g.setColor(Theme.accent());
g.fillRoundRect(1, 1, 14, 14, 4, 4);
g.setColor(Color.WHITE);
g.setStroke(new BasicStroke(1.6f));

View File

@@ -34,7 +34,7 @@ public final class InfoPanel extends JScrollPane {
private static final String TOGGLE_HREF = "app:toggle-info-tab";
private final JEditorPane pane = new JEditorPane();
private final JEditorPane pane = HtmlStyles.pane("font-family:sans-serif; font-size:11px;");
private DescriptionHandler descriptionHandler;
private ChannelNode shownChannel;
private ClientEntry shownClient;
@@ -43,20 +43,6 @@ public final class InfoPanel extends JScrollPane {
private boolean inChatTab;
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.addHyperlinkListener(e -> {
if (e.getEventType() != javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) return;
@@ -69,10 +55,16 @@ public final class InfoPanel extends JScrollPane {
}
});
setViewportView(pane);
setBorder(BorderFactory.createLineBorder(new java.awt.Color(0xD0D0D0)));
HtmlStyles.fillViewport(pane);
clear();
}
@Override
public void updateUI() {
super.updateUI();
setBorder(BorderFactory.createLineBorder(Theme.border()));
}
public void clear() {
shownChannel = null;
shownClient = null;
@@ -118,7 +110,7 @@ public final class InfoPanel extends JScrollPane {
private String body() {
if (shownChannel != null) return channelBody(shownChannel);
if (shownClient != null) return clientBody(shownClient, shownModel, shownIcons);
return "<i style='color:#8a8a8a'>Select a channel or client to see details.</i>";
return "<i class='muted'>Select a channel or client to see details.</i>";
}
private static String channelBody(ChannelNode ch) {
@@ -132,16 +124,16 @@ public final class InfoPanel extends JScrollPane {
if (ch.description != null && !ch.description.isEmpty()) {
sb.append("<div>").append(multiline(ch.description)).append("</div>");
} else if (ch.descriptionLoaded) {
sb.append("<i style='color:#8a8a8a'>No description.</i>");
sb.append("<i class='muted'>No description.</i>");
} else {
sb.append("<i style='color:#8a8a8a'>Loading description…</i>");
sb.append("<i class='muted'>Loading description…</i>");
}
return sb.toString();
}
private static String clientBody(ClientEntry cl, ServerModel model, IconRepository icons) {
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);
if (serverGroups.isEmpty()) {
@@ -184,8 +176,7 @@ public final class InfoPanel extends JScrollPane {
}
private void setHtml(String body) {
pane.setText("<html><body style='font-family:sans-serif;font-size:11px;color:#202020'>"
+ body + "</body></html>");
pane.setText("<html><body>" + body + "</body></html>");
pane.setCaretPosition(0);
}
@@ -206,7 +197,7 @@ public final class InfoPanel extends JScrollPane {
}
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>");
}
@@ -219,7 +210,4 @@ public final class InfoPanel extends JScrollPane {
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);
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);
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

@@ -73,7 +73,7 @@ final class MainToolbar extends JToolBar {
this.listener = listener;
setFloatable(false);
setBackground(Theme.TOOLBAR_BG);
setBackground(Theme.toolbarBg());
setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
setComponentPopupMenu(buildContextMenu());

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

@@ -69,7 +69,7 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
this.groupIcons = new GroupIcons(conn.getIcons());
this.selfState = new ServerTabSelfState(conn);
this.chatPanel = new ChatPanel();
this.treeActions = new ServerTabTreeActions(host, this, conn, chatPanel, infoPanel);
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);

View File

@@ -90,6 +90,7 @@ final class ServerTabConnectionEvents implements ConnectionListener {
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);

View File

@@ -155,7 +155,7 @@ final class ServerTabPane extends JPanel {
cell.setBorder(BorderFactory.createEmptyBorder(1, 0, 1, 0));
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());
// A label with a tooltip swallows mouse events, so select the tab explicitly.
label.addMouseListener(new MouseAdapter() {
@@ -169,12 +169,12 @@ final class ServerTabPane extends JPanel {
dragReorder.attach(label);
JButton close = new JButton("");
close.setFont(Theme.UI_FONT);
close.setFont(Theme.uiFont());
close.setFocusable(false);
close.setBorder(BorderFactory.createEmptyBorder());
close.setContentAreaFilled(false);
close.setMargin(new Insets(0, 0, 0, 0));
close.setForeground(Theme.CHAT_SYSTEM);
close.setForeground(Theme.chatSystem());
close.setPreferredSize(new Dimension(14, 14));
close.setToolTipText("Close this connection");
close.addActionListener(e -> listener.closeTab(tab));

View File

@@ -1,11 +1,14 @@
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;
/**
@@ -24,23 +27,32 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions {
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) {
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;
@@ -74,7 +86,8 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions {
chatPanel.appendSystem("That client is no longer on the server.");
return;
}
ClientMenu.build(client, client.id == conn.getSelfClientId(), this).show(source, x, y);
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) {
@@ -171,11 +184,92 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions {
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;

View File

@@ -8,6 +8,7 @@ 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
@@ -15,14 +16,21 @@ import java.awt.Component;
*/
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.TREE_BG);
setBackgroundSelectionColor(Theme.TREE_SELECTION);
setBorderSelectionColor(Theme.TREE_SELECTION);
setBackgroundNonSelectionColor(Theme.treeBg());
setBackgroundSelectionColor(Theme.treeSelection());
setBorderSelectionColor(Theme.treeSelection());
Object obj = ((DefaultMutableTreeNode) value).getUserObject();
if (obj instanceof ChannelNode) {
@@ -31,13 +39,13 @@ final class ServerTreeCellRenderer extends DefaultTreeCellRenderer {
if (spacer != null) {
setText(Spacers.render(spacer, 40));
setIcon(null);
setForeground(Theme.IDLE_CLIENT);
setFont(Theme.UI_FONT);
setForeground(Theme.idleClient());
setFont(Theme.uiFont());
} else {
setText(c.name);
setIcon(iconFor(c));
setForeground(Theme.CHANNEL_TEXT);
setFont(Theme.UI_BOLD);
setForeground(Theme.channelText());
setFont(Theme.uiBold());
}
} else if (obj instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) obj;
@@ -45,14 +53,18 @@ final class ServerTreeCellRenderer extends DefaultTreeCellRenderer {
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);
// 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.SERVER_TEXT);
setFont(Theme.UI_BOLD);
setForeground(Theme.serverText());
setFont(Theme.uiBold());
}
return this;
}

View File

@@ -1,7 +1,9 @@
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.ServerModel;
import javax.swing.DropMode;
@@ -12,6 +14,7 @@ import javax.swing.plaf.basic.BasicTreeUI;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.DefaultTreeModel;
import javax.swing.tree.TreePath;
import java.awt.Rectangle;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.List;
@@ -43,6 +46,23 @@ public final class ServerTreePanel extends JScrollPane {
/** Moves a client into the channel we are currently in. */
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. */
void browseFiles(ChannelNode channel);
@@ -71,16 +91,47 @@ public final class ServerTreePanel extends JScrollPane {
* siblings, or 0 to place it first
*/
void moveChannel(ChannelNode channel, int newParentId, int orderPredecessorId);
/** Assigns or removes a server group for a client. */
void setClientServerGroup(ClientEntry client, Group group, boolean assign);
/** 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 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 GroupIcons groupIcons;
private final Actions actions;
private final NicknameCellEditor nicknameEditor;
private int selfClientId = -1;
public ServerTreePanel(ServerModel model, GroupIcons groupIcons, Actions actions) {
@@ -100,14 +151,22 @@ public final class ServerTreePanel extends JScrollPane {
ui.setRightChildIndent(10);
}
tree.setRowHeight(20);
tree.setBackground(Theme.TREE_BG);
tree.setFont(Theme.UI_FONT);
tree.setCellRenderer(new ServerTreeCellRenderer());
tree.setBackground(Theme.treeBg());
tree.setFont(Theme.uiFont());
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);
getViewport().setBackground(Theme.TREE_BG);
// 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.
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
// yields the TS3 link BBCode, which the chat input accepts as plain text.
@@ -128,6 +187,7 @@ public final class ServerTreePanel extends JScrollPane {
tree.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
selectRowUnder(e);
maybePopup(e);
}
@@ -143,8 +203,12 @@ public final class ServerTreePanel extends JScrollPane {
if (obj instanceof ChannelNode && !Spacers.isSpacer(((ChannelNode) obj).name)) {
actions.joinChannel(((ChannelNode) obj).id);
} else if (obj instanceof ClientEntry) {
if (((ClientEntry) obj).id == selfClientId) {
tree.startEditingAtPath(tree.pathAt(e.getY()));
} else {
actions.openPrivateChat((ClientEntry) obj);
}
}
} else if (SwingUtilities.isMiddleMouseButton(e) && obj instanceof ClientEntry) {
actions.showConnectionInfo((ClientEntry) obj);
}
@@ -152,6 +216,21 @@ 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) {
this.selfClientId = id;
}
@@ -167,7 +246,7 @@ public final class ServerTreePanel extends JScrollPane {
}
private Object nodeAt(MouseEvent e) {
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
TreePath path = tree.pathAt(e.getY());
if (path == null) return null;
DefaultMutableTreeNode n = (DefaultMutableTreeNode) path.getLastPathComponent();
return n.getUserObject();
@@ -175,7 +254,7 @@ public final class ServerTreePanel extends JScrollPane {
private void maybePopup(MouseEvent e) {
if (!e.isPopupTrigger()) return;
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
TreePath path = tree.pathAt(e.getY());
if (path == null) return;
tree.setSelectionPath(path);
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
@@ -183,11 +262,15 @@ public final class ServerTreePanel extends JScrollPane {
showClientMenu((ClientEntry) obj, e);
} else if (obj instanceof ChannelNode) {
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) {
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) {
@@ -195,6 +278,14 @@ public final class ServerTreePanel extends JScrollPane {
ChannelMenu.build(channel, actions).show(tree, e.getX(), e.getY());
}
@Override
public void updateUI() {
super.updateUI();
// 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}. */
private TreePath pathOf(Object target) {
java.util.Enumeration<?> nodes = root.breadthFirstEnumeration();

View File

@@ -32,7 +32,7 @@ public final class SettingsDialog extends JDialog {
private final DevicesPanel devicesPanel;
private final VoiceActivationPanel voiceActivationPanel;
private final NotificationsPanel notificationsPanel;
private final IconPackPanel iconPackPanel;
private final DesignPanel designPanel;
private final HotkeysPanel hotkeysPanel;
private final ClientVersionPanel clientVersionPanel;
@@ -46,7 +46,7 @@ public final class SettingsDialog extends JDialog {
this.onApply = onApply;
notificationsPanel = new NotificationsPanel(settings, sounds);
iconPackPanel = new IconPackPanel(settings);
designPanel = new DesignPanel(settings);
hotkeysPanel = new HotkeysPanel(hotkeys);
clientVersionPanel = new ClientVersionPanel(settings);
devicesPanel = new DevicesPanel(settings, livePlayback,
@@ -58,7 +58,7 @@ public final class SettingsDialog extends JDialog {
tabs.addTab("Playback / Capture", scrollable(devicesPanel));
tabs.addTab("Voice Activation", scrollable(voiceActivationPanel));
tabs.addTab("Notifications", notificationsPanel);
tabs.addTab("Design", iconPackPanel);
tabs.addTab("Design", designPanel);
tabs.addTab("Hotkeys", hotkeysPanel);
tabs.addTab("Client Version", scrollable(clientVersionPanel));
@@ -128,7 +128,7 @@ public final class SettingsDialog extends JDialog {
private void apply() {
writeAudioSettings(settings);
notificationsPanel.apply();
iconPackPanel.apply();
designPanel.apply();
clientVersionPanel.apply();
hotkeysPanel.apply();
settings.save();
@@ -156,7 +156,7 @@ public final class SettingsDialog extends JDialog {
/** Leaves without applying, putting back the settings that preview themselves live. */
private void cancel() {
notificationsPanel.revert();
iconPackPanel.revert();
designPanel.revert();
close();
}

View File

@@ -1,5 +1,10 @@
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.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 =
Pattern.compile("^\\[(\\*|[lcr])?spacer[^\\]]*\\](.*)$");
@@ -41,6 +49,22 @@ public final class Spacers {
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. */
public static String render(Spacer s, int width) {
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

@@ -13,11 +13,11 @@ final class StatusBar extends JPanel {
StatusBar() {
super(new BorderLayout());
setBackground(Theme.STATUS_BG);
setBackground(Theme.statusBg());
setBorder(BorderFactory.createEmptyBorder(3, 8, 3, 8));
statusLabel.setFont(Theme.UI_FONT);
codecLabel.setFont(Theme.UI_FONT);
codecLabel.setForeground(Theme.CHAT_SYSTEM);
statusLabel.setFont(Theme.uiFont());
codecLabel.setFont(Theme.uiFont());
codecLabel.setForeground(Theme.chatSystem());
add(statusLabel, BorderLayout.WEST);
add(codecLabel, BorderLayout.EAST);
}

View File

@@ -1,37 +1,156 @@
package com.ts3client.ui;
import com.formdev.flatlaf.ui.FlatUIUtils;
import javax.swing.UIManager;
import java.awt.Color;
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 static final Color WINDOW_BG = new Color(0xF0F0F0);
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 static boolean dark;
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,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