From d8a56566d4ab0404f02168cf261b1917eeb33412 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Wed, 19 Aug 2026 15:11:49 +0000 Subject: [PATCH] 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 --- .gitignore | 1 + .../java/com/ts3client/net/ChannelAdmin.java | 231 +++++++++++ .../com/ts3client/net/ChannelSettings.java | 190 +++++++++ .../ts3client/net/ConnectionEventHandler.java | 1 + .../com/ts3client/net/IconRepository.java | 7 + .../java/com/ts3client/net/ServerModel.java | 28 ++ .../ts3client/net/TeamspeakConnection.java | 130 +++++- .../ts3client/ui/ChannelAdvancedPanel.java | 187 +++++++++ .../com/ts3client/ui/ChannelAudioPanel.java | 166 ++++++++ .../com/ts3client/ui/ChannelEditDialog.java | 374 ++++++++++++++++++ .../java/com/ts3client/ui/ChannelMenu.java | 4 + .../ts3client/ui/ChannelPermissionsPanel.java | 148 +++++++ .../ts3client/ui/ChannelStandardPanel.java | 195 +++++++++ .../ts3client/ui/DescriptionEditorDialog.java | 171 ++++++++ .../com/ts3client/ui/IconChooserDialog.java | 270 +++++++++++++ .../ts3client/ui/ServerTabTreeActions.java | 6 + .../com/ts3client/ui/ServerTreePanel.java | 3 + .../main/java/com/ts3client/ui/Spinners.java | 27 ++ ts3j | 2 +- 19 files changed, 2138 insertions(+), 3 deletions(-) create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/ChannelAdmin.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/ChannelSettings.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/ChannelAdvancedPanel.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/ChannelAudioPanel.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/ChannelEditDialog.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/ChannelPermissionsPanel.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/ChannelStandardPanel.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/DescriptionEditorDialog.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/IconChooserDialog.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/Spinners.java diff --git a/.gitignore b/.gitignore index 05b140d..e29f3b4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ out/ # The bundled proprietary TeamSpeak 3 client (reference binary, not our source) /TeamSpeak3-Client-linux_amd64/ +/re-android/ # Packages *.jar diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ChannelAdmin.java b/ts3-client/core/src/main/java/com/ts3client/net/ChannelAdmin.java new file mode 100644 index 0000000..b03dad9 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/ChannelAdmin.java @@ -0,0 +1,231 @@ +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. + * + *

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 { + + /** 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()); + } + + /** Applies {@code changes} (property name to value) with a single {@code channeledit}. */ + void edit(int channelId, Map 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 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 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 byName = new LinkedHashMap<>(); + for (Map.Entry 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 set, Collection remove) + throws Exception { + if (!set.isEmpty()) { + List parts = new ArrayList<>(set.size()); + for (Map.Entry 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 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 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_}. + */ + List listIcons() throws Exception { + List ids = new ArrayList<>(); + for (RemoteFile file : fileTransfers().list(0, "", "/")) { + if (file.isDirectory() || !file.getName().startsWith("icon_")) continue; + long id = parseLong(file.getName().substring("icon_".length())); + if (id > 0) ids.add(id); + } + return ids; + } + + /** + * 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 values = new ConcurrentHashMap<>(); + + PermRequest(int channelId) { + this.channelId = channelId; + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ChannelSettings.java b/ts3-client/core/src/main/java/com/ts3client/net/ChannelSettings.java new file mode 100644 index 0000000..0f388e8 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/ChannelSettings.java @@ -0,0 +1,190 @@ +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}. + * + *

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 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 changesFrom(ChannelSettings original) { + Map 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; + } + + private static void putIfChanged(Map into, String key, String value, String was) { + if (!value.equals(was)) into.put(key, value); + } + + private static void putIfChanged(Map into, String key, int value, int was) { + if (value != was) into.put(key, Integer.toString(value)); + } + + private static void putIfChanged(Map 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 props, String key) { + String v = props.get(key); + return v == null ? "" : v; + } + + private static boolean boolOf(Map props, String key) { + return "1".equals(props.get(key)); + } + + private static int intOf(Map 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 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; + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java index bfec351..db5a301 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java @@ -562,6 +562,7 @@ 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 ---- diff --git a/ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java b/ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java index 286fd1c..a64c65f 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java @@ -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 BUNDLED_IDS = List.of(100L, 200L, 300L, 500L, 600L); + /** Downloads {@code /icon_} from the connected server's file repository. */ public interface Fetcher { byte[] fetchIcon(long iconId) throws Exception; diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java index 1832325..c31d52a 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java @@ -132,6 +132,16 @@ public final class ServerModel { * 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 entry : permissionNames.entrySet()) { if (entry.getValue().equals(name)) { @@ -201,6 +211,24 @@ 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 siblingsOf(int channelId) { + ChannelNode channel = channels.get(channelId); + if (channel == null) return new ArrayList<>(); + List siblings = new ArrayList<>(); + for (ChannelNode c : channels.values()) { + if (c.parentId == channel.parentId) siblings.add(c); + } + // 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. + sortSiblings(siblings); + siblings.remove(channel); + return siblings; + } + /** 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); diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index 62fe48f..55d8e60 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -27,6 +27,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 +40,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 clientkick. */ private static final int REASON_KICK_CHANNEL = 4; @@ -58,6 +62,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 +71,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 +243,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())); @@ -487,6 +505,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; @@ -519,6 +539,7 @@ public final class TeamspeakConnection implements TS3Listener { } catch (Exception ignored) { } } + if (events != null) events.shutdownNow(); } // ---- self actions ---- @@ -866,6 +887,111 @@ public final class TeamspeakConnection implements TS3Listener { }, "ts3j-clientinfo").start(); } + // ---- 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 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, 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 changes, + Map setPermissions, + Collection removePermissions, + Consumer callback) { + run("ts3j-channel-edit", (ignored, error) -> callback.accept(error), () -> { + channels.edit(channelId, changes); + channels.writePermissions(channelId, setPermissions, removePermissions); + return null; + }); + } + + /** The ids of the icons uploaded to this virtual server. */ + public void requestServerIcons(BiConsumer, 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 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 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 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 void run(String threadName, BiConsumer callback, Job 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. */ diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelAdvancedPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelAdvancedPanel.java new file mode 100644 index 0000000..6aa1242 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelAdvancedPanel.java @@ -0,0 +1,187 @@ +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.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 ChannelEditDialog.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("

Delay in seconds after temporary channels are deleted" + + " after the last user left the channel.

"); + 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); + 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); + 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); + + c.gridx = 0; + c.gridy = 3; + c.weighty = 1; + panel.add(Box.createGlue(), c); + return panel; + } + + 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 (JRadioButton radio : choices) { + radio.setAlignmentX(0f); + box.add(radio); + } + spinner.setAlignmentX(0f); + box.add(Box.createVerticalStrut(4)); + box.add(spinner); + box.add(Box.createVerticalGlue()); + return box; + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelAudioPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelAudioPanel.java new file mode 100644 index 0000000..2801dce --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelAudioPanel.java @@ -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 ChannelEditDialog.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 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; + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelEditDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelEditDialog.java new file mode 100644 index 0000000..423457c --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelEditDialog.java @@ -0,0 +1,374 @@ +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 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. + */ +final class ChannelEditDialog 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 = "Name of this channel displayed in the tree." + + "" + + "" + + "" + + "" + + "" + + "" + + "
Syntax: \"[?Spacer#]Text\"
Where \"?\" stands for an alignment" + + " (r=right, c=center, l=left),
\"*\" will repeat the text to fill the whole line.
Change \"#\" to get a unique channel name.
Use one of the three-character-blocks as text for a" + + " special spacer: \"---\", \"...\", \"-.-\", \"___\", \"-..\"
"; + + private final TeamspeakConnection conn; + private final GroupIcons groupIcons; + private final ChannelNode channel; + + 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 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; + + ChannelEditDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, ChannelNode channel) { + super(owner, "Edit Channel: " + channel.name, ModalityType.APPLICATION_MODAL); + this.conn = conn; + this.groupIcons = groupIcons; + this.channel = channel; + this.standardPanel = new ChannelStandardPanel(conn.getModel().siblingsOf(channel.id)); + + 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); + + getContentPane().setLayout(new BorderLayout(0, 6)); + getContentPane().add(header(), BorderLayout.NORTH); + getContentPane().add(tabs, BorderLayout.CENTER); + getContentPane().add(buttons(), BorderLayout.SOUTH); + + setEnabledForLoading(false); + Dialogs.closeOnEscape(this); + setDefaultCloseOperation(DISPOSE_ON_CLOSE); + setSize(new Dimension(560, 620)); + setMinimumSize(new Dimension(480, 520)); + setLocationRelativeTo(owner); + + load(); + } + + // ---- 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.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); + })); + } + + 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; + } + + Map changes = edited.changesFrom(original); + Map setPermissions = permissionsPanel.changed(); + List 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(() -> { + if (error == null) { + dispose(); + return; + } + ok.setEnabled(true); + JOptionPane.showMessageDialog(this, "Could not save the channel: " + error, + "Error", JOptionPane.ERROR_MESSAGE); + })); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelMenu.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelMenu.java index 4c8188b..d06b0b9 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelMenu.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelMenu.java @@ -20,6 +20,10 @@ final class ChannelMenu { join.addActionListener(a -> actions.joinChannel(channel.id)); menu.add(join); menu.addSeparator(); + 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)); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelPermissionsPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelPermissionsPanel.java new file mode 100644 index 0000000..3a88662 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelPermissionsPanel.java @@ -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. + * + *

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 spinners = new LinkedHashMap<>(); + /** The values the server reported, to tell an edited power from an untouched one. */ + private final Map 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 permissions) { + original.clear(); + for (Map.Entry 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 changed() { + Map changed = new LinkedHashMap<>(); + for (Map.Entry 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 cleared() { + List cleared = new ArrayList<>(); + for (Map.Entry 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.CHAT_SYSTEM); + } + + 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; + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelStandardPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelStandardPanel.java new file mode 100644 index 0000000..9c445e1 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelStandardPanel.java @@ -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 ChannelEditDialog.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 sortAfter = new JComboBox<>(); + private final JSpinner talkPower = new JSpinner(new SpinnerNumberModel(0, 0, 9999, 1)); + + private Consumer typeListener; + + /** + * @param siblings the channels this one shares a parent with, in tree order and + * excluding the channel being edited + */ + ChannelStandardPanel(List 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("The default channel is the place where new clients join on login." + + "
There can be only one default channel for the whole server."); + + DefaultComboBoxModel 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 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; + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/DescriptionEditorDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/DescriptionEditorDialog.java new file mode 100644 index 0000000..203f676 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/DescriptionEditorDialog.java @@ -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. + * + *

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.CHAT_BG); + 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.CHAT_SYSTEM); + 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("U", "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("" + + BBCode.toHtml(area.getText()) + ""); + preview.setCaretPosition(0); + } + cards.show(body, on ? PREVIEW_CARD : EDIT_CARD); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/IconChooserDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/IconChooserDialog.java new file mode 100644 index 0000000..f5a821e --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/IconChooserDialog.java @@ -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. + * + *

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 remoteModel = new DefaultListModel<>(); + private final JList remoteList = new JList<>(remoteModel); + private final JList 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 localModel = (DefaultListModel) 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.CHAT_SYSTEM); + + 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 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 list) { + JList 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 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; + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java index 576e249..5670b5f 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java @@ -205,6 +205,12 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions { if (conn.isConnected()) conn.setChannelsSubscribed(channel.familyIds(family), subscribed); } + @Override + public void editChannel(ChannelNode channel) { + if (!conn.isConnected()) return; + new ChannelEditDialog(host, conn, groupIcons, channel).setVisible(true); + } + @Override public void browseFiles(ChannelNode channel) { if (!conn.canTransferFiles()) return; diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java index 7ffb016..426e345 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java @@ -44,6 +44,9 @@ 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); + /** Open the file repository browser for a channel. */ void browseFiles(ChannelNode channel); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/Spinners.java b/ts3-client/swing/src/main/java/com/ts3client/ui/Spinners.java new file mode 100644 index 0000000..7c38a27 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/Spinners.java @@ -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; + } +} diff --git a/ts3j b/ts3j index 0e5724b..49f2ce8 160000 --- a/ts3j +++ b/ts3j @@ -1 +1 @@ -Subproject commit 0e5724b877195afdca34810fa299a00f22db28e6 +Subproject commit 49f2ce874dd55910da8a199c81eef408ddafbf9a