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>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ out/
|
||||
|
||||
# The bundled proprietary TeamSpeak 3 client (reference binary, not our source)
|
||||
/TeamSpeak3-Client-linux_amd64/
|
||||
/re-android/
|
||||
|
||||
# Packages
|
||||
*.jar
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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 {
|
||||
|
||||
/** 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<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<>();
|
||||
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<Integer, Integer> values = new ConcurrentHashMap<>();
|
||||
|
||||
PermRequest(int channelId) {
|
||||
this.channelId = channelId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}.
|
||||
*
|
||||
* <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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Integer, String> 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<ChannelNode> siblingsOf(int channelId) {
|
||||
ChannelNode channel = channels.get(channelId);
|
||||
if (channel == null) return new ArrayList<>();
|
||||
List<ChannelNode> 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);
|
||||
|
||||
@@ -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 <b>clientkick</b>. */
|
||||
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<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);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
|
||||
@@ -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("<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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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 = "<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'>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;
|
||||
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<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;
|
||||
|
||||
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<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(() -> {
|
||||
if (error == null) {
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
ok.setEnabled(true);
|
||||
JOptionPane.showMessageDialog(this, "Could not save the channel: " + error,
|
||||
"Error", JOptionPane.ERROR_MESSAGE);
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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.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;
|
||||
}
|
||||
}
|
||||
@@ -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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.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("<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);
|
||||
}
|
||||
}
|
||||
@@ -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.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<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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
2
ts3j
2
ts3j
Submodule ts3j updated: 0e5724b877...49f2ce874d
Reference in New Issue
Block a user