Compare commits
26 Commits
047c89404c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 98719d717e | |||
| 35f10ed14c | |||
| d952ffd856 | |||
| acfe25db15 | |||
| 08e6c51e90 | |||
| acd438dd14 | |||
| 6e65797f25 | |||
| fa99cadc44 | |||
| 9ba0982f25 | |||
| 275de77c8b | |||
| 189620df7f | |||
| cb05b097a6 | |||
| bcc034e975 | |||
| f68c1e297c | |||
| 52bd180b56 | |||
| f2885d33ad | |||
| d8a56566d4 | |||
| b29f518d59 | |||
| acf0221837 | |||
| e5b607fc29 | |||
| d722377508 | |||
| b694845381 | |||
| cf5ba94092 | |||
| e5fb87a796 | |||
| de02f88f1c | |||
| 71084ea309 |
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
|
||||
|
||||
@@ -27,6 +27,12 @@ public final class Settings {
|
||||
CONTINUOUS
|
||||
}
|
||||
|
||||
/** Which look-and-feel variant the window is painted with. */
|
||||
public enum Appearance {
|
||||
LIGHT,
|
||||
DARK
|
||||
}
|
||||
|
||||
/** Voice-activation strategy, mirroring the TS3 client's VAD modes. */
|
||||
public enum VadMode {
|
||||
/** Speech-probability detector only. */
|
||||
@@ -95,6 +101,12 @@ public final class Settings {
|
||||
public boolean music = false;
|
||||
/** Master playback gain, 0..1 (may exceed 1 for boost up to 2). */
|
||||
public double outputVolume = 1.0;
|
||||
/**
|
||||
* Global multiplier on top of {@link #outputVolume} and {@link #soundVolume},
|
||||
* controlled from the toolbar's Master Volume slider so both can be ridden
|
||||
* with one control. 0..2, same boost headroom as the other volumes.
|
||||
*/
|
||||
public double masterVolume = 1.0;
|
||||
/** Microphone input gain multiplier applied before VAD/encode. */
|
||||
public double inputVolume = 1.0;
|
||||
/** Remove steady background noise from the microphone (spectral denoise). */
|
||||
@@ -119,6 +131,15 @@ public final class Settings {
|
||||
/** Extra folder to look for icon packs in, on top of the well-known locations. */
|
||||
public String iconPackDir = "";
|
||||
|
||||
/** Look-and-feel variant; see {@link Appearance}. */
|
||||
public Appearance appearance = Appearance.LIGHT;
|
||||
|
||||
// ---- window chrome ----
|
||||
/** Whether the status bar at the bottom of the window is shown. */
|
||||
public boolean showStatusBar = true;
|
||||
/** Whether the Master Volume slider is shown on the toolbar. */
|
||||
public boolean showMasterVolumeSlider = true;
|
||||
|
||||
/** Which actions make a sound, and which ones are important enough to survive muting. */
|
||||
public final NotificationSettings notifications = new NotificationSettings();
|
||||
|
||||
@@ -155,6 +176,16 @@ public final class Settings {
|
||||
return new File(identityFile);
|
||||
}
|
||||
|
||||
/** {@link #outputVolume} scaled by the Master Volume slider. */
|
||||
public double effectiveOutputVolume() {
|
||||
return outputVolume * masterVolume;
|
||||
}
|
||||
|
||||
/** {@link #soundVolume} scaled by the Master Volume slider. */
|
||||
public double effectiveSoundVolume() {
|
||||
return soundVolume * masterVolume;
|
||||
}
|
||||
|
||||
/** The directory holding all persistent client state (settings, identities, caches). */
|
||||
public static File configDir() {
|
||||
return DIR;
|
||||
@@ -185,6 +216,7 @@ public final class Settings {
|
||||
packetLoss = parseI(props.getProperty("packetLoss"), packetLoss);
|
||||
music = parseB(props.getProperty("music"), music);
|
||||
outputVolume = parseD(props.getProperty("outputVolume"), outputVolume);
|
||||
masterVolume = parseD(props.getProperty("masterVolume"), masterVolume);
|
||||
inputVolume = parseD(props.getProperty("inputVolume"), inputVolume);
|
||||
denoise = parseB(props.getProperty("denoise"), denoise);
|
||||
denoiserLevel = parseD(props.getProperty("denoiserLevel"), denoiserLevel);
|
||||
@@ -195,6 +227,9 @@ public final class Settings {
|
||||
soundPackDir = props.getProperty("soundPackDir", soundPackDir);
|
||||
iconPack = props.getProperty("iconPack", iconPack);
|
||||
iconPackDir = props.getProperty("iconPackDir", iconPackDir);
|
||||
appearance = parseAppearance(props.getProperty("appearance"), appearance);
|
||||
showStatusBar = parseB(props.getProperty("showStatusBar"), showStatusBar);
|
||||
showMasterVolumeSlider = parseB(props.getProperty("showMasterVolumeSlider"), showMasterVolumeSlider);
|
||||
notifications.load(props);
|
||||
}
|
||||
|
||||
@@ -223,6 +258,7 @@ public final class Settings {
|
||||
props.setProperty("packetLoss", Integer.toString(packetLoss));
|
||||
props.setProperty("music", Boolean.toString(music));
|
||||
props.setProperty("outputVolume", Double.toString(outputVolume));
|
||||
props.setProperty("masterVolume", Double.toString(masterVolume));
|
||||
props.setProperty("inputVolume", Double.toString(inputVolume));
|
||||
props.setProperty("denoise", Boolean.toString(denoise));
|
||||
props.setProperty("denoiserLevel", Double.toString(denoiserLevel));
|
||||
@@ -233,6 +269,9 @@ public final class Settings {
|
||||
props.setProperty("soundPackDir", soundPackDir);
|
||||
props.setProperty("iconPack", iconPack);
|
||||
props.setProperty("iconPackDir", iconPackDir);
|
||||
props.setProperty("appearance", appearance.name());
|
||||
props.setProperty("showStatusBar", Boolean.toString(showStatusBar));
|
||||
props.setProperty("showMasterVolumeSlider", Boolean.toString(showMasterVolumeSlider));
|
||||
notifications.store(props);
|
||||
}
|
||||
|
||||
@@ -245,6 +284,15 @@ public final class Settings {
|
||||
}
|
||||
}
|
||||
|
||||
private static Appearance parseAppearance(String v, Appearance def) {
|
||||
if (v == null) return def;
|
||||
try {
|
||||
return Appearance.valueOf(v);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
|
||||
private static VadMode parseVadMode(String v, VadMode def) {
|
||||
if (v == null) return def;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import com.github.manevolent.ts3j.api.Channel;
|
||||
import com.github.manevolent.ts3j.command.MultiCommand;
|
||||
import com.github.manevolent.ts3j.command.SingleCommand;
|
||||
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
|
||||
import com.github.manevolent.ts3j.event.UnknownTeamspeakEvent;
|
||||
import com.github.manevolent.ts3j.protocol.ProtocolRole;
|
||||
import com.ts3client.net.filetransfer.FileTransfer;
|
||||
import com.ts3client.net.filetransfer.FileTransferManager;
|
||||
import com.ts3client.net.filetransfer.RemoteFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.zip.CRC32;
|
||||
|
||||
/**
|
||||
* Everything the channel editor needs of the server: reading and writing a channel's
|
||||
* properties and its needed-power permissions, plus the virtual server's icon store.
|
||||
*
|
||||
* <p>Split out of {@link TeamspeakConnection} because it is a self-contained request/
|
||||
* response corner of the protocol that only the editor uses. Every call here blocks and
|
||||
* must run off the UI thread; the connection's public wrappers do that.
|
||||
*/
|
||||
final class ChannelAdmin {
|
||||
|
||||
/** The directory a server's icons live in, where the server has one. */
|
||||
private static final String ICON_DIRECTORY = "icons";
|
||||
|
||||
/** How long to wait for a whole icon to travel over the file connection. */
|
||||
private static final long ICON_UPLOAD_TIMEOUT_MS = 30_000;
|
||||
|
||||
private final TeamspeakConnection conn;
|
||||
|
||||
/**
|
||||
* The {@code channelpermlist} reply in progress. TeamSpeak answers the command with a
|
||||
* burst of {@code notifychannelpermlist} events rather than a command response, and
|
||||
* only the first of them repeats the channel id, so one request may be in flight at a
|
||||
* time and its entries are collected here until the command itself completes.
|
||||
*/
|
||||
private volatile PermRequest permRequest;
|
||||
|
||||
ChannelAdmin(TeamspeakConnection conn) {
|
||||
this.conn = conn;
|
||||
}
|
||||
|
||||
// ---- channel properties ----
|
||||
|
||||
/** Reads a channel's full property set. */
|
||||
ChannelSettings readSettings(int channelId) throws Exception {
|
||||
Channel info = conn.socket().getChannelInfo(channelId);
|
||||
if (info == null) throw new IllegalStateException("The server did not describe that channel");
|
||||
return ChannelSettings.from(channelId, info.getMap());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a channel below {@code parentId} (0 being the top level).
|
||||
*
|
||||
* @param properties the new channel's properties, as {@link ChannelSettings#creationParameters}
|
||||
* @return the new channel's id
|
||||
*/
|
||||
int create(int parentId, Map<String, String> properties) throws Exception {
|
||||
SingleCommand cmd = new SingleCommand("channelcreate", ProtocolRole.CLIENT);
|
||||
cmd.add(new CommandSingleParameter("cpid", Integer.toString(parentId)));
|
||||
for (Map.Entry<String, String> property : properties.entrySet()) {
|
||||
cmd.add(new CommandSingleParameter(property.getKey(), property.getValue()));
|
||||
}
|
||||
for (SingleCommand answer : conn.socket().executeCommand(cmd).get()) {
|
||||
int cid = parseInt(answer.toMap().get("cid"), -1);
|
||||
if (cid > 0) return cid;
|
||||
}
|
||||
// Some servers acknowledge the command without naming the channel; the
|
||||
// notifychannelcreated they also send does name it, so read it off the model
|
||||
// once that event has been handled.
|
||||
conn.awaitEventsProcessed();
|
||||
ChannelNode created = conn.getModel().findChannelByName(parentId, properties.get("channel_name"));
|
||||
if (created == null) throw new IllegalStateException("The server did not report the new channel");
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Applies {@code changes} (property name to value) with a single {@code channeledit}. */
|
||||
void edit(int channelId, Map<String, String> changes) throws Exception {
|
||||
if (changes.isEmpty()) return;
|
||||
SingleCommand cmd = new SingleCommand("channeledit", ProtocolRole.CLIENT);
|
||||
cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId)));
|
||||
for (Map.Entry<String, String> change : changes.entrySet()) {
|
||||
cmd.add(new CommandSingleParameter(change.getKey(), change.getValue()));
|
||||
}
|
||||
conn.socket().executeCommand(cmd).complete();
|
||||
}
|
||||
|
||||
// ---- channel permissions ----
|
||||
|
||||
/**
|
||||
* Reads the permissions set directly on a channel.
|
||||
*
|
||||
* @return permission name to value, holding only the permissions the channel itself
|
||||
* carries — anything inherited from a group is absent
|
||||
*/
|
||||
Map<String, Integer> readPermissions(int channelId) throws Exception {
|
||||
if (!conn.getModel().hasPermissionNames()) {
|
||||
// channelpermlist reports numeric ids, which only the permissionlist response
|
||||
// gives names to; without it the values could not be told apart.
|
||||
throw new IllegalStateException("The server did not send its permission list");
|
||||
}
|
||||
PermRequest request = new PermRequest(channelId);
|
||||
permRequest = request;
|
||||
try {
|
||||
SingleCommand cmd = new SingleCommand("channelpermlist", ProtocolRole.CLIENT,
|
||||
new CommandSingleParameter("cid", Integer.toString(channelId)));
|
||||
conn.socket().executeCommand(cmd).complete();
|
||||
// The entries were queued on the event thread before the command's own reply
|
||||
// was read, so draining that thread is what guarantees they have all landed.
|
||||
conn.awaitEventsProcessed();
|
||||
} finally {
|
||||
permRequest = null;
|
||||
}
|
||||
|
||||
Map<String, Integer> byName = new LinkedHashMap<>();
|
||||
for (Map.Entry<Integer, Integer> entry : request.values.entrySet()) {
|
||||
String name = conn.getModel().permissionName(entry.getKey());
|
||||
if (name != null) byName.put(name, entry.getValue());
|
||||
}
|
||||
return byName;
|
||||
}
|
||||
|
||||
/** Collects one {@code notifychannelpermlist} entry for the request in flight. */
|
||||
void onPermListEntry(UnknownTeamspeakEvent e) {
|
||||
PermRequest request = permRequest;
|
||||
if (request == null) return;
|
||||
String cid = e.get("cid");
|
||||
if (cid != null && !cid.isEmpty() && parseInt(cid, -1) != request.channelId) return;
|
||||
int permId = parseInt(e.get("permid"), -1);
|
||||
if (permId >= 0) request.values.put(permId, parseInt(e.get("permvalue"), 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets and clears channel permissions by name. A permission set to a value is added or
|
||||
* updated; one that is cleared is removed so the channel inherits it again.
|
||||
*/
|
||||
void writePermissions(int channelId, Map<String, Integer> set, Collection<String> remove)
|
||||
throws Exception {
|
||||
if (!set.isEmpty()) {
|
||||
List<SingleCommand> parts = new ArrayList<>(set.size());
|
||||
for (Map.Entry<String, Integer> perm : set.entrySet()) {
|
||||
parts.add(new SingleCommand("channeladdperm", ProtocolRole.CLIENT,
|
||||
new CommandSingleParameter("permsid", perm.getKey()),
|
||||
new CommandSingleParameter("permvalue", Integer.toString(perm.getValue()))));
|
||||
}
|
||||
send("channeladdperm", channelId, parts);
|
||||
}
|
||||
if (!remove.isEmpty()) {
|
||||
List<SingleCommand> parts = new ArrayList<>(remove.size());
|
||||
for (String name : remove) {
|
||||
parts.add(new SingleCommand("channeldelperm", ProtocolRole.CLIENT,
|
||||
new CommandSingleParameter("permsid", name)));
|
||||
}
|
||||
send("channeldelperm", channelId, parts);
|
||||
}
|
||||
}
|
||||
|
||||
private void send(String name, int channelId, List<SingleCommand> parts) throws Exception {
|
||||
MultiCommand cmd = new MultiCommand(name, ProtocolRole.CLIENT, parts);
|
||||
cmd.add(new CommandSingleParameter("cid", Integer.toString(channelId)));
|
||||
conn.socket().executeCommand(cmd).complete();
|
||||
}
|
||||
|
||||
// ---- the server's icon store ----
|
||||
|
||||
/**
|
||||
* The ids of the icons uploaded to this virtual server, which live in channel 0's file
|
||||
* repository as {@code /icon_<id>}.
|
||||
*/
|
||||
List<Long> listIcons() throws Exception {
|
||||
List<Long> ids = new ArrayList<>();
|
||||
boolean hasIconDirectory = false;
|
||||
for (RemoteFile file : fileTransfers().list(0, "", "/")) {
|
||||
if (file.isDirectory()) {
|
||||
hasIconDirectory |= file.getName().equals(ICON_DIRECTORY);
|
||||
continue;
|
||||
}
|
||||
addIcon(ids, file);
|
||||
}
|
||||
// Newer servers keep the icons in their own directory rather than loose in the
|
||||
// repository's root; both layouts exist, so whichever this server uses is read.
|
||||
if (hasIconDirectory) {
|
||||
for (RemoteFile file : fileTransfers().list(0, "", "/" + ICON_DIRECTORY)) {
|
||||
if (!file.isDirectory()) addIcon(ids, file);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static void addIcon(List<Long> ids, RemoteFile file) {
|
||||
if (!file.getName().startsWith("icon_")) return;
|
||||
long id = parseLong(file.getName().substring("icon_".length()));
|
||||
if (id > 0 && !ids.contains(id)) ids.add(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an image as a server icon.
|
||||
*
|
||||
* @return the icon's id, which TeamSpeak derives from the file's CRC32
|
||||
*/
|
||||
long uploadIcon(File source) throws Exception {
|
||||
byte[] data = Files.readAllBytes(source.toPath());
|
||||
if (data.length == 0) throw new IllegalArgumentException("That file is empty");
|
||||
if (data.length > TeamspeakConnection.MAX_ICON_BYTES) {
|
||||
throw new IllegalArgumentException("Icons may not be larger than "
|
||||
+ (TeamspeakConnection.MAX_ICON_BYTES / 1024) + " KiB");
|
||||
}
|
||||
CRC32 crc = new CRC32();
|
||||
crc.update(data);
|
||||
long id = crc.getValue();
|
||||
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
FileTransfer transfer = fileTransfers().upload(0, "", "/icon_" + id, source, true, t -> {
|
||||
if (t.isDone()) done.countDown();
|
||||
});
|
||||
if (!done.await(ICON_UPLOAD_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
|
||||
throw new IllegalStateException("The upload did not finish in time");
|
||||
}
|
||||
if (transfer.getState() != FileTransfer.State.COMPLETED) {
|
||||
throw new IllegalStateException(transfer.getErrorMessage() == null
|
||||
? "The upload failed" : transfer.getErrorMessage());
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
void deleteIcon(long iconId) throws Exception {
|
||||
fileTransfers().delete(0, "", "/icon_" + iconId);
|
||||
}
|
||||
|
||||
private FileTransferManager fileTransfers() {
|
||||
FileTransferManager ft = conn.fileTransfers();
|
||||
if (ft == null) throw new IllegalStateException("Not connected");
|
||||
return ft;
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static int parseInt(String value, int fallback) {
|
||||
try {
|
||||
return value == null || value.isEmpty() ? fallback : Integer.parseInt(value.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private static long parseLong(String value) {
|
||||
try {
|
||||
return Long.parseLong(value.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** One in-flight {@code channelpermlist} and the entries received for it so far. */
|
||||
private static final class PermRequest {
|
||||
final int channelId;
|
||||
final Map<Integer, Integer> values = new ConcurrentHashMap<>();
|
||||
|
||||
PermRequest(int channelId) {
|
||||
this.channelId = channelId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The editable properties of a channel, as read from {@code channelinfo} and written
|
||||
* back with {@code channeledit}.
|
||||
*
|
||||
* <p>Kept apart from {@link ChannelNode} on purpose: the node is the live tree state the
|
||||
* server pushes at us, while this is a snapshot an editor works on. Submitting compares
|
||||
* the edited copy against the untouched original ({@link #changesFrom}) so only the
|
||||
* properties the user actually changed are sent — TeamSpeak refuses the whole command
|
||||
* when it contains one property the client may not modify, even an unchanged one.
|
||||
*/
|
||||
public final class ChannelSettings {
|
||||
|
||||
/** How long a channel outlives its last occupant. */
|
||||
public enum Type {TEMPORARY, SEMI_PERMANENT, PERMANENT}
|
||||
|
||||
public int id;
|
||||
public int parentId;
|
||||
public String name = "";
|
||||
public String topic = "";
|
||||
public String description = "";
|
||||
public String phoneticName = "";
|
||||
/** Whether the server reports a password on this channel; the password itself is never readable. */
|
||||
public boolean hasPassword;
|
||||
/** {@code null} leaves the password alone, {@code ""} removes it, anything else sets it. */
|
||||
public String password;
|
||||
public Type type = Type.PERMANENT;
|
||||
public boolean defaultChannel;
|
||||
/** The sibling this channel sorts after, or 0 to sort first. */
|
||||
public int order;
|
||||
public int neededTalkPower;
|
||||
/** TeamSpeak codec id; see {@code com.github.manevolent.ts3j.api.Codec}. */
|
||||
public int codec = 4;
|
||||
public int codecQuality = 6;
|
||||
public boolean encrypted = true;
|
||||
public int deleteDelay;
|
||||
public boolean maxClientsUnlimited = true;
|
||||
public int maxClients = 16;
|
||||
public boolean familyInherited = true;
|
||||
public boolean familyUnlimited;
|
||||
public int maxFamilyClients = 16;
|
||||
public long iconId;
|
||||
|
||||
/** Reads a {@code channelinfo} (or {@code channellist}) property map. */
|
||||
public static ChannelSettings from(int channelId, Map<String, String> props) {
|
||||
ChannelSettings s = new ChannelSettings();
|
||||
s.id = channelId;
|
||||
s.parentId = intOf(props, "pid", intOf(props, "cpid", 0));
|
||||
s.name = strOf(props, "channel_name");
|
||||
s.topic = strOf(props, "channel_topic");
|
||||
s.description = strOf(props, "channel_description");
|
||||
s.phoneticName = strOf(props, "channel_name_phonetic");
|
||||
s.hasPassword = boolOf(props, "channel_flag_password");
|
||||
if (boolOf(props, "channel_flag_permanent")) s.type = Type.PERMANENT;
|
||||
else if (boolOf(props, "channel_flag_semi_permanent")) s.type = Type.SEMI_PERMANENT;
|
||||
else s.type = Type.TEMPORARY;
|
||||
s.defaultChannel = boolOf(props, "channel_flag_default");
|
||||
s.order = intOf(props, "channel_order", 0);
|
||||
s.neededTalkPower = intOf(props, "channel_needed_talk_power", 0);
|
||||
s.codec = intOf(props, "channel_codec", 4);
|
||||
s.codecQuality = intOf(props, "channel_codec_quality", 6);
|
||||
s.encrypted = !boolOf(props, "channel_codec_is_unencrypted");
|
||||
s.deleteDelay = intOf(props, "channel_delete_delay", 0);
|
||||
s.maxClientsUnlimited = boolOf(props, "channel_flag_maxclients_unlimited");
|
||||
s.maxClients = Math.max(0, intOf(props, "channel_maxclients", 16));
|
||||
s.familyInherited = boolOf(props, "channel_flag_maxfamilyclients_inherited");
|
||||
s.familyUnlimited = boolOf(props, "channel_flag_maxfamilyclients_unlimited");
|
||||
s.maxFamilyClients = Math.max(0, intOf(props, "channel_maxfamilyclients", 16));
|
||||
s.iconId = longOf(props, "channel_icon_id");
|
||||
return s;
|
||||
}
|
||||
|
||||
public ChannelSettings copy() {
|
||||
ChannelSettings c = new ChannelSettings();
|
||||
c.id = id;
|
||||
c.parentId = parentId;
|
||||
c.name = name;
|
||||
c.topic = topic;
|
||||
c.description = description;
|
||||
c.phoneticName = phoneticName;
|
||||
c.hasPassword = hasPassword;
|
||||
c.password = password;
|
||||
c.type = type;
|
||||
c.defaultChannel = defaultChannel;
|
||||
c.order = order;
|
||||
c.neededTalkPower = neededTalkPower;
|
||||
c.codec = codec;
|
||||
c.codecQuality = codecQuality;
|
||||
c.encrypted = encrypted;
|
||||
c.deleteDelay = deleteDelay;
|
||||
c.maxClientsUnlimited = maxClientsUnlimited;
|
||||
c.maxClients = maxClients;
|
||||
c.familyInherited = familyInherited;
|
||||
c.familyUnlimited = familyUnlimited;
|
||||
c.maxFamilyClients = maxFamilyClients;
|
||||
c.iconId = iconId;
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@code channeledit} parameters needed to turn {@code original} into this.
|
||||
*
|
||||
* @return property name to value, empty when nothing changed
|
||||
*/
|
||||
public Map<String, String> changesFrom(ChannelSettings original) {
|
||||
Map<String, String> changes = new LinkedHashMap<>();
|
||||
putIfChanged(changes, "channel_name", name, original.name);
|
||||
putIfChanged(changes, "channel_topic", topic, original.topic);
|
||||
putIfChanged(changes, "channel_description", description, original.description);
|
||||
putIfChanged(changes, "channel_name_phonetic", phoneticName, original.phoneticName);
|
||||
if (password != null) changes.put("channel_password", password);
|
||||
if (type != original.type) {
|
||||
// The three flags are mutually exclusive and the server wants the whole set:
|
||||
// clearing one without setting another leaves the channel type ambiguous.
|
||||
changes.put("channel_flag_permanent", flag(type == Type.PERMANENT));
|
||||
changes.put("channel_flag_semi_permanent", flag(type == Type.SEMI_PERMANENT));
|
||||
changes.put("channel_flag_temporary", flag(type == Type.TEMPORARY));
|
||||
}
|
||||
// Clearing the default flag is meaningless — some channel has to be the default —
|
||||
// so only setting it is ever sent.
|
||||
if (defaultChannel && !original.defaultChannel) changes.put("channel_flag_default", "1");
|
||||
putIfChanged(changes, "channel_order", order, original.order);
|
||||
putIfChanged(changes, "channel_needed_talk_power", neededTalkPower, original.neededTalkPower);
|
||||
putIfChanged(changes, "channel_codec", codec, original.codec);
|
||||
putIfChanged(changes, "channel_codec_quality", codecQuality, original.codecQuality);
|
||||
putIfChanged(changes, "channel_codec_is_unencrypted", !encrypted, !original.encrypted);
|
||||
putIfChanged(changes, "channel_delete_delay", deleteDelay, original.deleteDelay);
|
||||
putIfChanged(changes, "channel_flag_maxclients_unlimited",
|
||||
maxClientsUnlimited, original.maxClientsUnlimited);
|
||||
if (!maxClientsUnlimited) putIfChanged(changes, "channel_maxclients", maxClients, original.maxClients);
|
||||
putIfChanged(changes, "channel_flag_maxfamilyclients_inherited",
|
||||
familyInherited, original.familyInherited);
|
||||
putIfChanged(changes, "channel_flag_maxfamilyclients_unlimited",
|
||||
familyUnlimited, original.familyUnlimited);
|
||||
if (!familyInherited && !familyUnlimited) {
|
||||
putIfChanged(changes, "channel_maxfamilyclients", maxFamilyClients, original.maxFamilyClients);
|
||||
}
|
||||
if (iconId != original.iconId) changes.put("channel_icon_id", Long.toString(iconId));
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@code channelcreate} parameters for a channel that does not exist yet.
|
||||
*
|
||||
* <p>Everything a fresh channel would get anyway is left out, because TeamSpeak checks
|
||||
* a create permission per property that is present — a client allowed to create plain
|
||||
* channels but not, say, ones with a topic must not send an empty topic. What the
|
||||
* server cannot infer is always sent: the name, the channel type (whose absent flags
|
||||
* would silently mean temporary), the codec, and the client limits when they are not
|
||||
* unlimited.
|
||||
*/
|
||||
public Map<String, String> creationParameters() {
|
||||
Map<String, String> params = changesFrom(new ChannelSettings());
|
||||
params.put("channel_name", name);
|
||||
params.put("channel_flag_permanent", flag(type == Type.PERMANENT));
|
||||
params.put("channel_flag_semi_permanent", flag(type == Type.SEMI_PERMANENT));
|
||||
params.put("channel_flag_temporary", flag(type == Type.TEMPORARY));
|
||||
params.put("channel_codec", Integer.toString(codec));
|
||||
params.put("channel_codec_quality", Integer.toString(codecQuality));
|
||||
if (!maxClientsUnlimited) params.put("channel_maxclients", Integer.toString(maxClients));
|
||||
if (!familyInherited && !familyUnlimited) {
|
||||
params.put("channel_maxfamilyclients", Integer.toString(maxFamilyClients));
|
||||
}
|
||||
if (password == null || password.isEmpty()) params.remove("channel_password");
|
||||
return params;
|
||||
}
|
||||
|
||||
private static void putIfChanged(Map<String, String> into, String key, String value, String was) {
|
||||
if (!value.equals(was)) into.put(key, value);
|
||||
}
|
||||
|
||||
private static void putIfChanged(Map<String, String> into, String key, int value, int was) {
|
||||
if (value != was) into.put(key, Integer.toString(value));
|
||||
}
|
||||
|
||||
private static void putIfChanged(Map<String, String> into, String key, boolean value, boolean was) {
|
||||
if (value != was) into.put(key, flag(value));
|
||||
}
|
||||
|
||||
private static String flag(boolean set) {
|
||||
return set ? "1" : "0";
|
||||
}
|
||||
|
||||
private static String strOf(Map<String, String> props, String key) {
|
||||
String v = props.get(key);
|
||||
return v == null ? "" : v;
|
||||
}
|
||||
|
||||
private static boolean boolOf(Map<String, String> props, String key) {
|
||||
return "1".equals(props.get(key));
|
||||
}
|
||||
|
||||
private static int intOf(Map<String, String> props, String key, int fallback) {
|
||||
try {
|
||||
String v = props.get(key);
|
||||
return v == null || v.isEmpty() ? fallback : Integer.parseInt(v.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private static long longOf(Map<String, String> props, String key) {
|
||||
try {
|
||||
String v = props.get(key);
|
||||
if (v == null || v.isEmpty()) return 0;
|
||||
long id = Long.parseLong(v.trim());
|
||||
return id < 0 ? id & 0xFFFFFFFFL : id;
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ public final class ClientEntry {
|
||||
public int channelId;
|
||||
public String nickname;
|
||||
public String uniqueId = "";
|
||||
public int databaseId;
|
||||
public int type; // 0 = normal voice client, 1 = server-query
|
||||
public int talkPower;
|
||||
|
||||
@@ -39,4 +40,28 @@ public final class ClientEntry {
|
||||
public boolean isQuery() {
|
||||
return type == 1;
|
||||
}
|
||||
|
||||
/** Adds a server group id, if not already present. */
|
||||
public void addServerGroup(int groupId) {
|
||||
for (int id : serverGroupIds) if (id == groupId) return;
|
||||
int[] updated = java.util.Arrays.copyOf(serverGroupIds, serverGroupIds.length + 1);
|
||||
updated[serverGroupIds.length] = groupId;
|
||||
serverGroupIds = updated;
|
||||
}
|
||||
|
||||
/** Removes a server group id, if present. */
|
||||
public void removeServerGroup(int groupId) {
|
||||
int index = -1;
|
||||
for (int i = 0; i < serverGroupIds.length; i++) {
|
||||
if (serverGroupIds[i] == groupId) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (index < 0) return;
|
||||
int[] updated = new int[serverGroupIds.length - 1];
|
||||
System.arraycopy(serverGroupIds, 0, updated, 0, index);
|
||||
System.arraycopy(serverGroupIds, index + 1, updated, index, serverGroupIds.length - index - 1);
|
||||
serverGroupIds = updated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import com.github.manevolent.ts3j.event.*;
|
||||
import com.ts3client.sound.SoundEvent;
|
||||
import com.ts3client.sound.SoundNotifier;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The {@link TS3Listener} side of a connection: keeps {@link ServerModel} in sync with
|
||||
* incoming notify events, mirrors native TeamSpeak's server-tab log lines, and picks the
|
||||
* matching {@link SoundEvent} for each one. Registered alongside {@link TeamspeakConnection}
|
||||
* itself (which keeps only the lifecycle-critical {@code onDisconnected} callback) so this
|
||||
* class can stay focused on "event arrived, update state and tell the user" and nothing else.
|
||||
*/
|
||||
final class ConnectionEventHandler implements TS3Listener {
|
||||
|
||||
// ---- who went where: TeamSpeak's reason ids ----
|
||||
|
||||
/** {@code reasonid} of a client view/move notification. */
|
||||
private static final int REASON_SWITCHED = 0;
|
||||
private static final int REASON_MOVED = 1;
|
||||
private static final int REASON_TIMEOUT = 3;
|
||||
private static final int REASON_CHANNEL_KICK = 4;
|
||||
private static final int REASON_SERVER_KICK = 5;
|
||||
private static final int REASON_BAN = 6;
|
||||
|
||||
private final TeamspeakConnection conn;
|
||||
|
||||
ConnectionEventHandler(TeamspeakConnection conn) {
|
||||
this.conn = conn;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientJoin(ClientJoinEvent e) {
|
||||
ClientEntry c = conn.getModel().putClient(e.getClientId(), e.getClientNickname(), e.getClientTargetId());
|
||||
c.type = safeInt(e, "client_type");
|
||||
c.talkPower = e.getClientTalkPower();
|
||||
c.inputMuted = e.isClientInputMuted();
|
||||
c.outputMuted = e.isClientOutputMuted();
|
||||
c.inputHardware = e.isClientUsingHardwareInput();
|
||||
c.outputHardware = e.isClientUsingHardwareOutput();
|
||||
c.away = e.isClientAway();
|
||||
c.awayMessage = TeamspeakConnection.orEmpty(e.get("client_away_message"));
|
||||
c.uniqueId = TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier());
|
||||
c.databaseId = e.getClientDatabaseId();
|
||||
c.serverGroupIds = parseIntList(e.getClientServerGroups());
|
||||
c.channelGroupId = e.getClientChannelGroupId();
|
||||
c.self = (e.getClientId() == conn.getSelfClientId());
|
||||
if (e.getClientId() != conn.getSelfClientId()) {
|
||||
announceClientEntered(e);
|
||||
logClientEntered(e);
|
||||
}
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientLeave(ClientLeaveEvent e) {
|
||||
if (e.getClientId() == conn.getSelfClientId()) {
|
||||
announceOwnRemoval(safeInt(e, "reasonid"), e);
|
||||
} else {
|
||||
String name = conn.clientLink(e.getClientId());
|
||||
announceClientLeft(e);
|
||||
logClientLeft(e, name);
|
||||
}
|
||||
conn.getModel().removeClient(e.getClientId());
|
||||
if (conn.getPlayback() != null) conn.getPlayback().removeClient(e.getClientId());
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientMoved(ClientMovedEvent e) {
|
||||
ClientEntry c = conn.getModel().getClient(e.getClientId());
|
||||
if (c != null) {
|
||||
int from = c.channelId;
|
||||
c.channelId = e.getTargetChannelId();
|
||||
if (e.getClientId() == conn.getSelfClientId()) {
|
||||
announceOwnMove(safeInt(e, "reasonid"), e);
|
||||
} else {
|
||||
announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId());
|
||||
logClientMoved(e, conn.clientLink(c.id), from, e.getTargetChannelId());
|
||||
}
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/** A client became visible to us, logged the way native TS3's server tab does. */
|
||||
private void logClientEntered(ClientJoinEvent e) {
|
||||
String name = conn.clientLink(e.getClientId(),
|
||||
TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier()), e.getClientNickname());
|
||||
switch (safeInt(e, "reasonid")) {
|
||||
case REASON_MOVED:
|
||||
conn.log(name + " appears, coming from channel \"" + conn.channelLink(e.getClientFromId()) + "\"");
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
conn.log(name + " appears, was kicked from channel \"" + conn.channelLink(e.getClientFromId())
|
||||
+ "\" by " + invokerName(e));
|
||||
break;
|
||||
case REASON_SWITCHED:
|
||||
conn.log(name + " switched to channel \"" + conn.channelLink(e.getClientTargetId())
|
||||
+ "\", coming from channel \"" + conn.channelLink(e.getClientFromId()) + "\"");
|
||||
break;
|
||||
default:
|
||||
conn.log(name + " connected to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
/** A client stopped being visible to us. */
|
||||
private void logClientLeft(ClientLeaveEvent e, String name) {
|
||||
String reasonMsg = TeamspeakConnection.orEmpty(e.get("reasonmsg"));
|
||||
String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")";
|
||||
switch (safeInt(e, "reasonid")) {
|
||||
case REASON_TIMEOUT:
|
||||
conn.log(name + " dropped (ping timeout)");
|
||||
break;
|
||||
case REASON_SERVER_KICK:
|
||||
conn.log(name + " was kicked from the server by " + invokerName(e) + suffix);
|
||||
break;
|
||||
case REASON_BAN:
|
||||
conn.log(name + " was banned from the server by " + invokerName(e) + suffix);
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
conn.log(name + " left: was kicked to channel \"" + conn.channelLink(e.getClientTargetId())
|
||||
+ "\" by " + invokerName(e) + suffix);
|
||||
break;
|
||||
case REASON_MOVED:
|
||||
conn.log(name + " left, heading to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
|
||||
break;
|
||||
case REASON_SWITCHED:
|
||||
conn.log(name + " left, switched to channel \"" + conn.channelLink(e.getClientTargetId()) + "\"");
|
||||
break;
|
||||
default:
|
||||
conn.log(name + " disconnected");
|
||||
}
|
||||
}
|
||||
|
||||
/** A client we can see moved between two channels we can see. */
|
||||
private void logClientMoved(ClientMovedEvent e, String name, int fromChannel, int toChannel) {
|
||||
String reasonMsg = TeamspeakConnection.orEmpty(e.get("reasonmsg"));
|
||||
String suffix = reasonMsg.isEmpty() ? "" : " (" + reasonMsg + ")";
|
||||
switch (safeInt(e, "reasonid")) {
|
||||
case REASON_MOVED:
|
||||
conn.log(name + " was moved from channel \"" + conn.channelLink(fromChannel) + "\" to \""
|
||||
+ conn.channelLink(toChannel) + "\" by " + invokerName(e));
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
conn.log(name + " was kicked from channel \"" + conn.channelLink(fromChannel) + "\" to \""
|
||||
+ conn.channelLink(toChannel) + "\" by " + invokerName(e) + suffix);
|
||||
break;
|
||||
default:
|
||||
conn.log(name + " switched from channel \"" + conn.channelLink(fromChannel) + "\" to \""
|
||||
+ conn.channelLink(toChannel) + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A client became visible: either they just connected, or they moved in from a
|
||||
* channel we could not see — TeamSpeak's "appears" case.
|
||||
*/
|
||||
private void announceClientEntered(ClientJoinEvent e) {
|
||||
if (!conn.isConnected()) return;
|
||||
int clientId = e.getClientId();
|
||||
boolean current = conn.inOwnChannel(e.getClientTargetId());
|
||||
Map<String, String> vars = conn.clientVars(clientId, e.getClientNickname());
|
||||
switch (safeInt(e, "reasonid")) {
|
||||
case REASON_MOVED:
|
||||
conn.sound(current ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_APPEARS
|
||||
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_APPEARS, vars);
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
conn.sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_APPEARS
|
||||
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_APPEARS, vars);
|
||||
break;
|
||||
case REASON_SWITCHED:
|
||||
conn.sound(current ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_APPEARS
|
||||
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_APPEARS, vars);
|
||||
break;
|
||||
default:
|
||||
conn.sound(current ? SoundEvent.CLIENT_CONNECTION_CONNECTED_CURRENT_CHANNEL
|
||||
: SoundEvent.CLIENT_CONNECTION_CONNECTED_SERVER, vars);
|
||||
}
|
||||
}
|
||||
|
||||
/** A client stopped being visible: they left the server, or moved out of sight. */
|
||||
private void announceClientLeft(ClientLeaveEvent e) {
|
||||
if (!conn.isConnected()) return;
|
||||
int clientId = e.getClientId();
|
||||
boolean current = conn.inOwnChannel(e.getClientFromId());
|
||||
Map<String, String> vars = conn.clientVars(clientId, null);
|
||||
switch (safeInt(e, "reasonid")) {
|
||||
case REASON_TIMEOUT:
|
||||
conn.sound(current ? SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_CURRENT_CHANNEL
|
||||
: SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_SERVER, vars);
|
||||
break;
|
||||
case REASON_SERVER_KICK:
|
||||
conn.sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_CURRENT_CHANNEL
|
||||
: SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_SERVER, vars);
|
||||
break;
|
||||
case REASON_BAN:
|
||||
conn.sound(current ? SoundEvent.CLIENT_WAS_BANNED_CURRENT_CHANNEL
|
||||
: SoundEvent.CLIENT_WAS_BANNED_SERVER, vars);
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
conn.sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_DISAPPEARS
|
||||
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_DISAPPEARS, vars);
|
||||
break;
|
||||
case REASON_MOVED:
|
||||
conn.sound(current ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_DISAPPEARS
|
||||
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_DISAPPEARS, vars);
|
||||
break;
|
||||
case REASON_SWITCHED:
|
||||
conn.sound(current ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_DISAPPEARS
|
||||
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_DISAPPEARS, vars);
|
||||
break;
|
||||
default:
|
||||
conn.sound(current ? SoundEvent.CLIENT_CONNECTION_DISCONNECTED_CURRENT_CHANNEL
|
||||
: SoundEvent.CLIENT_CONNECTION_DISCONNECTED_SERVER, vars);
|
||||
}
|
||||
}
|
||||
|
||||
/** A client we can see moved between two channels we can see ("stays"). */
|
||||
private void announceClientMoved(int reason, int clientId, int fromChannel, int toChannel) {
|
||||
if (!conn.isConnected()) return;
|
||||
boolean toCurrent = conn.inOwnChannel(toChannel);
|
||||
boolean fromCurrent = conn.inOwnChannel(fromChannel);
|
||||
Map<String, String> vars = conn.clientVars(clientId, null);
|
||||
switch (reason) {
|
||||
case REASON_MOVED:
|
||||
conn.sound(toCurrent ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS
|
||||
: fromCurrent ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_STAYS
|
||||
: SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_STAYS, vars);
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
conn.sound(toCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_STAYS
|
||||
: fromCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_STAYS
|
||||
: SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_STAYS, vars);
|
||||
break;
|
||||
default:
|
||||
conn.sound(toCurrent ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_STAYS
|
||||
: fromCurrent ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_STAYS
|
||||
: SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_STAYS, vars);
|
||||
}
|
||||
}
|
||||
|
||||
/** We changed channel ourselves, or somebody changed it for us. */
|
||||
private void announceOwnMove(int reason, ClientMovedEvent e) {
|
||||
if (!conn.isConnected()) return;
|
||||
Map<String, String> vars = conn.channelVars(e.getTargetChannelId(), e.get("invokername"));
|
||||
switch (reason) {
|
||||
case REASON_MOVED:
|
||||
conn.sound(SoundEvent.YOU_WERE_MOVED_TO_DIFFERENT_CHANNEL, vars);
|
||||
break;
|
||||
case REASON_CHANNEL_KICK:
|
||||
conn.sound(SoundEvent.YOU_WERE_KICKED_FROM_CHANNEL, vars);
|
||||
break;
|
||||
default:
|
||||
conn.sound(SoundEvent.YOU_SWITCHED_CHANNEL, vars);
|
||||
}
|
||||
}
|
||||
|
||||
/** We were removed from the server (kick or ban); the disconnect follows. */
|
||||
private void announceOwnRemoval(int reason, ClientLeaveEvent e) {
|
||||
if (!conn.isConnected()) return;
|
||||
Map<String, String> vars = SoundNotifier.vars(
|
||||
"servername", conn.getModel().getServerName(),
|
||||
"clientname", TeamspeakConnection.orEmpty(e.get("invokername")),
|
||||
"reason", TeamspeakConnection.orEmpty(e.get("reasonmsg")));
|
||||
if (reason == REASON_SERVER_KICK) {
|
||||
conn.sound(SoundEvent.YOU_WERE_KICKED_FROM_SERVER, vars);
|
||||
} else if (reason == REASON_BAN) {
|
||||
conn.sound(SoundEvent.YOU_WERE_BANNED, vars);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientChanged(ClientUpdatedEvent e) {
|
||||
ClientEntry c = conn.getModel().getClient(e.getClientId());
|
||||
if (c == null) return;
|
||||
boolean renamed = has(e, "client_nickname") && !e.get("client_nickname").equals(c.nickname);
|
||||
String oldName = c.nickname;
|
||||
if (renamed) c.nickname = e.get("client_nickname");
|
||||
if (has(e, "client_input_muted")) c.inputMuted = e.getBoolean("client_input_muted");
|
||||
if (has(e, "client_output_muted")) c.outputMuted = e.getBoolean("client_output_muted");
|
||||
if (has(e, "client_input_hardware")) c.inputHardware = e.getBoolean("client_input_hardware");
|
||||
if (has(e, "client_output_hardware")) c.outputHardware = e.getBoolean("client_output_hardware");
|
||||
if (has(e, "client_away")) {
|
||||
c.away = e.getBoolean("client_away");
|
||||
// Both fields travel together, so an absent message here means "no message"
|
||||
// — which `has` cannot tell from "not reported".
|
||||
c.awayMessage = c.away ? TeamspeakConnection.orEmpty(e.get("client_away_message")) : "";
|
||||
} else if (has(e, "client_away_message")) {
|
||||
c.awayMessage = e.get("client_away_message");
|
||||
}
|
||||
if (has(e, "client_talk_power")) c.talkPower = e.getInt("client_talk_power");
|
||||
if (has(e, "client_description")) {
|
||||
c.description = e.get("client_description");
|
||||
conn.ui.onInfoUpdated();
|
||||
}
|
||||
if (has(e, "client_is_channel_commander"))
|
||||
c.channelCommander = e.getBoolean("client_is_channel_commander");
|
||||
announceClientUpdate(e, c, renamed, oldName);
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
/** Renames, talk-power changes and recording flags all arrive as a client update. */
|
||||
private void announceClientUpdate(ClientUpdatedEvent e, ClientEntry c, boolean renamed, String oldName) {
|
||||
if (!conn.isConnected()) return;
|
||||
boolean self = c.id == conn.getSelfClientId();
|
||||
Map<String, String> vars = conn.clientVars(c.id, null);
|
||||
|
||||
if (renamed) {
|
||||
conn.sound(safeInt(e, "invokerid") == conn.getSelfClientId()
|
||||
? SoundEvent.CLIENT_RENAMED_BY_YOU : SoundEvent.CLIENT_RENAMED_BY_OTHER, vars);
|
||||
conn.log(oldName + " is now known as " + conn.clientLink(c.id));
|
||||
}
|
||||
if (safeInt(e, "client_talk_request") > 0 && !self) {
|
||||
conn.sound(SoundEvent.CLIENT_REQUESTED_TALK_POWER, vars);
|
||||
}
|
||||
if (self && has(e, "client_is_talker")) {
|
||||
conn.sound(e.getBoolean("client_is_talker")
|
||||
? SoundEvent.YOU_WERE_GRANTED_TALK_POWER : SoundEvent.YOU_WERE_REVOKED_TALK_POWER, vars);
|
||||
}
|
||||
if (!self && has(e, "client_is_recording")) {
|
||||
boolean recording = e.getBoolean("client_is_recording");
|
||||
if (!recording) {
|
||||
conn.sound(SoundEvent.CLIENT_RECORDING_STOP, vars);
|
||||
} else {
|
||||
conn.sound(conn.inOwnChannel(c.channelId)
|
||||
? SoundEvent.CLIENT_RECORDING_IN_CHANNEL : SoundEvent.CLIENT_RECORDING_START, vars);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelCreate(ChannelCreateEvent e) {
|
||||
int cid = e.getChannelId();
|
||||
String name = e.get("channel_name");
|
||||
int pid = safeInt(e, "cpid");
|
||||
if (pid == 0) pid = safeInt(e, "pid");
|
||||
int order = safeInt(e, "channel_order");
|
||||
ChannelNode node = conn.getModel().putChannel(cid, name, pid, order);
|
||||
long icon = TeamspeakConnection.safeLong(e, "channel_icon_id");
|
||||
if (icon != 0) node.iconId = icon;
|
||||
conn.getModel().relinkChannel(cid, pid, order);
|
||||
if (conn.isConnected()) {
|
||||
conn.sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER,
|
||||
SoundEvent.CHANNEL_CREATED_BY_OTHER), conn.channelVars(cid, e.get("invokername")));
|
||||
conn.log("Channel \"" + conn.channelLink(cid) + "\" was created by " + invokerName(e));
|
||||
}
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelDeleted(ChannelDeletedEvent e) {
|
||||
int cid = e.getChannelId();
|
||||
if (conn.isConnected()) {
|
||||
conn.sound(byInvoker(e, SoundEvent.CHANNEL_DELETED_BY_YOU, SoundEvent.CHANNEL_DELETED_BY_OTHER,
|
||||
SoundEvent.CHANNEL_DELETED_BY_SERVER), conn.channelVars(cid, e.get("invokername")));
|
||||
conn.log("Channel \"" + conn.channelName(cid) + "\" was deleted by " + invokerName(e));
|
||||
}
|
||||
conn.getModel().removeChannel(cid);
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelEdit(ChannelEditedEvent e) {
|
||||
ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid"));
|
||||
if (ch != null) {
|
||||
if (has(e, "channel_name")) ch.name = e.get("channel_name");
|
||||
if (has(e, "channel_order")) ch.order = e.getInt("channel_order");
|
||||
if (has(e, "channel_icon_id")) ch.iconId = TeamspeakConnection.safeLong(e, "channel_icon_id");
|
||||
if (has(e, "channel_topic")) ch.topic = e.get("channel_topic");
|
||||
if (conn.isConnected()) {
|
||||
boolean current = conn.inOwnChannel(ch.id);
|
||||
conn.sound(current
|
||||
? byInvoker(e, SoundEvent.CHANNEL_EDITED_CURRENT_BY_YOU,
|
||||
SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER, SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER)
|
||||
: byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU,
|
||||
SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER),
|
||||
conn.channelVars(ch.id, e.get("invokername")));
|
||||
conn.log("Channel \"" + conn.channelLink(ch.id) + "\" was edited by " + invokerName(e));
|
||||
}
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptions never travel with the notify, so a cached one has to be re-fetched.
|
||||
* Only channels whose description was already loaded are refreshed; the rest pick the
|
||||
* new text up the first time they are looked at.
|
||||
*/
|
||||
@Override
|
||||
public void onChannelDescriptionChanged(ChannelDescriptionEditedEvent e) {
|
||||
ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid"));
|
||||
if (ch == null || !ch.descriptionLoaded) return;
|
||||
ch.descriptionLoaded = false;
|
||||
conn.requestChannelInfo(ch.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelMoved(ChannelMovedEvent e) {
|
||||
ChannelNode ch = conn.getModel().getChannel(safeInt(e, "cid"));
|
||||
if (ch != null) {
|
||||
int parent = has(e, "cpid") ? e.getInt("cpid") : ch.parentId;
|
||||
int order = has(e, "order") ? e.getInt("order") : ch.order;
|
||||
conn.getModel().relinkChannel(ch.id, parent, order);
|
||||
if (conn.isConnected()) {
|
||||
conn.sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER,
|
||||
SoundEvent.CHANNEL_MOVED_BY_OTHER), conn.channelVars(ch.id, e.get("invokername")));
|
||||
conn.log("Channel \"" + conn.channelLink(ch.id) + "\" was moved by " + invokerName(e));
|
||||
}
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelSubscribed(ChannelSubscribedEvent e) {
|
||||
setSubscribed(safeInt(e, "cid"), true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelUnsubscribed(ChannelUnsubscribedEvent e) {
|
||||
setSubscribed(safeInt(e, "cid"), false);
|
||||
}
|
||||
|
||||
private void setSubscribed(int cid, boolean subscribed) {
|
||||
ChannelNode ch = conn.getModel().getChannel(cid);
|
||||
if (ch == null || ch.subscribed == subscribed) return;
|
||||
ch.subscribed = subscribed;
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerEdit(ServerEditedEvent e) {
|
||||
if (has(e, "virtualserver_name")) conn.getModel().setServerName(e.get("virtualserver_name"));
|
||||
if (conn.isConnected()) {
|
||||
conn.sound(byInvoker(e, SoundEvent.SERVER_EDITED_BY_YOU, SoundEvent.SERVER_EDITED_BY_OTHER,
|
||||
SoundEvent.SERVER_EDITED_BY_OTHER), conn.serverVars());
|
||||
}
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerGroupClientAdded(ServerGroupClientAddedEvent e) {
|
||||
ClientEntry c = conn.getModel().getClient(e.getClientId());
|
||||
if (c != null) c.addServerGroup(e.getServerGroupId());
|
||||
boolean self = e.getClientId() == conn.getSelfClientId();
|
||||
conn.sound(self
|
||||
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER,
|
||||
SoundEvent.YOU_SERVERGROUP_ADDED_BY_SERVER)
|
||||
: byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER,
|
||||
SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_SERVER),
|
||||
groupVars(e.getClientId(), e.getName()));
|
||||
conn.log(clientLogName(e.getClientId()) + " was added to server group \"" + e.getName()
|
||||
+ "\" by " + invokerName(e) + ".");
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerGroupClientDeleted(ServerGroupClientDeletedEvent e) {
|
||||
int clientId = safeInt(e, "clid");
|
||||
ClientEntry c = conn.getModel().getClient(clientId);
|
||||
if (c != null) c.removeServerGroup(e.getServerGroupId());
|
||||
boolean self = clientId == conn.getSelfClientId();
|
||||
conn.sound(self
|
||||
? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER,
|
||||
SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_SERVER)
|
||||
: byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER,
|
||||
SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_SERVER),
|
||||
groupVars(clientId, e.get("name")));
|
||||
conn.log(clientLogName(clientId) + " was removed from server group \"" + TeamspeakConnection.orEmpty(e.get("name"))
|
||||
+ "\" by " + invokerName(e) + ".");
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientChannelGroupChanged(ClientChannelGroupChangedEvent e) {
|
||||
ClientEntry c = conn.getModel().getClient(e.getClientId());
|
||||
if (c != null) c.channelGroupId = e.getChannelGroupId();
|
||||
boolean self = e.getClientId() == conn.getSelfClientId();
|
||||
String groupName = conn.getModel().channelGroupName(e.getChannelGroupId());
|
||||
conn.sound(self
|
||||
? byInvoker(e, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER,
|
||||
SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_SERVER)
|
||||
: byInvoker(e, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER,
|
||||
SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_SERVER),
|
||||
groupVars(e.getClientId(), groupName));
|
||||
conn.log("Channel group \"" + TeamspeakConnection.orEmpty(groupName) + "\" was assigned to "
|
||||
+ clientLogName(e.getClientId()) + " by " + invokerName(e) + ".");
|
||||
conn.ui.onModelChanged();
|
||||
}
|
||||
|
||||
/** A client's name for a log line, as a link, falling back to its id once it has left. */
|
||||
private String clientLogName(int clientId) {
|
||||
return conn.clientLink(clientId);
|
||||
}
|
||||
|
||||
/** Picks the event variant matching who caused the change: us, another client, or the server. */
|
||||
private SoundEvent byInvoker(BaseEvent e, SoundEvent byYou, SoundEvent byOther, SoundEvent byServer) {
|
||||
int invoker = safeInt(e, "invokerid");
|
||||
if (invoker == conn.getSelfClientId() && invoker != 0) return byYou;
|
||||
return invoker == 0 ? byServer : byOther;
|
||||
}
|
||||
|
||||
private Map<String, String> groupVars(int clientId, String groupName) {
|
||||
Map<String, String> vars = conn.clientVars(clientId, null);
|
||||
vars.put("groupname", TeamspeakConnection.orEmpty(groupName));
|
||||
return vars;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelList(ChannelListEvent e) {
|
||||
// Incremental channel arriving during connect.
|
||||
int cid = e.getChannelId();
|
||||
String name = e.get("channel_name");
|
||||
int pid = safeInt(e, "cpid");
|
||||
int order = safeInt(e, "channel_order");
|
||||
ChannelNode node = conn.getModel().putChannel(cid, name, pid, order);
|
||||
long icon = TeamspeakConnection.safeLong(e, "channel_icon_id");
|
||||
if (icon != 0) node.iconId = icon;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerGroupList(ServerGroupListEvent e) {
|
||||
int id = safeInt(e, "sgid");
|
||||
if (id > 0) conn.getModel().putServerGroup(toGroup(e, id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelGroupList(ChannelGroupListEvent e) {
|
||||
int id = safeInt(e, "cgid");
|
||||
if (id > 0) conn.getModel().putChannelGroup(toGroup(e, id));
|
||||
}
|
||||
|
||||
private static Group toGroup(BaseEvent e, int id) {
|
||||
return new Group(id, e.get("name"), TeamspeakConnection.safeLong(e, "iconid"), safeInt(e, "sortid"),
|
||||
safeInt(e, "type"), safeInt(e, "n_member_addp"), safeInt(e, "n_member_removep"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPermissionList(PermissionListEvent e) {
|
||||
conn.getModel().putPermissionName(e.get("permname"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientNeededPermissions(ClientNeededPermissionsEvent e) {
|
||||
conn.getModel().putSelfPermissionValue(safeInt(e, "permid"), safeInt(e, "permvalue"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextMessage(TextMessageEvent e) {
|
||||
if (e.getInvokerId() == conn.getSelfClientId()) return; // don't echo our own
|
||||
ConnectionListener.ChatScope scope;
|
||||
SoundEvent notification;
|
||||
switch (e.getTargetMode()) {
|
||||
case CLIENT:
|
||||
scope = ConnectionListener.ChatScope.PRIVATE;
|
||||
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CLIENT;
|
||||
break;
|
||||
case CHANNEL:
|
||||
scope = ConnectionListener.ChatScope.CHANNEL;
|
||||
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CHANNEL;
|
||||
break;
|
||||
default:
|
||||
scope = ConnectionListener.ChatScope.SERVER;
|
||||
notification = SoundEvent.CHAT_RECEIVED_MESSAGE_SERVER;
|
||||
break;
|
||||
}
|
||||
conn.sound(notification, conn.clientVars(e.getInvokerId(), e.getInvokerName()));
|
||||
conn.ui.onChat(scope, e.getInvokerId(), e.getInvokerName(), e.getMessage());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientPoke(ClientPokeEvent e) {
|
||||
conn.sound(SoundEvent.OTHER_RECEIVED_POKE, conn.clientVars(e.getInvokerId(), e.getInvokerName()));
|
||||
conn.ui.onPoke(TeamspeakConnection.orEmpty(e.getInvokerName()), TeamspeakConnection.orEmpty(e.get("msg")));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnknownEvent(UnknownTeamspeakEvent e) {
|
||||
if ("notifyconnectioninfo".equals(e.getCommand())) conn.stats.onReport(e);
|
||||
else if ("notifychannelpermlist".equals(e.getCommand())) conn.channels.onPermListEntry(e);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
/** Who caused an event, linked where the server named them; "the server" when nobody did. */
|
||||
private String invokerName(BaseEvent e) {
|
||||
String name = TeamspeakConnection.orEmpty(e.get("invokername"));
|
||||
if (name.isEmpty()) return "the server";
|
||||
return conn.clientLink(safeInt(e, "invokerid"),
|
||||
TeamspeakConnection.orEmpty(e.get("invokeruid")), name);
|
||||
}
|
||||
|
||||
private static int safeInt(BaseEvent e, String key) {
|
||||
try {
|
||||
String v = e.get(key);
|
||||
return v == null ? 0 : Integer.parseInt(v.trim());
|
||||
} catch (Exception ex) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the event actually carries a field. ts3j answers a missing key with an
|
||||
* empty string rather than null, so a plain null check is always true — and a
|
||||
* partial update (say, someone muting) would otherwise look like it reported
|
||||
* every other field as well.
|
||||
*/
|
||||
private static boolean has(BaseEvent e, String key) {
|
||||
String value = e.get(key);
|
||||
return value != null && !value.isEmpty();
|
||||
}
|
||||
|
||||
/** Parses a comma-separated id list (e.g. server groups "6,12,15"). */
|
||||
private static int[] parseIntList(String csv) {
|
||||
if (csv == null || csv.isEmpty()) return new int[0];
|
||||
String[] parts = csv.split(",");
|
||||
int[] out = new int[parts.length];
|
||||
int n = 0;
|
||||
for (String p : parts) {
|
||||
try {
|
||||
out[n++] = Integer.parseInt(p.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
return n == parts.length ? out : java.util.Arrays.copyOf(out, n);
|
||||
}
|
||||
}
|
||||
@@ -33,4 +33,11 @@ public interface ConnectionListener {
|
||||
|
||||
/** Someone poked the local client. */
|
||||
void onPoke(String fromName, String message);
|
||||
|
||||
/**
|
||||
* Something happened on the server that native TS3 records in the server
|
||||
* log: a client connecting/disconnecting/moving, a channel being edited,
|
||||
* a group being (un)assigned, and so on.
|
||||
*/
|
||||
void onServerLog(String message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
package com.ts3client.net;
|
||||
|
||||
import com.github.manevolent.ts3j.api.Client;
|
||||
import com.github.manevolent.ts3j.command.SingleCommand;
|
||||
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
|
||||
import com.github.manevolent.ts3j.event.UnknownTeamspeakEvent;
|
||||
import com.github.manevolent.ts3j.protocol.PacketKind;
|
||||
import com.github.manevolent.ts3j.protocol.ProtocolRole;
|
||||
import com.github.manevolent.ts3j.protocol.packet.statistics.PacketStatistics;
|
||||
import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket;
|
||||
import com.github.manevolent.ts3j.util.Pair;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Builds {@link ConnectionStats} snapshots for the info dialog. The local client's own
|
||||
* figures are read straight from its live packet counters; a remote client's come from
|
||||
* {@code getconnectioninfo}, whose {@code notifyconnectioninfo} report arrives
|
||||
* asynchronously via {@link #onReport} and is matched back up by client id.
|
||||
*/
|
||||
final class ConnectionStatsCollector {
|
||||
|
||||
private final TeamspeakConnection conn;
|
||||
|
||||
/** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */
|
||||
private final Map<Integer, PendingConnInfo> pending = new ConcurrentHashMap<>();
|
||||
|
||||
ConnectionStatsCollector(TeamspeakConnection conn) {
|
||||
this.conn = conn;
|
||||
}
|
||||
|
||||
/** Drops any requests left over from a connection that just went away. */
|
||||
void clear() {
|
||||
pending.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches connection statistics for a client and delivers them to
|
||||
* {@code callback} (invoked off the Swing EDT — the callback is responsible
|
||||
* for marshalling). May be called repeatedly to poll.
|
||||
*/
|
||||
void request(int clientId, Consumer<ConnectionStats> callback) {
|
||||
if (clientId == conn.getSelfClientId()) {
|
||||
new Thread(() -> callback.accept(buildLocalStats()), "ts3j-conninfo-self").start();
|
||||
} else {
|
||||
new Thread(() -> requestRemote(clientId, callback), "ts3j-conninfo").start();
|
||||
}
|
||||
}
|
||||
|
||||
/** Matches an asynchronously arriving {@code notifyconnectioninfo} report to its request. */
|
||||
void onReport(UnknownTeamspeakEvent e) {
|
||||
int clid = safeInt(e.get("clid"));
|
||||
PendingConnInfo p = pending.remove(clid);
|
||||
if (p == null) return;
|
||||
applyConnectionFields(p.stats, e.getMap());
|
||||
p.callback.accept(p.stats);
|
||||
}
|
||||
|
||||
/** Builds a live snapshot of the local client's connection from its own counters. */
|
||||
private ConnectionStats buildLocalStats() {
|
||||
ConnectionStats s = new ConnectionStats();
|
||||
s.clientId = conn.getSelfClientId();
|
||||
s.self = true;
|
||||
s.live = true;
|
||||
s.packetLoss = 0; // the local client has no server->client loss figure
|
||||
|
||||
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
|
||||
if (self != null) {
|
||||
s.nickname = self.nickname;
|
||||
s.version = self.version;
|
||||
s.platform = self.platform;
|
||||
s.idleTimeMs = self.idleTimeMs;
|
||||
}
|
||||
long connectedAt = conn.connectedAtMs;
|
||||
if (connectedAt > 0) s.connectedTimeMs = System.currentTimeMillis() - connectedAt;
|
||||
|
||||
LocalTeamspeakClientSocket c = conn.client;
|
||||
if (c == null) return s;
|
||||
|
||||
try {
|
||||
Pair<Double, Double> ping = c.getPing();
|
||||
s.pingMs = ping.getKey() * 1000.0;
|
||||
s.pingDeviationMs = ping.getValue() * 1000.0;
|
||||
} catch (Exception ignored) {
|
||||
// ping unavailable; leave as unknown
|
||||
}
|
||||
|
||||
long pSent = 0, pRecv = 0, bSent = 0, bRecv = 0;
|
||||
long bwSs = 0, bwRs = 0, bwSm = 0, bwRm = 0;
|
||||
for (PacketKind kind : PacketKind.values()) {
|
||||
PacketStatistics st = c.getStatistics(kind);
|
||||
ConnectionStats.KindStats ks = new ConnectionStats.KindStats(mapKind(kind));
|
||||
ks.packetLoss = 0; // the local client cannot measure its own server->client loss
|
||||
ks.packetsSent = st.getSentPackets();
|
||||
ks.packetsReceived = st.getReceivedPackets();
|
||||
ks.bytesSent = st.getSentBytes();
|
||||
ks.bytesReceived = st.getReceivedBytes();
|
||||
ks.bandwidthSentLastSecond = st.getSentBytesLastSecond();
|
||||
ks.bandwidthReceivedLastSecond = st.getReceivedBytesLastSecond();
|
||||
ks.bandwidthSentLastMinute = st.getSentBytesLastMinute();
|
||||
ks.bandwidthReceivedLastMinute = st.getReceivedBytesLastMinute();
|
||||
s.perKind.add(ks);
|
||||
|
||||
pSent += ks.packetsSent;
|
||||
pRecv += ks.packetsReceived;
|
||||
bSent += ks.bytesSent;
|
||||
bRecv += ks.bytesReceived;
|
||||
bwSs += ks.bandwidthSentLastSecond;
|
||||
bwRs += ks.bandwidthReceivedLastSecond;
|
||||
bwSm += ks.bandwidthSentLastMinute;
|
||||
bwRm += ks.bandwidthReceivedLastMinute;
|
||||
}
|
||||
s.packetsSentTotal = pSent;
|
||||
s.packetsReceivedTotal = pRecv;
|
||||
s.bytesSentTotal = bSent;
|
||||
s.bytesReceivedTotal = bRecv;
|
||||
s.bandwidthSentLastSecond = bwSs;
|
||||
s.bandwidthReceivedLastSecond = bwRs;
|
||||
s.bandwidthSentLastMinute = bwSm;
|
||||
s.bandwidthReceivedLastMinute = bwRm;
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a remote client's connection info. First loads {@code clientinfo}
|
||||
* for the stable fields, then issues {@code getconnectioninfo} whose
|
||||
* {@code notifyconnectioninfo} report arrives asynchronously via
|
||||
* {@link #onReport}. If the report does not arrive shortly (e.g. the
|
||||
* server withholds it), the clientinfo-only snapshot is delivered instead.
|
||||
*/
|
||||
private void requestRemote(int clientId, Consumer<ConnectionStats> callback) {
|
||||
ConnectionStats s = new ConnectionStats();
|
||||
s.clientId = clientId;
|
||||
ClientEntry entry = conn.getModel().getClient(clientId);
|
||||
if (entry != null) s.nickname = entry.nickname;
|
||||
|
||||
LocalTeamspeakClientSocket client = conn.client;
|
||||
try {
|
||||
Client c = client.getClientInfo(clientId);
|
||||
if (c != null) {
|
||||
s.version = TeamspeakConnection.orEmpty(c.getVersion());
|
||||
s.platform = TeamspeakConnection.orEmpty(c.getPlatform());
|
||||
s.ip = TeamspeakConnection.orEmpty(c.getIp());
|
||||
s.idleTimeMs = c.getIdleTime();
|
||||
if (entry == null) s.nickname = TeamspeakConnection.orEmpty(c.getNickname());
|
||||
applyConnectionFields(s, c.getMap());
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// clientinfo may be permission-restricted; continue with what we have
|
||||
}
|
||||
|
||||
PendingConnInfo p = new PendingConnInfo(callback, s);
|
||||
pending.put(clientId, p);
|
||||
|
||||
boolean sent = false;
|
||||
try {
|
||||
SingleCommand cmd = new SingleCommand("getconnectioninfo", ProtocolRole.CLIENT,
|
||||
new CommandSingleParameter("clid", Integer.toString(clientId)));
|
||||
client.executeCommand(cmd).complete();
|
||||
sent = true;
|
||||
} catch (Exception ignored) {
|
||||
// command failed; fall back to the clientinfo snapshot below
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
try {
|
||||
Thread.sleep(700); // give notifyconnectioninfo a chance to arrive
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
// If the report already arrived, onReport removed and delivered it.
|
||||
if (pending.remove(clientId, p)) {
|
||||
callback.accept(s);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses TeamSpeak {@code connection_*} fields from a command map into {@code s}. */
|
||||
private static void applyConnectionFields(ConnectionStats s, Map<String, String> m) {
|
||||
double ping = parseDouble(m.get("connection_ping"));
|
||||
if (ping >= 0) s.pingMs = ping;
|
||||
double dev = parseDouble(m.get("connection_ping_deviation"));
|
||||
if (dev >= 0) s.pingDeviationMs = dev;
|
||||
double loss = parseDouble(m.get("connection_packetloss_total"));
|
||||
if (loss < 0) loss = parseDouble(m.get("connection_server2client_packetloss_total"));
|
||||
if (loss >= 0) s.packetLoss = loss;
|
||||
|
||||
long connected = parseLong(m.get("connection_connected_time"));
|
||||
if (connected >= 0) s.connectedTimeMs = connected;
|
||||
String ip = m.get("connection_client_ip");
|
||||
if (ip != null && !ip.isEmpty()) s.ip = ip;
|
||||
|
||||
s.packetsSentTotal = pick(s.packetsSentTotal, m.get("connection_packets_sent_total"));
|
||||
s.packetsReceivedTotal = pick(s.packetsReceivedTotal, m.get("connection_packets_received_total"));
|
||||
s.bytesSentTotal = pick(s.bytesSentTotal, m.get("connection_bytes_sent_total"));
|
||||
s.bytesReceivedTotal = pick(s.bytesReceivedTotal, m.get("connection_bytes_received_total"));
|
||||
s.bandwidthSentLastSecond =
|
||||
pick(s.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_total"));
|
||||
s.bandwidthReceivedLastSecond =
|
||||
pick(s.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_total"));
|
||||
s.bandwidthSentLastMinute =
|
||||
pick(s.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_total"));
|
||||
s.bandwidthReceivedLastMinute =
|
||||
pick(s.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_total"));
|
||||
s.filetransferBandwidthSent =
|
||||
pick(s.filetransferBandwidthSent, m.get("connection_filetransfer_bandwidth_sent"));
|
||||
s.filetransferBandwidthReceived =
|
||||
pick(s.filetransferBandwidthReceived, m.get("connection_filetransfer_bandwidth_received"));
|
||||
|
||||
applyPerKindFields(s, m);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the per-category {@code connection_*_<kind>} fields (as sent in a
|
||||
* {@code notifyconnectioninfo} report) into {@code s}, merging into any
|
||||
* existing rows. Categories absent from the map are left untouched.
|
||||
*/
|
||||
private static void applyPerKindFields(ConnectionStats s, Map<String, String> m) {
|
||||
for (ConnectionStats.Kind kind : ConnectionStats.Kind.values()) {
|
||||
String suffix = kind.name().toLowerCase(java.util.Locale.ROOT); // keepalive/control/speech
|
||||
String probe = m.get("connection_packets_sent_" + suffix);
|
||||
String probe2 = m.get("connection_server2client_packetloss_" + suffix);
|
||||
if (probe == null && probe2 == null) continue; // this category not reported
|
||||
|
||||
ConnectionStats.KindStats ks = s.getOrCreateKind(kind);
|
||||
ks.packetsSent = pick(ks.packetsSent, m.get("connection_packets_sent_" + suffix));
|
||||
ks.packetsReceived = pick(ks.packetsReceived, m.get("connection_packets_received_" + suffix));
|
||||
ks.bytesSent = pick(ks.bytesSent, m.get("connection_bytes_sent_" + suffix));
|
||||
ks.bytesReceived = pick(ks.bytesReceived, m.get("connection_bytes_received_" + suffix));
|
||||
ks.bandwidthSentLastSecond =
|
||||
pick(ks.bandwidthSentLastSecond, m.get("connection_bandwidth_sent_last_second_" + suffix));
|
||||
ks.bandwidthReceivedLastSecond =
|
||||
pick(ks.bandwidthReceivedLastSecond, m.get("connection_bandwidth_received_last_second_" + suffix));
|
||||
ks.bandwidthSentLastMinute =
|
||||
pick(ks.bandwidthSentLastMinute, m.get("connection_bandwidth_sent_last_minute_" + suffix));
|
||||
ks.bandwidthReceivedLastMinute =
|
||||
pick(ks.bandwidthReceivedLastMinute, m.get("connection_bandwidth_received_last_minute_" + suffix));
|
||||
double kloss = parseDouble(m.get("connection_server2client_packetloss_" + suffix));
|
||||
if (kloss >= 0) ks.packetLoss = kloss;
|
||||
}
|
||||
}
|
||||
|
||||
private static long pick(long current, String value) {
|
||||
long v = parseLong(value);
|
||||
return v >= 0 ? v : current;
|
||||
}
|
||||
|
||||
private static ConnectionStats.Kind mapKind(PacketKind kind) {
|
||||
switch (kind) {
|
||||
case KEEPALIVE:
|
||||
return ConnectionStats.Kind.KEEPALIVE;
|
||||
case SPEECH:
|
||||
return ConnectionStats.Kind.SPEECH;
|
||||
default:
|
||||
return ConnectionStats.Kind.CONTROL;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses a long, returning -1 for null/blank/non-numeric input. */
|
||||
private static long parseLong(String s) {
|
||||
if (s == null || s.isEmpty()) return -1;
|
||||
try {
|
||||
return Long.parseLong(s.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses a double, returning -1 for null/blank/non-numeric input. */
|
||||
private static double parseDouble(String s) {
|
||||
if (s == null || s.isEmpty()) return -1;
|
||||
try {
|
||||
return Double.parseDouble(s.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return the parsed value, or 0 when it is absent or not a number */
|
||||
private static int safeInt(String value) {
|
||||
try {
|
||||
return value == null ? 0 : Integer.parseInt(value.trim());
|
||||
} catch (NumberFormatException ex) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Callback + accumulating snapshot for an in-flight {@code getconnectioninfo}. */
|
||||
private static final class PendingConnInfo {
|
||||
final Consumer<ConnectionStats> callback;
|
||||
final ConnectionStats stats;
|
||||
|
||||
PendingConnInfo(Consumer<ConnectionStats> callback, ConnectionStats stats) {
|
||||
this.callback = callback;
|
||||
this.stats = stats;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,26 @@ public final class Group {
|
||||
public final long iconId;
|
||||
/** Display order among groups; lower comes first. */
|
||||
public final int sortId;
|
||||
/** TS3 group type: 0 = template, 1 = regular, 2 = query (server groups only). */
|
||||
public final int type;
|
||||
/** Power needed to add a member to this group. */
|
||||
public final int neededMemberAddPower;
|
||||
/** Power needed to remove a member from this group. */
|
||||
public final int neededMemberRemovePower;
|
||||
|
||||
public Group(int id, String name, long iconId, int sortId) {
|
||||
public Group(int id, String name, long iconId, int sortId, int type,
|
||||
int neededMemberAddPower, int neededMemberRemovePower) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.iconId = iconId;
|
||||
this.sortId = sortId;
|
||||
this.type = type;
|
||||
this.neededMemberAddPower = neededMemberAddPower;
|
||||
this.neededMemberRemovePower = neededMemberRemovePower;
|
||||
}
|
||||
|
||||
/** Regular, user-assignable groups exclude templates and query-only groups. */
|
||||
public boolean isRegular() {
|
||||
return type == 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,13 +18,29 @@ public final class ServerModel {
|
||||
private final Map<Integer, ClientEntry> clients = new LinkedHashMap<>();
|
||||
private final Map<Integer, Group> serverGroups = new LinkedHashMap<>();
|
||||
private final Map<Integer, Group> channelGroups = new LinkedHashMap<>();
|
||||
/**
|
||||
* Permission id -> name, learned from the server's {@code permissionlist} response.
|
||||
* Entries carry no id of their own: TS3 numbers them by position in the response,
|
||||
* and the empty "group_id_end" separator records marking category boundaries don't
|
||||
* count toward that position, so only {@link #putPermissionName} advances it.
|
||||
*/
|
||||
private final Map<Integer, String> permissionNames = new LinkedHashMap<>();
|
||||
private int nextPermissionId;
|
||||
/** Permission id -> the local client's resolved value, from {@code notifyclientneededpermissions}. */
|
||||
private final Map<Integer, Integer> selfPermissionValues = new LinkedHashMap<>();
|
||||
private String serverName = "TeamSpeak Server";
|
||||
/** The channel group everyone starts in, e.g. "Guest" — not worth offering to (re)assign. */
|
||||
private int defaultChannelGroupId;
|
||||
|
||||
public synchronized void clear() {
|
||||
channels.clear();
|
||||
clients.clear();
|
||||
serverGroups.clear();
|
||||
channelGroups.clear();
|
||||
permissionNames.clear();
|
||||
nextPermissionId = 0;
|
||||
selfPermissionValues.clear();
|
||||
defaultChannelGroupId = 0;
|
||||
}
|
||||
|
||||
// ---- groups ----
|
||||
@@ -78,6 +94,64 @@ public final class ServerModel {
|
||||
return g == null ? null : g.name;
|
||||
}
|
||||
|
||||
/** Regular (non-template, non-query) server groups, ordered for display. */
|
||||
public synchronized List<Group> allServerGroups() {
|
||||
return regularGroups(serverGroups);
|
||||
}
|
||||
|
||||
/** Regular (non-template, non-query) channel groups, ordered for display. */
|
||||
public synchronized List<Group> allChannelGroups() {
|
||||
return regularGroups(channelGroups);
|
||||
}
|
||||
|
||||
private static List<Group> regularGroups(Map<Integer, Group> groups) {
|
||||
List<Group> list = new ArrayList<>();
|
||||
for (Group g : groups.values()) {
|
||||
if (g.isRegular()) list.add(g);
|
||||
}
|
||||
list.sort(Comparator.comparingInt((Group g) -> g.sortId).thenComparingInt(g -> g.id));
|
||||
return list;
|
||||
}
|
||||
|
||||
// ---- permissions ----
|
||||
|
||||
/** Records the next named entry of a {@code permissionlist} response; ignores separators. */
|
||||
public synchronized void putPermissionName(String name) {
|
||||
if (name == null || name.isEmpty()) return;
|
||||
permissionNames.put(nextPermissionId, name);
|
||||
nextPermissionId++;
|
||||
}
|
||||
|
||||
public synchronized void putSelfPermissionValue(int permId, int value) {
|
||||
selfPermissionValues.put(permId, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The local client's resolved value for a named permission (e.g.
|
||||
* {@code "i_group_needed_member_add_power"}), or {@code 0} if it isn't known
|
||||
* yet (the {@code permissionlist} request is still in flight, or the server
|
||||
* never reported a non-default value for it).
|
||||
*/
|
||||
/** Whether the server's {@code permissionlist} has arrived, so ids can be named at all. */
|
||||
public synchronized boolean hasPermissionNames() {
|
||||
return !permissionNames.isEmpty();
|
||||
}
|
||||
|
||||
/** The name the server gave permission id {@code id}, or {@code null} if unknown. */
|
||||
public synchronized String permissionName(int id) {
|
||||
return permissionNames.get(id);
|
||||
}
|
||||
|
||||
public synchronized int selfPermissionValue(String name) {
|
||||
for (Map.Entry<Integer, String> entry : permissionNames.entrySet()) {
|
||||
if (entry.getValue().equals(name)) {
|
||||
Integer value = selfPermissionValues.get(entry.getKey());
|
||||
if (value != null) return value;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public synchronized String getServerName() {
|
||||
return serverName;
|
||||
}
|
||||
@@ -86,6 +160,14 @@ public final class ServerModel {
|
||||
if (name != null && !name.isEmpty()) this.serverName = name;
|
||||
}
|
||||
|
||||
public synchronized int defaultChannelGroupId() {
|
||||
return defaultChannelGroupId;
|
||||
}
|
||||
|
||||
public synchronized void setDefaultChannelGroupId(int id) {
|
||||
this.defaultChannelGroupId = id;
|
||||
}
|
||||
|
||||
// ---- channels ----
|
||||
|
||||
public synchronized ChannelNode putChannel(int id, String name, int parentId, int order) {
|
||||
@@ -129,6 +211,41 @@ public final class ServerModel {
|
||||
c.order = newOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* The channels sharing a parent with {@code channelId}, in the order they appear in the
|
||||
* tree and excluding the channel itself — what the editor offers as places to sort after.
|
||||
*/
|
||||
public synchronized List<ChannelNode> siblingsOf(int channelId) {
|
||||
ChannelNode channel = channels.get(channelId);
|
||||
if (channel == null) return new ArrayList<>();
|
||||
// Ordered with the channel still in place: the order links form a chain, and
|
||||
// pulling a link out of it first would strand everything below.
|
||||
List<ChannelNode> siblings = childrenOf(channel.parentId);
|
||||
siblings.remove(channel);
|
||||
return siblings;
|
||||
}
|
||||
|
||||
/**
|
||||
* The channels directly below {@code parentId} (0 being the top level), in the order
|
||||
* they appear in the tree — what a new channel can be sorted after.
|
||||
*/
|
||||
public synchronized List<ChannelNode> childrenOf(int parentId) {
|
||||
List<ChannelNode> children = new ArrayList<>();
|
||||
for (ChannelNode c : channels.values()) {
|
||||
if (c.parentId == parentId) children.add(c);
|
||||
}
|
||||
sortSiblings(children);
|
||||
return children;
|
||||
}
|
||||
|
||||
/** The channel of that exact name directly below {@code parentId}, or {@code null}. */
|
||||
public synchronized ChannelNode findChannelByName(int parentId, String name) {
|
||||
for (ChannelNode c : channels.values()) {
|
||||
if (c.parentId == parentId && c.name.equals(name)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The "/"-separated path of a channel from the root, e.g. {@code "Lobby/Games"}. */
|
||||
public synchronized String channelPath(int id) {
|
||||
ChannelNode c = channels.get(id);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,8 @@ public final class FileTransferManager {
|
||||
|
||||
/** TeamSpeak error id returned by {@code ftgetfilelist} for an empty directory. */
|
||||
private static final int ERROR_DATABASE_EMPTY_RESULT = 0x0501;
|
||||
/** How long to wait for the server to finish listing a directory. */
|
||||
private static final long LIST_TIMEOUT_MS = 15_000;
|
||||
|
||||
private final LocalTeamspeakClientSocket socket;
|
||||
/** Host we are connected to, used when the server reports no dedicated file-transfer host. */
|
||||
@@ -70,6 +72,14 @@ public final class FileTransferManager {
|
||||
* routes back to the waiting transfer thread.
|
||||
*/
|
||||
private final Map<Integer, CompletableFuture<Map<String, String>>> pendingInits = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* The {@code ftgetfilelist} in progress. The server answers that command with a
|
||||
* {@code notifyfilelist} event per entry and a closing {@code notifyfilelistfinished}
|
||||
* rather than with rows of the command's own reply, so the entries are collected here
|
||||
* until the listing ends. One listing may be in flight at a time.
|
||||
*/
|
||||
private volatile FileListRequest fileListRequest;
|
||||
private final TS3Listener ftEventListener = new TS3Listener() {
|
||||
@Override
|
||||
public void onUnknownEvent(UnknownTeamspeakEvent e) {
|
||||
@@ -88,34 +98,49 @@ public final class FileTransferManager {
|
||||
/**
|
||||
* Lists the files and subdirectories directly under {@code path} in the given
|
||||
* channel's repository. Returns an empty list for an empty directory.
|
||||
*
|
||||
* <p>Blocks until the server has sent the whole listing, so it must not be called from
|
||||
* the event thread that delivers it.
|
||||
*/
|
||||
public List<RemoteFile> list(int channelId, String channelPassword, String path)
|
||||
throws Exception {
|
||||
String directory = path == null || path.isEmpty() ? "/" : path;
|
||||
SingleCommand cmd = new SingleCommand("ftgetfilelist", ProtocolRole.CLIENT,
|
||||
new CommandSingleParameter("cid", Integer.toString(channelId)),
|
||||
new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword),
|
||||
new CommandSingleParameter("path", path == null || path.isEmpty() ? "/" : path));
|
||||
new CommandSingleParameter("path", directory));
|
||||
|
||||
List<RemoteFile> files = new ArrayList<>();
|
||||
Iterable<SingleCommand> rows;
|
||||
FileListRequest request = new FileListRequest(channelId, directory);
|
||||
fileListRequest = request;
|
||||
try {
|
||||
rows = socket.executeCommand(cmd).get();
|
||||
socket.executeCommand(cmd).complete();
|
||||
// The command's reply only acknowledges it; the entries are events, and the
|
||||
// server marks their end with notifyfilelistfinished.
|
||||
request.finished.get(LIST_TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
||||
} catch (com.github.manevolent.ts3j.command.CommandException e) {
|
||||
if (e.getErrorId() == ERROR_DATABASE_EMPTY_RESULT) return files;
|
||||
throw e;
|
||||
if (e.getErrorId() != ERROR_DATABASE_EMPTY_RESULT) throw e;
|
||||
} catch (TimeoutException e) {
|
||||
throw new IOException("The server did not finish listing " + directory);
|
||||
} finally {
|
||||
fileListRequest = null;
|
||||
}
|
||||
for (SingleCommand row : rows) {
|
||||
Map<String, String> m = row.toMap();
|
||||
String name = m.get("name");
|
||||
if (name == null || name.isEmpty()) continue;
|
||||
files.add(new RemoteFile(
|
||||
return new ArrayList<>(request.files);
|
||||
}
|
||||
|
||||
/** Collects one {@code notifyfilelist} entry for the listing in flight. */
|
||||
private void collectListEntry(Map<String, String> row) {
|
||||
FileListRequest request = fileListRequest;
|
||||
if (request == null) return;
|
||||
String cid = row.get("cid");
|
||||
if (cid != null && !cid.isEmpty() && parseLong(cid) != request.channelId) return;
|
||||
String name = row.get("name");
|
||||
if (name == null || name.isEmpty()) return;
|
||||
request.files.add(new RemoteFile(
|
||||
name,
|
||||
m.getOrDefault("path", path),
|
||||
parseLong(m.get("size")),
|
||||
"0".equals(m.get("type")),
|
||||
parseLong(m.get("datetime"))));
|
||||
}
|
||||
return files;
|
||||
row.getOrDefault("path", request.path),
|
||||
parseLong(row.get("size")),
|
||||
"0".equals(row.get("type")),
|
||||
parseLong(row.get("datetime"))));
|
||||
}
|
||||
|
||||
/** Creates a new directory at {@code dirPath} (a full path such as {@code /new}). */
|
||||
@@ -351,6 +376,15 @@ public final class FileTransferManager {
|
||||
String command = e.getCommand();
|
||||
if (command == null) return;
|
||||
Map<String, String> map = e.getMap();
|
||||
if (command.equals("notifyfilelist")) {
|
||||
collectListEntry(map);
|
||||
return;
|
||||
}
|
||||
if (command.equals("notifyfilelistfinished")) {
|
||||
FileListRequest request = fileListRequest;
|
||||
if (request != null) request.finished.complete(null);
|
||||
return;
|
||||
}
|
||||
Integer ftfid = tryParseInt(map.get("clientftfid"));
|
||||
if (ftfid == null) return;
|
||||
CompletableFuture<Map<String, String>> future = pendingInits.get(ftfid);
|
||||
@@ -454,4 +488,17 @@ public final class FileTransferManager {
|
||||
String m = r.getMessage();
|
||||
return m != null ? m : r.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
/** One in-flight {@code ftgetfilelist} and the entries received for it so far. */
|
||||
private static final class FileListRequest {
|
||||
final int channelId;
|
||||
final String path;
|
||||
final List<RemoteFile> files = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||
final CompletableFuture<Void> finished = new CompletableFuture<>();
|
||||
|
||||
FileListRequest(int channelId, String path) {
|
||||
this.channelId = channelId;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ public final class SoundNotifier {
|
||||
if (script == null) return;
|
||||
|
||||
String resolved = script.resolve(withDefaults(variables));
|
||||
double volume = Math.max(0, Math.min(1.0, settings.soundVolume));
|
||||
double volume = Math.max(0, Math.min(1.0, settings.effectiveSoundVolume()));
|
||||
if (volume <= 0) return;
|
||||
|
||||
if (script.kind() == SoundScript.Kind.SAY) {
|
||||
|
||||
@@ -37,6 +37,10 @@ public final class BBCode {
|
||||
/** CSS class on links that leave the client, so they are visibly not an identity. */
|
||||
public static final String EXTERNAL_LINK_CLASS = "extlink";
|
||||
|
||||
/** A single {@code [url=href]label[/url]} pair, the only markup the log carries. */
|
||||
private static final Pattern URL_LINK =
|
||||
Pattern.compile("(?i)\\[url=([^\\]]+)](.*?)\\[/url]");
|
||||
|
||||
private BBCode() {
|
||||
}
|
||||
|
||||
@@ -70,6 +74,29 @@ public final class BBCode {
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders text carrying {@code [url=…]…[/url]} links and nothing else: everything
|
||||
* outside those tags stays literal. The server log is written this way, so that a
|
||||
* name happening to contain bracket markup is shown as it is rather than rendered.
|
||||
*/
|
||||
public static String linksToHtml(String input) {
|
||||
if (input == null || input.isEmpty()) return "";
|
||||
StringBuilder out = new StringBuilder();
|
||||
Matcher m = URL_LINK.matcher(input);
|
||||
int last = 0;
|
||||
while (m.find()) {
|
||||
out.append(escape(input.substring(last, m.start())));
|
||||
String href = m.group(1);
|
||||
if (SAFE_URL.matcher(href).matches()) {
|
||||
out.append(link(href)).append(escape(m.group(2))).append(linkClose(href));
|
||||
} else {
|
||||
out.append(escape(m.group()));
|
||||
}
|
||||
last = m.end();
|
||||
}
|
||||
return out.append(escape(input.substring(last))).toString();
|
||||
}
|
||||
|
||||
/** Escapes plain text for HTML without interpreting any BBCode. */
|
||||
public static String escape(String s) {
|
||||
if (s == null) return "";
|
||||
|
||||
@@ -9,7 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
/**
|
||||
* Pins the ts3j behaviour {@code TeamspeakConnection.has(...)} exists for: a field
|
||||
* Pins the ts3j behaviour {@code ConnectionEventHandler.has(...)} exists for: a field
|
||||
* the event never carried reads back as an empty string, so a null check would treat
|
||||
* every partial update (someone muting, say) as if it reported every other field too.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ts3client.text;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* The server log's renderer: it turns the client's own links into anchors and leaves
|
||||
* everything else — names carrying bracket markup included — as plain text.
|
||||
*/
|
||||
class BBCodeLinksTest {
|
||||
|
||||
@Test
|
||||
void rendersClientAndChannelLinks() {
|
||||
String log = TsLink.clientBBCode(7, "uid=", "Bob") + " connected to channel \""
|
||||
+ TsLink.channelBBCode(3, "Lobby") + "\"";
|
||||
String html = BBCode.linksToHtml(log);
|
||||
assertTrue(html.contains("href=\"client://7/uid=~Bob\""), html);
|
||||
assertTrue(html.contains(">Bob</a>"), html);
|
||||
assertTrue(html.contains("href=\"channel://3/Lobby\""), html);
|
||||
assertTrue(html.contains(">Lobby</a>"), html);
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesOtherMarkupLiteral() {
|
||||
assertEquals("[b]not bold[/b] & <i>", BBCode.linksToHtml("[b]not bold[/b] & <i>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsBracketsInsideALabelLiteral() {
|
||||
String html = BBCode.linksToHtml(TsLink.channelBBCode(3, "[cspacer]---"));
|
||||
assertTrue(html.contains(">[cspacer]---</a>"), html);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsHrefsThatAreNotLinks() {
|
||||
assertEquals("[url=javascript:alert(1)]x[/url]", BBCode.linksToHtml("[url=javascript:alert(1)]x[/url]"));
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<ts3j.version>1.0.3</ts3j.version>
|
||||
<jsvg.version>2.1.0</jsvg.version>
|
||||
<flatlaf.version>3.7.2</flatlaf.version>
|
||||
<surefire.version>3.5.6</surefire.version>
|
||||
</properties>
|
||||
|
||||
@@ -50,6 +51,11 @@
|
||||
<artifactId>jsvg</artifactId>
|
||||
<version>${jsvg.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.formdev</groupId>
|
||||
<artifactId>flatlaf</artifactId>
|
||||
<version>${flatlaf.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ts3client</groupId>
|
||||
<artifactId>ts3-client-core</artifactId>
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
<groupId>com.github.weisj</groupId>
|
||||
<artifactId>jsvg</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.formdev</groupId>
|
||||
<artifactId>flatlaf</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
@@ -84,6 +88,15 @@
|
||||
<include>**</include>
|
||||
</includes>
|
||||
</filter>
|
||||
<!-- FlatLaf instantiates its UI delegates by name and reads its
|
||||
themes from .properties resources; neither is reachable
|
||||
statically, so minimizeJar must not touch it. -->
|
||||
<filter>
|
||||
<artifact>com.formdev:flatlaf</artifact>
|
||||
<includes>
|
||||
<include>**</include>
|
||||
</includes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
|
||||
@@ -2,14 +2,14 @@ package com.ts3client;
|
||||
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.ui.IconTheme;
|
||||
import com.ts3client.ui.LookAndFeelManager;
|
||||
import com.ts3client.ui.MainFrame;
|
||||
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.UIManager;
|
||||
|
||||
/**
|
||||
* Application entry point. Loads settings, applies the system look-and-feel and
|
||||
* shows the main window on the Swing event dispatch thread.
|
||||
* Application entry point. Loads settings, installs the look-and-feel and shows the
|
||||
* main window on the Swing event dispatch thread.
|
||||
*/
|
||||
public final class Main {
|
||||
|
||||
@@ -23,11 +23,7 @@ public final class Main {
|
||||
final Settings settings = Settings.load();
|
||||
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
try {
|
||||
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
|
||||
} catch (Exception ignored) {
|
||||
// fall back to cross-platform L&F
|
||||
}
|
||||
LookAndFeelManager.install(settings.appearance);
|
||||
IconTheme.get().reload(settings);
|
||||
new MainFrame(settings).setVisible(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelSettings;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.ButtonGroup;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JRadioButton;
|
||||
import javax.swing.JSpinner;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.SpinnerNumberModel;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FlowLayout;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.Insets;
|
||||
|
||||
/**
|
||||
* The channel editor's "Advanced" tab: the phonetic name, the delete delay of a temporary
|
||||
* channel, voice encryption, and the two client limits.
|
||||
*/
|
||||
final class ChannelAdvancedPanel extends JPanel implements ChannelDialog.Tab {
|
||||
|
||||
/** TeamSpeak's ceiling for {@code channel_delete_delay}, in seconds. */
|
||||
private static final int MAX_DELETE_DELAY = 604800;
|
||||
|
||||
private final JTextField phoneticName = new JTextField();
|
||||
private final JSpinner deleteDelay =
|
||||
Spinners.compact(new JSpinner(new SpinnerNumberModel(0, 0, MAX_DELETE_DELAY, 1)));
|
||||
private final JButton deleteDelayMax = new JButton("max");
|
||||
private final JCheckBox encrypted = new JCheckBox("Voice Data encrypted");
|
||||
|
||||
private final JRadioButton maxUsersUnlimited = new JRadioButton("Unlimited");
|
||||
private final JRadioButton maxUsersLimited = new JRadioButton("Limited");
|
||||
private final JSpinner maxUsers = Spinners.compact(new JSpinner(new SpinnerNumberModel(16, 0, 65535, 1)));
|
||||
|
||||
private final JRadioButton familyInherited = new JRadioButton("Inherited");
|
||||
private final JRadioButton familyUnlimited = new JRadioButton("Unlimited");
|
||||
private final JRadioButton familyLimited = new JRadioButton("Limited");
|
||||
private final JSpinner familyMaxUsers =
|
||||
Spinners.compact(new JSpinner(new SpinnerNumberModel(16, 0, 65535, 1)));
|
||||
|
||||
ChannelAdvancedPanel() {
|
||||
setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
|
||||
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
|
||||
|
||||
phoneticName.setToolTipText("Set phonetic nickname used for this channel by text-to-speech.");
|
||||
deleteDelay.setToolTipText("<html><p>Delay in seconds after temporary channels are deleted"
|
||||
+ " after the last user left the channel.</p></html>");
|
||||
deleteDelayMax.setToolTipText("Set delete delay to maximum value");
|
||||
deleteDelayMax.addActionListener(a -> deleteDelay.setValue(MAX_DELETE_DELAY));
|
||||
encrypted.setToolTipText("Voice data in this channel will be encrypted.");
|
||||
|
||||
ButtonGroup users = new ButtonGroup();
|
||||
users.add(maxUsersUnlimited);
|
||||
users.add(maxUsersLimited);
|
||||
maxUsersUnlimited.setToolTipText("Unlimited number of clients can join this channel.");
|
||||
maxUsersLimited.setToolTipText("Limit number of clients in this channel.");
|
||||
maxUsersUnlimited.addActionListener(a -> syncLimits());
|
||||
maxUsersLimited.addActionListener(a -> syncLimits());
|
||||
|
||||
ButtonGroup family = new ButtonGroup();
|
||||
family.add(familyInherited);
|
||||
family.add(familyUnlimited);
|
||||
family.add(familyLimited);
|
||||
familyInherited.setToolTipText("Inherit the client limit by parent channel.");
|
||||
familyUnlimited.setToolTipText("The channel subtree can be joined by unlimited number of clients.");
|
||||
familyLimited.setToolTipText("Limit the number of clients in the channel subtree.");
|
||||
for (JRadioButton radio : new JRadioButton[]{familyInherited, familyUnlimited, familyLimited}) {
|
||||
radio.addActionListener(a -> syncLimits());
|
||||
}
|
||||
|
||||
JPanel other = otherSettings();
|
||||
other.setAlignmentX(0f);
|
||||
fixHeight(other);
|
||||
add(other);
|
||||
add(Box.createVerticalStrut(6));
|
||||
|
||||
JPanel limits = new JPanel();
|
||||
limits.setLayout(new BoxLayout(limits, BoxLayout.X_AXIS));
|
||||
limits.add(limitBox("Max Users", new JRadioButton[]{maxUsersUnlimited, maxUsersLimited}, maxUsers));
|
||||
limits.add(Box.createHorizontalStrut(8));
|
||||
limits.add(limitBox("Family Max Users",
|
||||
new JRadioButton[]{familyInherited, familyUnlimited, familyLimited}, familyMaxUsers));
|
||||
limits.setAlignmentX(0f);
|
||||
fixHeight(limits);
|
||||
add(limits);
|
||||
add(Box.createVerticalGlue());
|
||||
}
|
||||
|
||||
/** The delete delay only means anything for a channel that disappears on its own. */
|
||||
void setChannelType(ChannelSettings.Type type) {
|
||||
boolean temporary = type == ChannelSettings.Type.TEMPORARY;
|
||||
deleteDelay.setEnabled(temporary);
|
||||
deleteDelayMax.setEnabled(temporary);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void read(ChannelSettings settings) {
|
||||
phoneticName.setText(settings.phoneticName);
|
||||
deleteDelay.setValue(Math.min(MAX_DELETE_DELAY, Math.max(0, settings.deleteDelay)));
|
||||
encrypted.setSelected(settings.encrypted);
|
||||
|
||||
if (settings.maxClientsUnlimited) maxUsersUnlimited.setSelected(true);
|
||||
else maxUsersLimited.setSelected(true);
|
||||
maxUsers.setValue(settings.maxClients);
|
||||
|
||||
if (settings.familyInherited) familyInherited.setSelected(true);
|
||||
else if (settings.familyUnlimited) familyUnlimited.setSelected(true);
|
||||
else familyLimited.setSelected(true);
|
||||
familyMaxUsers.setValue(settings.maxFamilyClients);
|
||||
|
||||
setChannelType(settings.type);
|
||||
syncLimits();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(ChannelSettings settings) {
|
||||
settings.phoneticName = phoneticName.getText();
|
||||
settings.deleteDelay = (Integer) deleteDelay.getValue();
|
||||
settings.encrypted = encrypted.isSelected();
|
||||
settings.maxClientsUnlimited = maxUsersUnlimited.isSelected();
|
||||
settings.maxClients = (Integer) maxUsers.getValue();
|
||||
settings.familyInherited = familyInherited.isSelected();
|
||||
settings.familyUnlimited = familyUnlimited.isSelected();
|
||||
settings.maxFamilyClients = (Integer) familyMaxUsers.getValue();
|
||||
}
|
||||
|
||||
private void syncLimits() {
|
||||
maxUsers.setEnabled(maxUsersLimited.isSelected());
|
||||
familyMaxUsers.setEnabled(familyLimited.isSelected());
|
||||
}
|
||||
|
||||
private JPanel otherSettings() {
|
||||
JPanel panel = new JPanel(new GridBagLayout());
|
||||
panel.setBorder(BorderFactory.createTitledBorder("Other Settings"));
|
||||
GridBagConstraints c = new GridBagConstraints();
|
||||
c.insets = new Insets(3, 4, 3, 4);
|
||||
c.anchor = GridBagConstraints.WEST;
|
||||
c.fill = GridBagConstraints.HORIZONTAL;
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = 0;
|
||||
panel.add(new JLabel("Phonetic Name:"), c);
|
||||
c.gridx = 1;
|
||||
c.weightx = 1;
|
||||
panel.add(phoneticName, c);
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = 1;
|
||||
c.weightx = 0;
|
||||
panel.add(new JLabel("Delete delay:"), c);
|
||||
JPanel delay = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
|
||||
delay.add(deleteDelay);
|
||||
delay.add(deleteDelayMax);
|
||||
c.gridx = 1;
|
||||
c.weightx = 1;
|
||||
panel.add(delay, c);
|
||||
|
||||
c.gridx = 1;
|
||||
c.gridy = 2;
|
||||
panel.add(encrypted, c);
|
||||
return panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* A limit box: its choices stacked, with the count beside the last of them —
|
||||
* "Limited", the only choice it belongs to.
|
||||
*/
|
||||
private JPanel limitBox(String title, JRadioButton[] choices, JSpinner spinner) {
|
||||
JPanel box = new JPanel();
|
||||
box.setLayout(new BoxLayout(box, BoxLayout.Y_AXIS));
|
||||
box.setBorder(BorderFactory.createTitledBorder(title));
|
||||
for (int i = 0; i < choices.length; i++) {
|
||||
JRadioButton radio = choices[i];
|
||||
radio.setAlignmentX(0f);
|
||||
if (i < choices.length - 1) {
|
||||
box.add(radio);
|
||||
continue;
|
||||
}
|
||||
JPanel row = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0));
|
||||
row.setAlignmentX(0f);
|
||||
row.add(radio);
|
||||
row.add(spinner);
|
||||
box.add(row);
|
||||
}
|
||||
box.add(Box.createVerticalGlue());
|
||||
return box;
|
||||
}
|
||||
|
||||
/** Keeps a section at its natural height, so the free space collects at the bottom. */
|
||||
private static void fixHeight(JPanel panel) {
|
||||
panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, panel.getPreferredSize().height));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelSettings;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.ButtonGroup;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JRadioButton;
|
||||
import javax.swing.JSlider;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.Insets;
|
||||
|
||||
/**
|
||||
* The channel editor's "Audio" tab: the codec preset, or the codec and quality picked by
|
||||
* hand, with the bandwidth one talking client then costs.
|
||||
*/
|
||||
final class ChannelAudioPanel extends JPanel implements ChannelDialog.Tab {
|
||||
|
||||
/** Codec names in TeamSpeak's own id order, which is what the combo box index is. */
|
||||
private static final String[] CODEC_NAMES = {
|
||||
"Speex Narrowband", "Speex Wideband", "Speex Ultra-Wideband",
|
||||
"CELT Mono", "Opus Voice", "Opus Music"};
|
||||
|
||||
/**
|
||||
* Bandwidth per talking client in KiB/s at quality 0 and 10, per codec, as published by
|
||||
* TeamSpeak. The steps in between are interpolated: the client shows a figure per
|
||||
* quality step, and only the two ends of each codec's range are documented.
|
||||
*/
|
||||
private static final double[][] BANDWIDTH_RANGE = {
|
||||
{2.49, 5.22}, {2.69, 7.37}, {2.73, 7.57}, {6.10, 13.92}, {2.73, 7.71}, {3.08, 11.87}};
|
||||
|
||||
private static final int CODEC_OPUS_VOICE = 4;
|
||||
private static final int CODEC_OPUS_MUSIC = 5;
|
||||
|
||||
private final JRadioButton voiceMobile = new JRadioButton("Voice Mobile");
|
||||
private final JRadioButton voiceDesktop = new JRadioButton("Voice Desktop");
|
||||
private final JRadioButton music = new JRadioButton("Music");
|
||||
private final JRadioButton custom = new JRadioButton("Custom");
|
||||
private final JComboBox<String> codec = new JComboBox<>(CODEC_NAMES);
|
||||
private final JSlider quality = new JSlider(0, 10, 6);
|
||||
private final JLabel qualityValue = new JLabel("6");
|
||||
private final JLabel bandwidth = new JLabel();
|
||||
private final JPanel customSettings = new JPanel(new GridBagLayout());
|
||||
|
||||
ChannelAudioPanel() {
|
||||
super(new BorderLayout(8, 8));
|
||||
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
|
||||
|
||||
ButtonGroup presets = new ButtonGroup();
|
||||
presets.add(voiceMobile);
|
||||
presets.add(voiceDesktop);
|
||||
presets.add(music);
|
||||
presets.add(custom);
|
||||
voiceMobile.addActionListener(a -> applyPreset(CODEC_OPUS_VOICE, 4));
|
||||
voiceDesktop.addActionListener(a -> applyPreset(CODEC_OPUS_VOICE, 6));
|
||||
music.addActionListener(a -> applyPreset(CODEC_OPUS_MUSIC, 6));
|
||||
custom.addActionListener(a -> setCustomEnabled(true));
|
||||
|
||||
codec.addActionListener(a -> updateBandwidth());
|
||||
quality.setMajorTickSpacing(1);
|
||||
quality.setPaintTicks(true);
|
||||
quality.setSnapToTicks(true);
|
||||
quality.addChangeListener(e -> updateBandwidth());
|
||||
|
||||
add(presetBox(), BorderLayout.WEST);
|
||||
add(customBox(), BorderLayout.CENTER);
|
||||
add(bandwidthRow(), BorderLayout.SOUTH);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void read(ChannelSettings settings) {
|
||||
codec.setSelectedIndex(Math.max(0, Math.min(CODEC_NAMES.length - 1, settings.codec)));
|
||||
quality.setValue(Math.max(0, Math.min(10, settings.codecQuality)));
|
||||
if (settings.codec == CODEC_OPUS_VOICE && settings.codecQuality == 4) voiceMobile.setSelected(true);
|
||||
else if (settings.codec == CODEC_OPUS_VOICE && settings.codecQuality == 6) voiceDesktop.setSelected(true);
|
||||
else if (settings.codec == CODEC_OPUS_MUSIC && settings.codecQuality == 6) music.setSelected(true);
|
||||
else custom.setSelected(true);
|
||||
setCustomEnabled(custom.isSelected());
|
||||
updateBandwidth();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(ChannelSettings settings) {
|
||||
settings.codec = codec.getSelectedIndex();
|
||||
settings.codecQuality = quality.getValue();
|
||||
}
|
||||
|
||||
private void applyPreset(int codecId, int qualityValue) {
|
||||
codec.setSelectedIndex(codecId);
|
||||
quality.setValue(qualityValue);
|
||||
setCustomEnabled(false);
|
||||
updateBandwidth();
|
||||
}
|
||||
|
||||
private void setCustomEnabled(boolean enabled) {
|
||||
customSettings.setEnabled(enabled);
|
||||
for (java.awt.Component child : customSettings.getComponents()) child.setEnabled(enabled);
|
||||
}
|
||||
|
||||
private void updateBandwidth() {
|
||||
qualityValue.setText(Integer.toString(quality.getValue()));
|
||||
int index = Math.max(0, Math.min(BANDWIDTH_RANGE.length - 1, codec.getSelectedIndex()));
|
||||
double[] range = BANDWIDTH_RANGE[index];
|
||||
double value = range[0] + (range[1] - range[0]) * quality.getValue() / 10.0;
|
||||
bandwidth.setText(String.format("%.2f KiB/s", value));
|
||||
}
|
||||
|
||||
private JPanel presetBox() {
|
||||
JPanel box = new JPanel();
|
||||
box.setLayout(new BoxLayout(box, BoxLayout.Y_AXIS));
|
||||
box.setBorder(BorderFactory.createTitledBorder("Presets"));
|
||||
for (JRadioButton radio : new JRadioButton[]{voiceMobile, voiceDesktop, music, custom}) {
|
||||
radio.setAlignmentX(0f);
|
||||
box.add(radio);
|
||||
}
|
||||
box.add(Box.createVerticalGlue());
|
||||
return box;
|
||||
}
|
||||
|
||||
private JPanel customBox() {
|
||||
customSettings.setBorder(BorderFactory.createTitledBorder("Custom Settings"));
|
||||
GridBagConstraints c = new GridBagConstraints();
|
||||
c.insets = new Insets(4, 4, 4, 4);
|
||||
c.anchor = GridBagConstraints.WEST;
|
||||
c.fill = GridBagConstraints.HORIZONTAL;
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = 0;
|
||||
customSettings.add(new JLabel("Codec:"), c);
|
||||
c.gridx = 1;
|
||||
c.weightx = 1;
|
||||
c.gridwidth = 2;
|
||||
customSettings.add(codec, c);
|
||||
|
||||
c.gridwidth = 1;
|
||||
c.gridx = 0;
|
||||
c.gridy = 1;
|
||||
c.weightx = 0;
|
||||
customSettings.add(new JLabel("Quality:"), c);
|
||||
c.gridx = 1;
|
||||
c.weightx = 1;
|
||||
customSettings.add(quality, c);
|
||||
c.gridx = 2;
|
||||
c.weightx = 0;
|
||||
customSettings.add(qualityValue, c);
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = 2;
|
||||
c.weighty = 1;
|
||||
customSettings.add(Box.createVerticalGlue(), c);
|
||||
return customSettings;
|
||||
}
|
||||
|
||||
private JPanel bandwidthRow() {
|
||||
JPanel row = new JPanel(new BorderLayout(6, 0));
|
||||
row.add(new JLabel("Bandwidth usage:"), BorderLayout.WEST);
|
||||
row.add(bandwidth, BorderLayout.CENTER);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ChannelSettings;
|
||||
import com.ts3client.net.TeamspeakConnection;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.ImageIcon;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JMenuItem;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JPasswordField;
|
||||
import javax.swing.JPopupMenu;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTabbedPane;
|
||||
import javax.swing.JTextArea;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.Timer;
|
||||
import javax.swing.event.DocumentEvent;
|
||||
import javax.swing.event.DocumentListener;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.Insets;
|
||||
import java.awt.Window;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* TeamSpeak's channel editor: the channel's name, icon, password, topic and description
|
||||
* above four tabs holding everything else. The official client uses the same dialog to
|
||||
* create a channel and to edit one, and so does this.
|
||||
*
|
||||
* <p>When editing, the channel's properties are not in the tree model — the tree only
|
||||
* carries what the server pushes for display — so the dialog opens empty and fills itself
|
||||
* from a {@code channelinfo}, with its own permissions arriving separately. Saving sends
|
||||
* only what was actually changed, because a {@code channeledit} carrying one property the
|
||||
* client may not modify is refused as a whole.
|
||||
*
|
||||
* <p>When creating, there is nothing to read: the dialog starts from the defaults its
|
||||
* caller chose and sends a {@code channelcreate} carrying whatever deviates from a plain
|
||||
* new channel, for the same reason.
|
||||
*/
|
||||
final class ChannelDialog extends JDialog {
|
||||
|
||||
/** A tab that maps a part of the channel's settings onto its controls. */
|
||||
interface Tab {
|
||||
void read(ChannelSettings settings);
|
||||
|
||||
void write(ChannelSettings settings);
|
||||
}
|
||||
|
||||
/** How long, and how often, to keep looking for a channel icon that is still downloading. */
|
||||
private static final int ICON_RETRY_MS = 500;
|
||||
private static final int ICON_RETRIES = 20;
|
||||
|
||||
/** What the password field shows for an existing password, which is never readable. */
|
||||
private static final String PASSWORD_PLACEHOLDER = "••••••••";
|
||||
|
||||
private static final String NAME_TOOLTIP = "<html>Name of this channel displayed in the tree."
|
||||
+ "<table>"
|
||||
+ "<tr><td style='white-space:nowrap'>Syntax: \"<strong>[?Spacer#]Text</strong>\"</td></tr>"
|
||||
+ "<tr><td style='white-space:nowrap'>Where \"?\" stands for an alignment"
|
||||
+ " (r=right, c=center, l=left),</td></tr>"
|
||||
+ "<tr><td style='white-space:nowrap'>\"*\" will repeat the text to fill the whole line.</td></tr>"
|
||||
+ "<tr><td style='white-space:nowrap'>Change \"#\" to get a unique channel name.</td></tr>"
|
||||
+ "<tr><td style='white-space:nowrap'>[lSpacer0] a left aligned text</td></tr>"
|
||||
+ "<tr><td style='white-space:nowrap'>[cSpacer1] a centered text</td></tr>"
|
||||
+ "<tr><td style='white-space:nowrap'>[rSpacer2] a right aligned text</td></tr>"
|
||||
+ "<tr><td style='white-space:nowrap'>Use one of the three-character-blocks as text for a"
|
||||
+ " special spacer: \"---\", \"...\", \"-.-\", \"___\", \"-..\"</td></tr>"
|
||||
+ "</table></html>";
|
||||
|
||||
private final TeamspeakConnection conn;
|
||||
private final GroupIcons groupIcons;
|
||||
/** The channel being edited, or {@code null} when a new one is being created. */
|
||||
private final ChannelNode channel;
|
||||
/** Where a new channel goes; 0 is the top level. Unused when editing. */
|
||||
private final int parentId;
|
||||
|
||||
private final JTextField name = new JTextField();
|
||||
private final JPasswordField password = new JPasswordField();
|
||||
private final JTextField topic = new JTextField();
|
||||
private final JTextArea description = new JTextArea(5, 40);
|
||||
private final JButton iconButton = new JButton();
|
||||
private final JButton ok = new JButton("OK");
|
||||
|
||||
private final ChannelStandardPanel standardPanel;
|
||||
private final ChannelAudioPanel audioPanel = new ChannelAudioPanel();
|
||||
private final ChannelPermissionsPanel permissionsPanel = new ChannelPermissionsPanel();
|
||||
private final ChannelAdvancedPanel advancedPanel = new ChannelAdvancedPanel();
|
||||
|
||||
/** The tabs backed by {@link ChannelSettings}; the permissions tab has its own source. */
|
||||
private final List<Tab> settingsTabs;
|
||||
|
||||
/** The channel as the server last described it; the baseline every change is measured against. */
|
||||
private ChannelSettings original;
|
||||
private long iconId;
|
||||
private boolean passwordEdited;
|
||||
|
||||
/** The editor for an existing channel. */
|
||||
static ChannelDialog toEdit(Window owner, TeamspeakConnection conn, GroupIcons groupIcons,
|
||||
ChannelNode channel) {
|
||||
return new ChannelDialog(owner, conn, groupIcons, "Edit Channel: " + channel.name,
|
||||
channel, channel.parentId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor for a channel that does not exist yet.
|
||||
*
|
||||
* @param parentId the channel it goes below, 0 being the top level
|
||||
* @param defaults what the dialog opens with, e.g. the channel type its caller prefers
|
||||
*/
|
||||
static ChannelDialog toCreate(Window owner, TeamspeakConnection conn, GroupIcons groupIcons,
|
||||
String title, int parentId, ChannelSettings defaults) {
|
||||
return new ChannelDialog(owner, conn, groupIcons, title, null, parentId, defaults);
|
||||
}
|
||||
|
||||
private ChannelDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, String title,
|
||||
ChannelNode channel, int parentId, ChannelSettings defaults) {
|
||||
super(owner, title, ModalityType.APPLICATION_MODAL);
|
||||
this.conn = conn;
|
||||
this.groupIcons = groupIcons;
|
||||
this.channel = channel;
|
||||
this.parentId = parentId;
|
||||
this.standardPanel = new ChannelStandardPanel(channel != null
|
||||
? conn.getModel().siblingsOf(channel.id) : conn.getModel().childrenOf(parentId));
|
||||
|
||||
standardPanel.onTypeChanged(advancedPanel::setChannelType);
|
||||
settingsTabs = List.of(standardPanel, audioPanel, advancedPanel);
|
||||
|
||||
JTabbedPane tabs = new JTabbedPane();
|
||||
tabs.addTab("Standard", standardPanel);
|
||||
tabs.addTab("Audio", audioPanel);
|
||||
tabs.addTab("Permissions", permissionsPanel);
|
||||
tabs.addTab("Advanced", advancedPanel);
|
||||
|
||||
// The tabs need no more than their natural height, so they sit at the bottom
|
||||
// with the buttons and every spare pixel goes to the description instead.
|
||||
JPanel bottom = new JPanel(new BorderLayout(0, 6));
|
||||
bottom.add(tabs, BorderLayout.CENTER);
|
||||
bottom.add(buttons(), BorderLayout.SOUTH);
|
||||
|
||||
getContentPane().setLayout(new BorderLayout(0, 6));
|
||||
getContentPane().add(header(), BorderLayout.CENTER);
|
||||
getContentPane().add(bottom, BorderLayout.SOUTH);
|
||||
|
||||
setEnabledForLoading(false);
|
||||
Dialogs.closeOnEscape(this);
|
||||
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||
setSize(new Dimension(560, 620));
|
||||
setMinimumSize(new Dimension(480, 520));
|
||||
setLocationRelativeTo(owner);
|
||||
|
||||
if (channel != null) load();
|
||||
else prepareNew(defaults);
|
||||
}
|
||||
|
||||
// ---- layout ----
|
||||
|
||||
private JPanel header() {
|
||||
JPanel panel = new JPanel(new GridBagLayout());
|
||||
panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 0, 8));
|
||||
GridBagConstraints c = new GridBagConstraints();
|
||||
c.insets = new Insets(3, 4, 3, 4);
|
||||
c.anchor = GridBagConstraints.WEST;
|
||||
c.fill = GridBagConstraints.HORIZONTAL;
|
||||
|
||||
name.setToolTipText(NAME_TOOLTIP);
|
||||
password.setToolTipText("Optional password for this channel.");
|
||||
topic.setToolTipText("Optional topic for this channel, displayed in the info area on the right.");
|
||||
password.getDocument().addDocumentListener(new DocumentListener() {
|
||||
@Override
|
||||
public void insertUpdate(DocumentEvent e) {
|
||||
passwordEdited = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeUpdate(DocumentEvent e) {
|
||||
passwordEdited = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void changedUpdate(DocumentEvent e) {
|
||||
passwordEdited = true;
|
||||
}
|
||||
});
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = 0;
|
||||
panel.add(new JLabel("Name:"), c);
|
||||
c.gridx = 1;
|
||||
c.weightx = 1;
|
||||
panel.add(name, c);
|
||||
c.gridx = 2;
|
||||
c.weightx = 0;
|
||||
panel.add(iconField(), c);
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = 1;
|
||||
panel.add(new JLabel("Password:"), c);
|
||||
c.gridx = 1;
|
||||
c.gridwidth = 2;
|
||||
c.weightx = 1;
|
||||
panel.add(password, c);
|
||||
|
||||
c.gridwidth = 1;
|
||||
c.gridx = 0;
|
||||
c.gridy = 2;
|
||||
c.weightx = 0;
|
||||
panel.add(new JLabel("Topic:"), c);
|
||||
c.gridx = 1;
|
||||
c.gridwidth = 2;
|
||||
c.weightx = 1;
|
||||
panel.add(topic, c);
|
||||
|
||||
c.gridwidth = 1;
|
||||
c.gridx = 0;
|
||||
c.gridy = 3;
|
||||
c.weightx = 0;
|
||||
panel.add(new JLabel("Description:"), c);
|
||||
JButton popOut = new JButton("Edit…");
|
||||
popOut.setToolTipText("Tear off description editor");
|
||||
popOut.addActionListener(a -> editDescription());
|
||||
c.gridx = 1;
|
||||
c.gridwidth = 2;
|
||||
c.fill = GridBagConstraints.NONE;
|
||||
panel.add(popOut, c);
|
||||
|
||||
description.setLineWrap(true);
|
||||
description.setWrapStyleWord(true);
|
||||
c.gridx = 0;
|
||||
c.gridy = 4;
|
||||
c.gridwidth = 3;
|
||||
c.weightx = 1;
|
||||
c.weighty = 1;
|
||||
c.fill = GridBagConstraints.BOTH;
|
||||
panel.add(new JScrollPane(description), c);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private JPanel iconField() {
|
||||
iconButton.setToolTipText("Set channel icon.");
|
||||
iconButton.setPreferredSize(new Dimension(26, 24));
|
||||
iconButton.setMargin(new Insets(1, 1, 1, 1));
|
||||
iconButton.addActionListener(a -> chooseIcon());
|
||||
iconButton.addMouseListener(new MouseAdapter() {
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e) {
|
||||
if (e.isPopupTrigger()) showIconMenu(e);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(MouseEvent e) {
|
||||
if (e.isPopupTrigger()) showIconMenu(e);
|
||||
}
|
||||
});
|
||||
JPanel row = new JPanel(new BorderLayout(4, 0));
|
||||
row.add(new JLabel("Icon:"), BorderLayout.WEST);
|
||||
row.add(iconButton, BorderLayout.CENTER);
|
||||
return row;
|
||||
}
|
||||
|
||||
private JPanel buttons() {
|
||||
JButton cancel = new JButton("Cancel");
|
||||
ok.addActionListener(a -> save());
|
||||
cancel.addActionListener(a -> dispose());
|
||||
JPanel panel = new JPanel(new BorderLayout());
|
||||
panel.setBorder(BorderFactory.createEmptyBorder(0, 8, 8, 8));
|
||||
JPanel right = new JPanel();
|
||||
right.add(ok);
|
||||
right.add(cancel);
|
||||
panel.add(right, BorderLayout.EAST);
|
||||
getRootPane().setDefaultButton(ok);
|
||||
return panel;
|
||||
}
|
||||
|
||||
// ---- loading ----
|
||||
|
||||
private void load() {
|
||||
conn.requestChannelSettings(channel.id, (settings, error) -> SwingUtilities.invokeLater(() -> {
|
||||
if (error != null) {
|
||||
JOptionPane.showMessageDialog(this, "Could not read the channel: " + error,
|
||||
"Error", JOptionPane.ERROR_MESSAGE);
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
apply(settings);
|
||||
}));
|
||||
conn.requestChannelPermissions(channel.id, (permissions, error) -> SwingUtilities.invokeLater(() -> {
|
||||
if (error != null) permissionsPanel.showUnavailable("No permission to view channel permissions.");
|
||||
else permissionsPanel.read(permissions);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the dialog on a channel that does not exist yet. Its permissions start empty
|
||||
* and editable — they are applied to the channel once the server has created it.
|
||||
*/
|
||||
private void prepareNew(ChannelSettings defaults) {
|
||||
permissionsPanel.read(Map.of());
|
||||
apply(defaults);
|
||||
name.selectAll();
|
||||
}
|
||||
|
||||
private void apply(ChannelSettings settings) {
|
||||
original = settings;
|
||||
name.setText(settings.name);
|
||||
topic.setText(settings.topic);
|
||||
description.setText(settings.description);
|
||||
description.setCaretPosition(0);
|
||||
if (settings.hasPassword) password.setText(PASSWORD_PLACEHOLDER);
|
||||
passwordEdited = false;
|
||||
setIconId(settings.iconId);
|
||||
for (Tab tab : settingsTabs) tab.read(settings);
|
||||
setEnabledForLoading(true);
|
||||
name.requestFocusInWindow();
|
||||
}
|
||||
|
||||
private void setEnabledForLoading(boolean loaded) {
|
||||
ok.setEnabled(loaded);
|
||||
name.setEnabled(loaded);
|
||||
password.setEnabled(loaded);
|
||||
topic.setEnabled(loaded);
|
||||
description.setEnabled(loaded);
|
||||
iconButton.setEnabled(loaded);
|
||||
}
|
||||
|
||||
// ---- icon ----
|
||||
|
||||
private void setIconId(long id) {
|
||||
setIconId(id, ICON_RETRIES);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param retries how many more times to look for an icon that is still downloading;
|
||||
* one that never arrives (deleted, or not ours to read) simply stays blank
|
||||
*/
|
||||
private void setIconId(long id, int retries) {
|
||||
iconId = id;
|
||||
ImageIcon icon = groupIcons.icon(id);
|
||||
iconButton.setIcon(icon);
|
||||
if (icon != null || id == 0 || retries <= 0) return;
|
||||
Timer retry = new Timer(ICON_RETRY_MS, null);
|
||||
retry.setRepeats(false);
|
||||
retry.addActionListener(e -> {
|
||||
if (iconId == id) setIconId(id, retries - 1);
|
||||
});
|
||||
retry.start();
|
||||
}
|
||||
|
||||
private void chooseIcon() {
|
||||
IconChooserDialog chooser = new IconChooserDialog(this, conn, groupIcons);
|
||||
chooser.setVisible(true);
|
||||
if (chooser.isAccepted()) setIconId(chooser.getIconId());
|
||||
}
|
||||
|
||||
private void showIconMenu(MouseEvent e) {
|
||||
JPopupMenu menu = new JPopupMenu();
|
||||
JMenuItem edit = new JMenuItem("Edit Icon");
|
||||
edit.addActionListener(a -> chooseIcon());
|
||||
JMenuItem remove = new JMenuItem("Remove Icon");
|
||||
remove.setEnabled(iconId != 0);
|
||||
remove.addActionListener(a -> setIconId(0));
|
||||
menu.add(edit);
|
||||
menu.add(remove);
|
||||
menu.show(e.getComponent(), e.getX(), e.getY());
|
||||
}
|
||||
|
||||
// ---- description ----
|
||||
|
||||
private void editDescription() {
|
||||
DescriptionEditorDialog editor = new DescriptionEditorDialog(this, description.getText());
|
||||
editor.setVisible(true);
|
||||
if (editor.isAccepted()) description.setText(editor.getDescription());
|
||||
}
|
||||
|
||||
// ---- saving ----
|
||||
|
||||
private void save() {
|
||||
if (original == null) return;
|
||||
ChannelSettings edited = original.copy();
|
||||
edited.name = name.getText().trim();
|
||||
edited.topic = topic.getText();
|
||||
edited.description = description.getText();
|
||||
edited.password = passwordEdited ? new String(password.getPassword()) : null;
|
||||
edited.iconId = iconId;
|
||||
for (Tab tab : settingsTabs) tab.write(edited);
|
||||
|
||||
if (edited.name.isEmpty()) {
|
||||
JOptionPane.showMessageDialog(this, "The channel needs a name.",
|
||||
"Error", JOptionPane.ERROR_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (channel == null) {
|
||||
ok.setEnabled(false);
|
||||
conn.createChannel(parentId, edited.creationParameters(), permissionsPanel.changed(),
|
||||
(channelId, error) -> SwingUtilities.invokeLater(
|
||||
() -> finish(error, "Could not create the channel: ")));
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, String> changes = edited.changesFrom(original);
|
||||
Map<String, Integer> setPermissions = permissionsPanel.changed();
|
||||
List<String> clearedPermissions = permissionsPanel.cleared();
|
||||
if (changes.isEmpty() && setPermissions.isEmpty() && clearedPermissions.isEmpty()) {
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
ok.setEnabled(false);
|
||||
conn.applyChannelEdit(channel.id, changes, setPermissions, clearedPermissions,
|
||||
error -> SwingUtilities.invokeLater(() -> finish(error, "Could not save the channel: ")));
|
||||
}
|
||||
|
||||
/** Closes the dialog, or reports why the server would not have it and lets the user retry. */
|
||||
private void finish(String error, String errorPrefix) {
|
||||
if (error == null) {
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
ok.setEnabled(true);
|
||||
JOptionPane.showMessageDialog(this, errorPrefix + error, "Error", JOptionPane.ERROR_MESSAGE);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ChannelSettings;
|
||||
|
||||
import javax.swing.JMenuItem;
|
||||
import javax.swing.JPopupMenu;
|
||||
@@ -20,6 +21,18 @@ final class ChannelMenu {
|
||||
join.addActionListener(a -> actions.joinChannel(channel.id));
|
||||
menu.add(join);
|
||||
menu.addSeparator();
|
||||
// Both start out temporary: a channel made from another channel's menu is
|
||||
// usually a throwaway one, and a permanent channel is a deliberate choice.
|
||||
JMenuItem create = new JMenuItem("Create Channel", Icons.of("CHANNEL_CREATE"));
|
||||
create.addActionListener(a -> actions.createChannel(null, ChannelSettings.Type.TEMPORARY));
|
||||
menu.add(create);
|
||||
JMenuItem createSub = new JMenuItem("Create Sub-Channel", Icons.of("CHANNEL_CREATE_SUB"));
|
||||
createSub.addActionListener(a -> actions.createChannel(channel, ChannelSettings.Type.TEMPORARY));
|
||||
menu.add(createSub);
|
||||
JMenuItem edit = new JMenuItem("Edit Channel", Icons.of("CHANNEL_EDIT"));
|
||||
edit.addActionListener(a -> actions.editChannel(channel));
|
||||
menu.add(edit);
|
||||
menu.addSeparator();
|
||||
addSubscriptionItems(menu, channel, actions);
|
||||
JMenuItem files = new JMenuItem("Browse files", Icons.of("FILETRANSFER"));
|
||||
files.addActionListener(a -> actions.browseFiles(channel));
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JSpinner;
|
||||
import javax.swing.SpinnerNumberModel;
|
||||
import java.awt.Color;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.Insets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The channel editor's "Permissions" tab: the needed-power permissions set on the channel
|
||||
* itself, which is the subset of channel permissions TeamSpeak surfaces here.
|
||||
*
|
||||
* <p>These are not channel properties but permissions, so they are read with
|
||||
* {@code channelpermlist} and written with {@code channeladdperm}. A power left at zero is
|
||||
* not a permission the channel carries: clearing a spinner back to zero removes the
|
||||
* permission again so the channel inherits it, which is how the official client behaves.
|
||||
*/
|
||||
final class ChannelPermissionsPanel extends JPanel {
|
||||
|
||||
/** The regular powers, in the order the official dialog lists them. */
|
||||
private static final String[][] REGULAR = {
|
||||
{"i_channel_needed_join_power", "Join:"},
|
||||
{"i_channel_needed_subscribe_power", "Subscribe:"},
|
||||
{"i_channel_needed_description_view_power", "Desc. View:"},
|
||||
{"i_channel_needed_modify_power", "Modify:"},
|
||||
{"i_channel_needed_delete_power", "Delete:"}};
|
||||
|
||||
private static final String[][] FILE_TRANSFER = {
|
||||
{"i_ft_needed_file_browse_power", "Browse:"},
|
||||
{"i_ft_needed_file_upload_power", "Upload:"},
|
||||
{"i_ft_needed_file_download_power", "Download:"},
|
||||
{"i_ft_needed_file_rename_power", "Rename:"},
|
||||
{"i_ft_needed_directory_create_power", "Dir. Create:"}};
|
||||
|
||||
private final Map<String, JSpinner> spinners = new LinkedHashMap<>();
|
||||
/** The values the server reported, to tell an edited power from an untouched one. */
|
||||
private final Map<String, Integer> original = new LinkedHashMap<>();
|
||||
private final JLabel status = new JLabel(" ");
|
||||
|
||||
ChannelPermissionsPanel() {
|
||||
setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
|
||||
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
|
||||
|
||||
JPanel groups = new JPanel();
|
||||
groups.setLayout(new BoxLayout(groups, BoxLayout.X_AXIS));
|
||||
groups.add(group("Regular Needed Powers", REGULAR));
|
||||
groups.add(Box.createHorizontalStrut(8));
|
||||
groups.add(group("File Transfer Needed Powers", FILE_TRANSFER));
|
||||
groups.setAlignmentX(0f);
|
||||
add(groups);
|
||||
|
||||
status.setAlignmentX(0f);
|
||||
status.setBorder(BorderFactory.createEmptyBorder(6, 2, 0, 2));
|
||||
add(status);
|
||||
add(Box.createVerticalGlue());
|
||||
setEditable(false);
|
||||
}
|
||||
|
||||
/** Fills the spinners from a {@code channelpermlist} result and enables editing. */
|
||||
void read(Map<String, Integer> permissions) {
|
||||
original.clear();
|
||||
for (Map.Entry<String, JSpinner> entry : spinners.entrySet()) {
|
||||
int value = permissions.getOrDefault(entry.getKey(), 0);
|
||||
original.put(entry.getKey(), value);
|
||||
entry.getValue().setValue(value);
|
||||
}
|
||||
setEditable(true);
|
||||
setStatus(" ", false);
|
||||
}
|
||||
|
||||
/** Greys the tab out with a reason, e.g. when the server refuses to show the permissions. */
|
||||
void showUnavailable(String message) {
|
||||
setEditable(false);
|
||||
setStatus(message, true);
|
||||
}
|
||||
|
||||
/** The permissions whose power was changed to a non-zero value. */
|
||||
Map<String, Integer> changed() {
|
||||
Map<String, Integer> changed = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, JSpinner> entry : spinners.entrySet()) {
|
||||
int value = (Integer) entry.getValue().getValue();
|
||||
if (value != 0 && value != original.getOrDefault(entry.getKey(), 0)) {
|
||||
changed.put(entry.getKey(), value);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** The permissions cleared back to zero, which are removed from the channel. */
|
||||
List<String> cleared() {
|
||||
List<String> cleared = new ArrayList<>();
|
||||
for (Map.Entry<String, JSpinner> entry : spinners.entrySet()) {
|
||||
int value = (Integer) entry.getValue().getValue();
|
||||
if (value == 0 && original.getOrDefault(entry.getKey(), 0) != 0) cleared.add(entry.getKey());
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
|
||||
void setStatus(String message, boolean error) {
|
||||
status.setText(message == null || message.isEmpty() ? " " : message);
|
||||
status.setForeground(error ? Color.RED.darker() : Theme.chatSystem());
|
||||
}
|
||||
|
||||
private void setEditable(boolean editable) {
|
||||
for (JSpinner spinner : spinners.values()) spinner.setEnabled(editable);
|
||||
}
|
||||
|
||||
private JPanel group(String title, String[][] permissions) {
|
||||
JPanel panel = new JPanel(new GridBagLayout());
|
||||
panel.setBorder(BorderFactory.createTitledBorder(title));
|
||||
GridBagConstraints c = new GridBagConstraints();
|
||||
c.insets = new Insets(3, 4, 3, 4);
|
||||
c.anchor = GridBagConstraints.WEST;
|
||||
c.fill = GridBagConstraints.HORIZONTAL;
|
||||
int row = 0;
|
||||
for (String[] permission : permissions) {
|
||||
JSpinner spinner = Spinners.compact(new JSpinner(new SpinnerNumberModel(0, 0, 9999, 1)));
|
||||
spinners.put(permission[0], spinner);
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.weightx = 0;
|
||||
panel.add(new JLabel(permission[1]), c);
|
||||
c.gridx = 1;
|
||||
c.weightx = 1;
|
||||
c.fill = GridBagConstraints.NONE;
|
||||
panel.add(spinner, c);
|
||||
c.fill = GridBagConstraints.HORIZONTAL;
|
||||
row++;
|
||||
}
|
||||
// Keeps the rows at the top of the group instead of centred in whatever height
|
||||
// the tab happens to give it.
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.weighty = 1;
|
||||
panel.add(Box.createGlue(), c);
|
||||
return panel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ChannelSettings;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.ButtonGroup;
|
||||
import javax.swing.DefaultComboBoxModel;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JRadioButton;
|
||||
import javax.swing.JSeparator;
|
||||
import javax.swing.JSpinner;
|
||||
import javax.swing.SpinnerNumberModel;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FlowLayout;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.Insets;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* The channel editor's "Standard" tab: how long the channel lives, whether it is the
|
||||
* server's default one, where it sorts among its siblings, and its moderation setting.
|
||||
*/
|
||||
final class ChannelStandardPanel extends JPanel implements ChannelDialog.Tab {
|
||||
|
||||
private final JRadioButton temporary = new JRadioButton("Temporary");
|
||||
private final JRadioButton semiPermanent = new JRadioButton("Semi-Permanent");
|
||||
private final JRadioButton permanent = new JRadioButton("Permanent");
|
||||
private final JCheckBox defaultChannel = new JCheckBox("Default Channel");
|
||||
private final JComboBox<SortEntry> sortAfter = new JComboBox<>();
|
||||
private final JSpinner talkPower = new JSpinner(new SpinnerNumberModel(0, 0, 9999, 1));
|
||||
|
||||
private Consumer<ChannelSettings.Type> typeListener;
|
||||
|
||||
/**
|
||||
* @param siblings the channels this one shares a parent with, in tree order and
|
||||
* excluding the channel being edited
|
||||
*/
|
||||
ChannelStandardPanel(List<ChannelNode> siblings) {
|
||||
super(new BorderLayout(8, 8));
|
||||
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
|
||||
|
||||
temporary.setToolTipText("Channel will be deleted when the last user left.");
|
||||
semiPermanent.setToolTipText("Channel exists until server is restarted.");
|
||||
permanent.setToolTipText("Channel will exist until manually deleted.");
|
||||
ButtonGroup types = new ButtonGroup();
|
||||
types.add(temporary);
|
||||
types.add(semiPermanent);
|
||||
types.add(permanent);
|
||||
for (JRadioButton radio : new JRadioButton[]{temporary, semiPermanent, permanent}) {
|
||||
radio.addActionListener(a -> fireTypeChanged());
|
||||
}
|
||||
|
||||
defaultChannel.setToolTipText("<html>The default channel is the place where new clients join on login."
|
||||
+ "<br>There can be only one default channel for the whole server.</html>");
|
||||
|
||||
DefaultComboBoxModel<SortEntry> order = new DefaultComboBoxModel<>();
|
||||
order.addElement(new SortEntry(0, "(first)"));
|
||||
for (ChannelNode sibling : siblings) order.addElement(new SortEntry(sibling.id, sibling.name));
|
||||
sortAfter.setModel(order);
|
||||
sortAfter.setToolTipText("Channel will be sorted below this channel.");
|
||||
sortAfter.setPreferredSize(new Dimension(200, sortAfter.getPreferredSize().height));
|
||||
Spinners.compact(talkPower);
|
||||
|
||||
add(typeBox(), BorderLayout.WEST);
|
||||
add(rightColumn(), BorderLayout.CENTER);
|
||||
}
|
||||
|
||||
/** Notified whenever the channel type changes, so the delete delay can follow it. */
|
||||
void onTypeChanged(Consumer<ChannelSettings.Type> listener) {
|
||||
this.typeListener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void read(ChannelSettings settings) {
|
||||
switch (settings.type) {
|
||||
case TEMPORARY:
|
||||
temporary.setSelected(true);
|
||||
break;
|
||||
case SEMI_PERMANENT:
|
||||
semiPermanent.setSelected(true);
|
||||
break;
|
||||
default:
|
||||
permanent.setSelected(true);
|
||||
break;
|
||||
}
|
||||
defaultChannel.setSelected(settings.defaultChannel);
|
||||
// The default channel cannot simply stop being one; another has to take over.
|
||||
defaultChannel.setEnabled(!settings.defaultChannel);
|
||||
select(settings.order);
|
||||
talkPower.setValue(settings.neededTalkPower);
|
||||
fireTypeChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(ChannelSettings settings) {
|
||||
settings.type = selectedType();
|
||||
settings.defaultChannel = defaultChannel.isSelected();
|
||||
SortEntry entry = (SortEntry) sortAfter.getSelectedItem();
|
||||
settings.order = entry == null ? 0 : entry.channelId;
|
||||
settings.neededTalkPower = (Integer) talkPower.getValue();
|
||||
}
|
||||
|
||||
private ChannelSettings.Type selectedType() {
|
||||
if (temporary.isSelected()) return ChannelSettings.Type.TEMPORARY;
|
||||
if (semiPermanent.isSelected()) return ChannelSettings.Type.SEMI_PERMANENT;
|
||||
return ChannelSettings.Type.PERMANENT;
|
||||
}
|
||||
|
||||
private void fireTypeChanged() {
|
||||
if (typeListener != null) typeListener.accept(selectedType());
|
||||
}
|
||||
|
||||
private void select(int channelId) {
|
||||
for (int i = 0; i < sortAfter.getItemCount(); i++) {
|
||||
if (sortAfter.getItemAt(i).channelId == channelId) {
|
||||
sortAfter.setSelectedIndex(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
sortAfter.setSelectedIndex(0);
|
||||
}
|
||||
|
||||
private JPanel typeBox() {
|
||||
JPanel box = new JPanel();
|
||||
box.setLayout(new BoxLayout(box, BoxLayout.Y_AXIS));
|
||||
box.setBorder(BorderFactory.createTitledBorder("Channel Type"));
|
||||
for (JRadioButton radio : new JRadioButton[]{temporary, semiPermanent, permanent}) {
|
||||
radio.setAlignmentX(0f);
|
||||
box.add(radio);
|
||||
}
|
||||
JSeparator line = new JSeparator();
|
||||
line.setAlignmentX(0f);
|
||||
line.setMaximumSize(new Dimension(Integer.MAX_VALUE, 8));
|
||||
box.add(Box.createVerticalStrut(4));
|
||||
box.add(line);
|
||||
box.add(Box.createVerticalStrut(4));
|
||||
defaultChannel.setAlignmentX(0f);
|
||||
box.add(defaultChannel);
|
||||
box.add(Box.createVerticalGlue());
|
||||
return box;
|
||||
}
|
||||
|
||||
private JPanel rightColumn() {
|
||||
JPanel sort = new JPanel(new BorderLayout());
|
||||
sort.setBorder(BorderFactory.createTitledBorder("Sort This Channel After:"));
|
||||
sort.add(sortAfter, BorderLayout.CENTER);
|
||||
|
||||
JPanel moderation = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2));
|
||||
moderation.setBorder(BorderFactory.createTitledBorder("Moderation"));
|
||||
JLabel label = new JLabel("Needed Talk Power:");
|
||||
label.setToolTipText("Talk Power required to speak in this channel.");
|
||||
moderation.add(label);
|
||||
moderation.add(talkPower);
|
||||
|
||||
// Both groups keep their natural height; the filler below soaks up the rest, so
|
||||
// they sit at the top instead of stretching over the whole tab.
|
||||
JPanel column = new JPanel(new GridBagLayout());
|
||||
GridBagConstraints c = new GridBagConstraints();
|
||||
c.gridx = 0;
|
||||
c.fill = GridBagConstraints.HORIZONTAL;
|
||||
c.weightx = 1;
|
||||
c.insets = new Insets(0, 0, 6, 0);
|
||||
column.add(sort, c);
|
||||
column.add(moderation, c);
|
||||
c.weighty = 1;
|
||||
c.fill = GridBagConstraints.BOTH;
|
||||
column.add(Box.createGlue(), c);
|
||||
return column;
|
||||
}
|
||||
|
||||
/** One entry of the "sort after" list: a sibling channel, or the top of the list. */
|
||||
private static final class SortEntry {
|
||||
final int channelId;
|
||||
final String label;
|
||||
|
||||
SortEntry(int channelId, String label) {
|
||||
this.channelId = channelId;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.ts3client.text.BBCode;
|
||||
import com.ts3client.text.TsLink;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Icon;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JEditorPane;
|
||||
import javax.swing.JLabel;
|
||||
@@ -15,8 +16,6 @@ import javax.swing.SwingUtilities;
|
||||
import javax.swing.event.HyperlinkEvent;
|
||||
import javax.swing.text.BadLocationException;
|
||||
import javax.swing.text.html.HTMLDocument;
|
||||
import javax.swing.text.html.HTMLEditorKit;
|
||||
import javax.swing.text.html.StyleSheet;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.Cursor;
|
||||
@@ -25,6 +24,7 @@ import java.awt.FlowLayout;
|
||||
import java.awt.Point;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.awt.event.MouseWheelEvent;
|
||||
import java.io.IOException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
@@ -62,6 +62,10 @@ public final class ChatPanel extends JPanel {
|
||||
private final Tab channelTab = new Tab(Target.CHANNEL, 0, "Channel");
|
||||
/** Private chats keyed by the peer's client id. */
|
||||
private final Map<Integer, Tab> privateTabs = new LinkedHashMap<>();
|
||||
/** Static-content tabs (e.g. a moved-out description), keyed by caller-chosen id. */
|
||||
private final Map<String, Tab> noteTabs = new LinkedHashMap<>();
|
||||
|
||||
private final TabDragReorder dragReorder = new TabDragReorder(tabs, this::moveTab);
|
||||
|
||||
private SendHandler sendHandler;
|
||||
private LinkHandler linkHandler;
|
||||
@@ -69,7 +73,8 @@ public final class ChatPanel extends JPanel {
|
||||
public ChatPanel() {
|
||||
super(new BorderLayout());
|
||||
|
||||
tabs.setFont(Theme.UI_FONT);
|
||||
tabs.setFont(Theme.uiFont());
|
||||
tabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT);
|
||||
addTab(serverTab, false);
|
||||
addTab(channelTab, false);
|
||||
tabs.setSelectedIndex(0);
|
||||
@@ -77,6 +82,8 @@ public final class ChatPanel extends JPanel {
|
||||
Tab t = selectedTab();
|
||||
if (t != null) t.setUnread(false);
|
||||
});
|
||||
tabs.addMouseWheelListener(this::onWheel);
|
||||
dragReorder.attach(tabs);
|
||||
add(tabs, BorderLayout.CENTER);
|
||||
|
||||
JPanel bottom = new JPanel(new BorderLayout(4, 0));
|
||||
@@ -130,6 +137,40 @@ public final class ChatPanel extends JPanel {
|
||||
});
|
||||
}
|
||||
|
||||
/** Drops every static-content tab (moved-out descriptions), e.g. after disconnecting. */
|
||||
public void closeNoteTabs() {
|
||||
edt(() -> {
|
||||
for (Tab t : noteTabs.values()) tabs.remove(t.scroll);
|
||||
noteTabs.clear();
|
||||
});
|
||||
}
|
||||
|
||||
/** Closes a single static-content tab by its key, if open; does not run its {@code onClose}. */
|
||||
public void closeNoteTab(String key) {
|
||||
edt(() -> {
|
||||
Tab tab = noteTabs.remove(key);
|
||||
if (tab != null) tabs.remove(tab.scroll);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens (or replaces the content of, and focuses) a static-content tab, e.g. a
|
||||
* channel/client description moved out of the info panel. {@code onClose} runs
|
||||
* when the tab is closed, so the caller can un-hide the description again.
|
||||
*/
|
||||
public void openDescriptionTab(String key, Icon icon, String title, String htmlBody, Runnable onClose) {
|
||||
edt(() -> {
|
||||
Tab tab = noteTabs.get(key);
|
||||
if (tab == null) {
|
||||
tab = new Tab(key, icon, title, onClose);
|
||||
noteTabs.put(key, tab);
|
||||
addTab(tab, true);
|
||||
}
|
||||
tab.setStaticContent(htmlBody);
|
||||
tabs.setSelectedComponent(tab.scroll);
|
||||
});
|
||||
}
|
||||
|
||||
private void fireSend() {
|
||||
String text = input.getText().trim();
|
||||
if (text.isEmpty() || sendHandler == null) return;
|
||||
@@ -143,10 +184,20 @@ public final class ChatPanel extends JPanel {
|
||||
|
||||
/** System notices always land in the server tab. */
|
||||
public void appendSystem(String text) {
|
||||
edt(() -> serverTab.appendLine("<span style=\"color:#8A8A8A\">" + stamp()
|
||||
edt(() -> serverTab.appendLine("<span class=\"muted\">" + stamp()
|
||||
+ BBCode.escape(text) + "</span>"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side events (client joins/leaves/moves, group changes, channel edits, …).
|
||||
* These lines name clients and channels with TeamSpeak's own links, which stay
|
||||
* clickable here — everything else in them is literal text.
|
||||
*/
|
||||
public void appendServerLog(String text) {
|
||||
edt(() -> serverTab.appendLine("<span class=\"event\">" + stamp()
|
||||
+ BBCode.linksToHtml(text) + "</span>"));
|
||||
}
|
||||
|
||||
public void appendServerMessage(int fromId, String from, String text) {
|
||||
edt(() -> serverTab.appendMessage(fromId, from, text));
|
||||
}
|
||||
@@ -180,10 +231,40 @@ public final class ChatPanel extends JPanel {
|
||||
}
|
||||
|
||||
private void closeTab(Tab tab) {
|
||||
if (tab.noteKey != null) {
|
||||
noteTabs.remove(tab.noteKey);
|
||||
if (tab.onClose != null) tab.onClose.run();
|
||||
} else {
|
||||
privateTabs.remove(tab.clientId);
|
||||
}
|
||||
tabs.remove(tab.scroll);
|
||||
}
|
||||
|
||||
/** Drags a tab from one position to another, keeping the current selection on screen. */
|
||||
private void moveTab(int from, int to) {
|
||||
if (from < 0 || to < 0 || from >= tabs.getTabCount() || to >= tabs.getTabCount() || from == to) return;
|
||||
Component content = tabs.getComponentAt(from);
|
||||
String title = tabs.getTitleAt(from);
|
||||
Icon icon = tabs.getIconAt(from);
|
||||
String tip = tabs.getToolTipTextAt(from);
|
||||
Component header = tabs.getTabComponentAt(from);
|
||||
boolean wasSelected = tabs.getSelectedIndex() == from;
|
||||
tabs.removeTabAt(from);
|
||||
tabs.insertTab(title, icon, content, tip, to);
|
||||
tabs.setTabComponentAt(to, header);
|
||||
if (wasSelected) tabs.setSelectedIndex(to);
|
||||
}
|
||||
|
||||
private void onWheel(MouseWheelEvent e) {
|
||||
Component content = tabs.getSelectedComponent();
|
||||
if (content != null && content.getBounds().contains(e.getPoint())) return;
|
||||
int steps = e.getWheelRotation();
|
||||
if (steps == 0) return;
|
||||
int target = Math.max(0, Math.min(tabs.getTabCount() - 1, tabs.getSelectedIndex() + steps));
|
||||
if (target != tabs.getSelectedIndex()) tabs.setSelectedIndex(target);
|
||||
e.consume();
|
||||
}
|
||||
|
||||
private Tab selectedTab() {
|
||||
Component c = tabs.getSelectedComponent();
|
||||
if (c == serverTab.scroll) return serverTab;
|
||||
@@ -205,40 +286,39 @@ public final class ChatPanel extends JPanel {
|
||||
final Target target;
|
||||
final int clientId;
|
||||
final String title;
|
||||
final JEditorPane log = new JEditorPane();
|
||||
final JEditorPane log = HtmlStyles.pane("font-family:sans-serif; font-size:11px; margin:4px 6px;");
|
||||
final JScrollPane scroll;
|
||||
private final HTMLDocument doc;
|
||||
/** Set for a static-content tab (e.g. a moved-out description); null for a conversation. */
|
||||
final String noteKey;
|
||||
final Runnable onClose;
|
||||
private final Icon icon;
|
||||
private JLabel titleLabel;
|
||||
|
||||
Tab(Target target, int clientId, String title) {
|
||||
this(target, clientId, title, null, null, null);
|
||||
}
|
||||
|
||||
/** A static-content tab: no send target, closing it runs {@code onClose}. */
|
||||
Tab(String noteKey, Icon icon, String title, Runnable onClose) {
|
||||
this(null, 0, title, icon, noteKey, onClose);
|
||||
}
|
||||
|
||||
private Tab(Target target, int clientId, String title, Icon icon, String noteKey, Runnable onClose) {
|
||||
this.target = target;
|
||||
this.clientId = clientId;
|
||||
this.title = title;
|
||||
this.icon = icon;
|
||||
this.noteKey = noteKey;
|
||||
this.onClose = onClose;
|
||||
|
||||
HTMLEditorKit kit = new HTMLEditorKit();
|
||||
StyleSheet css = new StyleSheet();
|
||||
css.addStyleSheet(kit.getStyleSheet());
|
||||
css.addRule("body { font-family:sans-serif; font-size:11px; color:#202020; margin:4px 6px; }");
|
||||
css.addRule("a { color:" + hex(Theme.CHAT_NAME) + "; text-decoration:none; }");
|
||||
// Client references look exactly like a message author's name.
|
||||
css.addRule("a." + BBCode.IDENTITY_LINK_CLASS
|
||||
+ " { color:" + hex(Theme.CHAT_NAME) + "; font-weight:bold; text-decoration:none; }");
|
||||
// External links are underlined so they can't be mistaken for an identity.
|
||||
css.addRule("a." + BBCode.EXTERNAL_LINK_CLASS
|
||||
+ " { color:" + hex(Theme.LINK) + "; text-decoration:underline; }");
|
||||
kit.setStyleSheet(css);
|
||||
|
||||
log.setEditorKit(kit);
|
||||
log.setEditable(false);
|
||||
log.setBackground(Theme.CHAT_BG);
|
||||
log.setText("<html><body><div id=\"chatlog\"></div></body></html>");
|
||||
doc = (HTMLDocument) log.getDocument();
|
||||
log.addHyperlinkListener(e -> {
|
||||
if (e.getEventType() != HyperlinkEvent.EventType.ACTIVATED) return;
|
||||
onLink(e.getDescription());
|
||||
});
|
||||
|
||||
scroll = new JScrollPane(log);
|
||||
HtmlStyles.fillViewport(log);
|
||||
scroll.setBorder(BorderFactory.createEmptyBorder());
|
||||
}
|
||||
|
||||
@@ -259,13 +339,15 @@ public final class ChatPanel extends JPanel {
|
||||
Component header(boolean closable) {
|
||||
JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
|
||||
p.setOpaque(false);
|
||||
titleLabel = new JLabel(title);
|
||||
titleLabel.setFont(Theme.UI_FONT);
|
||||
titleLabel = new JLabel(title, icon, JLabel.LEADING);
|
||||
titleLabel.setFont(Theme.uiFont());
|
||||
p.add(titleLabel);
|
||||
dragReorder.attach(p);
|
||||
dragReorder.attach(titleLabel);
|
||||
if (closable) {
|
||||
JLabel close = new JLabel("×");
|
||||
close.setFont(Theme.UI_BOLD);
|
||||
close.setForeground(Theme.CHAT_SYSTEM);
|
||||
close.setFont(Theme.uiBold());
|
||||
close.setForeground(Theme.chatSystem());
|
||||
close.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
close.setToolTipText("Close chat");
|
||||
close.addMouseListener(new MouseAdapter() {
|
||||
@@ -289,8 +371,8 @@ public final class ChatPanel extends JPanel {
|
||||
|
||||
void setUnread(boolean unread) {
|
||||
if (titleLabel == null) return;
|
||||
titleLabel.setFont(unread ? Theme.UI_BOLD : Theme.UI_FONT);
|
||||
titleLabel.setForeground(unread ? Theme.ACCENT : null);
|
||||
titleLabel.setFont(unread ? Theme.uiBold() : Theme.uiFont());
|
||||
titleLabel.setForeground(unread ? Theme.accent() : null);
|
||||
}
|
||||
|
||||
void appendMessage(int fromId, String from, String text) {
|
||||
@@ -298,13 +380,20 @@ public final class ChatPanel extends JPanel {
|
||||
String sender = fromId > 0
|
||||
? "<a class=\"" + BBCode.IDENTITY_LINK_CLASS + "\" href=\""
|
||||
+ BBCode.escape(TsLink.clientHref(fromId, "", from)) + "\">" + name + "</a>"
|
||||
: "<b style=\"color:" + hex(Theme.CHAT_NAME) + "\">" + name + "</b>";
|
||||
appendLine("<span style=\"color:#8A8A8A\">" + stamp() + "</span>"
|
||||
: "<span class=\"name\">" + name + "</span>";
|
||||
appendLine("<span class=\"muted\">" + stamp() + "</span>"
|
||||
+ sender + ": " + BBCode.toHtml(text));
|
||||
}
|
||||
|
||||
/** Replaces the whole log with fixed content; used by static-content tabs. */
|
||||
void setStaticContent(String html) {
|
||||
log.setText("<html><body><div id=\"chatlog\">" + html + "</div></body></html>");
|
||||
log.setCaretPosition(0);
|
||||
}
|
||||
|
||||
void appendLine(String html) {
|
||||
try {
|
||||
HTMLDocument doc = (HTMLDocument) log.getDocument();
|
||||
doc.insertBeforeEnd(doc.getElement("chatlog"), "<div>" + html + "</div>");
|
||||
log.setCaretPosition(doc.getLength());
|
||||
} catch (BadLocationException | IOException ignored) {
|
||||
@@ -312,8 +401,4 @@ public final class ChatPanel extends JPanel {
|
||||
if (tabs.getSelectedComponent() != scroll) setUnread(true);
|
||||
}
|
||||
}
|
||||
|
||||
private static String hex(java.awt.Color c) {
|
||||
return String.format("#%06X", c.getRGB() & 0xFFFFFF);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ClientEntry;
|
||||
import com.ts3client.net.Group;
|
||||
import com.ts3client.net.ServerModel;
|
||||
|
||||
import javax.swing.JCheckBoxMenuItem;
|
||||
import javax.swing.JMenu;
|
||||
import javax.swing.JMenuItem;
|
||||
import javax.swing.JPopupMenu;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The context menu for a client, shared by the server tree and the clickable
|
||||
@@ -14,7 +20,13 @@ final class ClientMenu {
|
||||
private ClientMenu() {
|
||||
}
|
||||
|
||||
static JPopupMenu build(ClientEntry client, boolean self, ServerTreePanel.Actions actions) {
|
||||
/**
|
||||
* @param fromTree whether this menu was opened from the server tree itself, as
|
||||
* opposed to a client link elsewhere (chat log, etc.) — "Find
|
||||
* Client in Channel Tree" is redundant in the former case.
|
||||
*/
|
||||
static JPopupMenu build(ClientEntry client, boolean self, boolean fromTree, ServerModel model,
|
||||
GroupIcons groupIcons, ServerTreePanel.Actions actions) {
|
||||
JPopupMenu menu = new JPopupMenu();
|
||||
if (!self) {
|
||||
JMenuItem pm = new JMenuItem("Open text chat", Icons.of("PLAYER_CHAT"));
|
||||
@@ -44,15 +56,117 @@ final class ClientMenu {
|
||||
menu.add(me);
|
||||
}
|
||||
menu.addSeparator();
|
||||
menu.add(buildServerGroupMenu(client, model, groupIcons, actions));
|
||||
menu.add(buildChannelGroupMenu(client, model, groupIcons, actions));
|
||||
menu.addSeparator();
|
||||
if (!fromTree) {
|
||||
JMenuItem findInTree = new JMenuItem("Find Client in Channel Tree", Icons.of("CHANNEL_SWITCH"));
|
||||
findInTree.setEnabled(isVisible(client, model));
|
||||
findInTree.addActionListener(a -> actions.findClientInTree(client));
|
||||
menu.add(findInTree);
|
||||
}
|
||||
JMenuItem description = new JMenuItem("Change Description", Icons.of("EDIT"));
|
||||
description.addActionListener(a -> actions.changeClientDescription(client));
|
||||
menu.add(description);
|
||||
JMenuItem info = new JMenuItem("Connection Info", Icons.of("INFO"));
|
||||
info.addActionListener(a -> actions.showConnectionInfo(client));
|
||||
menu.add(info);
|
||||
if (!self) {
|
||||
menu.addSeparator();
|
||||
JMenuItem joinChannel = new JMenuItem("Join Channel of Client", Icons.of("CHANNEL_SWITCH"));
|
||||
joinChannel.setEnabled(canJoinChannelOf(client, model));
|
||||
joinChannel.addActionListener(a -> actions.joinChannel(client.channelId));
|
||||
menu.add(joinChannel);
|
||||
JMenuItem moveHere = new JMenuItem("Move Client to own Channel", Icons.of("MOVE_CLIENT_TO_OWN_CHANNEL"));
|
||||
moveHere.addActionListener(a -> actions.moveClientToOwnChannel(client));
|
||||
menu.add(moveHere);
|
||||
}
|
||||
return menu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the client's channel is actually rendered in the tree — it won't be if
|
||||
* we're not subscribed to it (e.g. it needs more subscribe power than we have).
|
||||
*/
|
||||
private static boolean isVisible(ClientEntry client, ServerModel model) {
|
||||
ChannelNode channel = model.getChannel(client.channelId);
|
||||
return channel != null && channel.subscribed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether we can expect a join to succeed: the channel must be visible to us, and
|
||||
* not already full. This can't account for a join-power permission requirement —
|
||||
* unlike group membership, TS3 doesn't expose a channel's join-power threshold as
|
||||
* a plain property, only as a permission resolved through channel/channel-group/
|
||||
* server-group inheritance, which isn't something a regular client can query.
|
||||
*/
|
||||
private static boolean canJoinChannelOf(ClientEntry client, ServerModel model) {
|
||||
ChannelNode channel = model.getChannel(client.channelId);
|
||||
if (channel == null || !channel.subscribed) return false;
|
||||
return channel.maxClients < 0 || channel.clients.size() < channel.maxClients;
|
||||
}
|
||||
|
||||
private static JMenu buildServerGroupMenu(ClientEntry client, ServerModel model, GroupIcons groupIcons,
|
||||
ServerTreePanel.Actions actions) {
|
||||
JMenu menu = new JMenu("Set Server Groups");
|
||||
menu.setIcon(Icons.of("PERMISSIONS_SERVER_GROUPS"));
|
||||
JMenuItem dialog = new JMenuItem("Server Groups Dialog...");
|
||||
dialog.addActionListener(a -> actions.showServerGroupsDialog(client));
|
||||
menu.add(dialog);
|
||||
menu.addSeparator();
|
||||
|
||||
int addPower = model.selfPermissionValue("i_group_needed_member_add_power");
|
||||
int removePower = model.selfPermissionValue("i_group_needed_member_remove_power");
|
||||
for (Group g : model.allServerGroups()) {
|
||||
boolean assigned = contains(client.serverGroupIds, g.id);
|
||||
if (!canAssign(g, assigned, addPower, removePower)) continue;
|
||||
JCheckBoxMenuItem item = new JCheckBoxMenuItem(g.name, groupIcons.iconOf(g));
|
||||
item.setSelected(assigned);
|
||||
item.addActionListener(a -> actions.setClientServerGroup(client, g, !assigned));
|
||||
menu.add(item);
|
||||
}
|
||||
return menu;
|
||||
}
|
||||
|
||||
private static JMenu buildChannelGroupMenu(ClientEntry client, ServerModel model, GroupIcons groupIcons,
|
||||
ServerTreePanel.Actions actions) {
|
||||
JMenu menu = new JMenu("Set Channel Group");
|
||||
menu.setIcon(Icons.of("PERMISSIONS_CHANNEL_GROUPS"));
|
||||
int addPower = model.selfPermissionValue("i_group_needed_member_add_power");
|
||||
int removePower = model.selfPermissionValue("i_group_needed_member_remove_power");
|
||||
int defaultGroupId = model.defaultChannelGroupId();
|
||||
for (Group g : model.allChannelGroups()) {
|
||||
if (g.id == defaultGroupId) continue;
|
||||
boolean assigned = client.channelGroupId == g.id;
|
||||
if (!canAssign(g, assigned, addPower, removePower)) continue;
|
||||
JCheckBoxMenuItem item = new JCheckBoxMenuItem(g.name, groupIcons.iconOf(g));
|
||||
item.setSelected(assigned);
|
||||
item.addActionListener(a -> actions.setClientChannelGroup(client, g));
|
||||
menu.add(item);
|
||||
}
|
||||
return menu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the local client has enough power to (un)assign this group: the add
|
||||
* power always gates the checkbox, and the remove power additionally gates
|
||||
* unassigning an already-held group. {@code -1} is TS3's "unlimited" sentinel on
|
||||
* either side: an unlimited local power always passes, and a group that needs
|
||||
* unlimited power can only be touched by a local client that has it.
|
||||
*/
|
||||
static boolean canAssign(Group g, boolean assigned, int addPower, int removePower) {
|
||||
return hasPower(g.neededMemberAddPower, addPower)
|
||||
&& (!assigned || hasPower(g.neededMemberRemovePower, removePower));
|
||||
}
|
||||
|
||||
private static boolean hasPower(int needed, int own) {
|
||||
if (own == -1) return true;
|
||||
if (needed == -1) return false;
|
||||
return own >= needed;
|
||||
}
|
||||
|
||||
private static boolean contains(int[] ids, int id) {
|
||||
for (int i : ids) if (i == id) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ public final class ConnectionInfoDialog extends JDialog {
|
||||
this.clientId = clientId;
|
||||
|
||||
JPanel content = new JPanel(new BorderLayout(0, 10));
|
||||
content.setBackground(Theme.WINDOW_BG);
|
||||
content.setBackground(Theme.windowBg());
|
||||
content.setBorder(BorderFactory.createEmptyBorder(12, 14, 12, 14));
|
||||
content.add(buildSummary(), BorderLayout.NORTH);
|
||||
content.add(buildTabs(), BorderLayout.CENTER);
|
||||
@@ -92,7 +92,7 @@ public final class ConnectionInfoDialog extends JDialog {
|
||||
|
||||
private JPanel buildSummary() {
|
||||
JPanel grid = new JPanel(new GridBagLayout());
|
||||
grid.setBackground(Theme.WINDOW_BG);
|
||||
grid.setBackground(Theme.windowBg());
|
||||
int row = 0;
|
||||
addRow(grid, row++, "Address", addressValue);
|
||||
addRow(grid, row++, "Client version", versionValue);
|
||||
@@ -106,8 +106,8 @@ public final class ConnectionInfoDialog extends JDialog {
|
||||
|
||||
private JTabbedPane buildTabs() {
|
||||
JTabbedPane tabs = new JTabbedPane();
|
||||
tabs.setFont(Theme.UI_FONT);
|
||||
tabs.setBackground(Theme.WINDOW_BG);
|
||||
tabs.setFont(Theme.uiFont());
|
||||
tabs.setBackground(Theme.windowBg());
|
||||
tabs.addTab("Total", totalTab);
|
||||
tabs.addTab("Speech", speechTab);
|
||||
tabs.addTab("Keep Alive", keepAliveTab);
|
||||
@@ -118,7 +118,7 @@ public final class ConnectionInfoDialog extends JDialog {
|
||||
private JPanel buildButtons() {
|
||||
JPanel bar = new JPanel();
|
||||
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
|
||||
bar.setBackground(Theme.WINDOW_BG);
|
||||
bar.setBackground(Theme.windowBg());
|
||||
bar.add(Box.createHorizontalGlue());
|
||||
JButton close = new JButton("Close");
|
||||
close.addActionListener(e -> dispose());
|
||||
@@ -128,8 +128,8 @@ public final class ConnectionInfoDialog extends JDialog {
|
||||
|
||||
private static JLabel value() {
|
||||
JLabel l = new JLabel("—");
|
||||
l.setFont(Theme.UI_FONT);
|
||||
l.setForeground(Theme.TREE_TEXT);
|
||||
l.setFont(Theme.uiFont());
|
||||
l.setForeground(Theme.treeText());
|
||||
return l;
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ public final class ConnectionInfoDialog extends JDialog {
|
||||
lc.anchor = GridBagConstraints.WEST;
|
||||
lc.insets = new Insets(2, 0, 2, 16);
|
||||
JLabel key = new JLabel(label);
|
||||
key.setFont(Theme.UI_FONT);
|
||||
key.setFont(Theme.uiFont());
|
||||
key.setForeground(new Color(0x5A6B7B));
|
||||
grid.add(key, lc);
|
||||
|
||||
@@ -255,7 +255,7 @@ public final class ConnectionInfoDialog extends JDialog {
|
||||
|
||||
KindTab() {
|
||||
super(new GridBagLayout());
|
||||
setBackground(Theme.WINDOW_BG);
|
||||
setBackground(Theme.windowBg());
|
||||
setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
|
||||
int row = 0;
|
||||
addRow(this, row++, "Packet loss", packetLoss);
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.text.BBCode;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JColorChooser;
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JEditorPane;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTextArea;
|
||||
import javax.swing.JToggleButton;
|
||||
import javax.swing.JToolBar;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.CardLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Font;
|
||||
import java.awt.Window;
|
||||
|
||||
/**
|
||||
* The description editor TeamSpeak tears off the channel dialog: the raw BBCode plus
|
||||
* the handful of formatting buttons it offers (bold, italic, underline, colour) and a
|
||||
* preview that renders the text the way the info panel will.
|
||||
*
|
||||
* <p>Formatting works on the selection, wrapping it in the tag pair; with nothing
|
||||
* selected the pair is inserted and the caret placed between the two halves, so typing
|
||||
* continues inside it.
|
||||
*/
|
||||
final class DescriptionEditorDialog extends JDialog {
|
||||
|
||||
private static final String EDIT_CARD = "edit";
|
||||
private static final String PREVIEW_CARD = "preview";
|
||||
|
||||
private final JTextArea area = new JTextArea();
|
||||
private final JEditorPane preview = new JEditorPane("text/html", "");
|
||||
private final CardLayout cards = new CardLayout();
|
||||
private final JPanel body = new JPanel(cards);
|
||||
private final JToggleButton previewButton = new JToggleButton("Preview");
|
||||
private boolean accepted;
|
||||
|
||||
DescriptionEditorDialog(Window owner, String description) {
|
||||
super(owner, "Channel Description", ModalityType.APPLICATION_MODAL);
|
||||
area.setText(description);
|
||||
area.setLineWrap(true);
|
||||
area.setWrapStyleWord(true);
|
||||
area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
|
||||
area.setCaretPosition(0);
|
||||
|
||||
preview.setEditable(false);
|
||||
preview.setBackground(Theme.chatBg());
|
||||
preview.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
|
||||
|
||||
body.add(new JScrollPane(area), EDIT_CARD);
|
||||
body.add(new JScrollPane(preview), PREVIEW_CARD);
|
||||
|
||||
JLabel hint = new JLabel("Press Button to \"Preview\" the changes in channel info.");
|
||||
hint.setForeground(Theme.chatSystem());
|
||||
hint.setBorder(BorderFactory.createEmptyBorder(4, 6, 0, 6));
|
||||
|
||||
getContentPane().setLayout(new BorderLayout());
|
||||
getContentPane().add(buildToolbar(), BorderLayout.NORTH);
|
||||
getContentPane().add(body, BorderLayout.CENTER);
|
||||
JPanel south = new JPanel(new BorderLayout());
|
||||
south.add(hint, BorderLayout.NORTH);
|
||||
south.add(buildButtons(), BorderLayout.SOUTH);
|
||||
getContentPane().add(south, BorderLayout.SOUTH);
|
||||
|
||||
Dialogs.closeOnEscape(this);
|
||||
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||
setSize(new Dimension(520, 380));
|
||||
setMinimumSize(new Dimension(360, 260));
|
||||
setLocationRelativeTo(owner);
|
||||
}
|
||||
|
||||
/** @return whether the user confirmed; {@link #getDescription()} then holds the new text */
|
||||
boolean isAccepted() {
|
||||
return accepted;
|
||||
}
|
||||
|
||||
String getDescription() {
|
||||
return area.getText();
|
||||
}
|
||||
|
||||
private JToolBar buildToolbar() {
|
||||
JToolBar bar = new JToolBar();
|
||||
bar.setFloatable(false);
|
||||
bar.add(tagButton("B", "Bold", Font.BOLD, "[b]", "[/b]"));
|
||||
bar.add(tagButton("I", "Italic", Font.ITALIC, "[i]", "[/i]"));
|
||||
JButton underline = tagButton("<html><u>U</u></html>", "Underline", Font.PLAIN, "[u]", "[/u]");
|
||||
bar.add(underline);
|
||||
JButton color = new JButton("Color");
|
||||
color.setToolTipText("Color");
|
||||
color.addActionListener(a -> chooseColor());
|
||||
bar.add(square(color));
|
||||
bar.add(Box.createHorizontalGlue());
|
||||
previewButton.setToolTipText("Show the description as the info panel renders it");
|
||||
previewButton.addActionListener(a -> showPreview(previewButton.isSelected()));
|
||||
previewButton.setMaximumSize(previewButton.getPreferredSize());
|
||||
bar.add(previewButton);
|
||||
return bar;
|
||||
}
|
||||
|
||||
private JPanel buildButtons() {
|
||||
JButton ok = new JButton("OK");
|
||||
JButton cancel = new JButton("Cancel");
|
||||
ok.addActionListener(a -> {
|
||||
accepted = true;
|
||||
dispose();
|
||||
});
|
||||
cancel.addActionListener(a -> dispose());
|
||||
JPanel panel = new JPanel(new BorderLayout());
|
||||
JPanel right = new JPanel();
|
||||
right.add(ok);
|
||||
right.add(cancel);
|
||||
panel.add(right, BorderLayout.EAST);
|
||||
getRootPane().setDefaultButton(ok);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private JButton tagButton(String text, String tip, int style, String open, String close) {
|
||||
JButton button = new JButton(text);
|
||||
button.setToolTipText(tip);
|
||||
button.setFont(button.getFont().deriveFont(style));
|
||||
button.addActionListener(a -> wrapSelection(open, close));
|
||||
return square(button);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins a toolbar button to its natural size: the tool bar lays its children out along a
|
||||
* box, which would otherwise let one of them soak up all the free width.
|
||||
*/
|
||||
private static JButton square(JButton button) {
|
||||
Dimension size = new Dimension(Math.max(28, button.getPreferredSize().width),
|
||||
button.getPreferredSize().height);
|
||||
button.setPreferredSize(size);
|
||||
button.setMaximumSize(size);
|
||||
return button;
|
||||
}
|
||||
|
||||
private void chooseColor() {
|
||||
Color chosen = JColorChooser.showDialog(this, "Color", Color.BLACK);
|
||||
if (chosen == null) return;
|
||||
wrapSelection(String.format("[color=#%02x%02x%02x]",
|
||||
chosen.getRed(), chosen.getGreen(), chosen.getBlue()), "[/color]");
|
||||
}
|
||||
|
||||
/** Wraps the selection (or the caret) in a BBCode tag pair and returns focus to the text. */
|
||||
private void wrapSelection(String open, String close) {
|
||||
if (previewButton.isSelected()) showPreview(false);
|
||||
int start = area.getSelectionStart();
|
||||
int end = area.getSelectionEnd();
|
||||
String selected = area.getSelectedText();
|
||||
area.replaceRange(open + (selected == null ? "" : selected) + close, start, end);
|
||||
area.setCaretPosition(start + open.length() + (selected == null ? 0 : selected.length()));
|
||||
area.requestFocusInWindow();
|
||||
}
|
||||
|
||||
private void showPreview(boolean on) {
|
||||
previewButton.setSelected(on);
|
||||
if (on) {
|
||||
preview.setText("<html><body style=\"font-family:sans-serif;font-size:9pt\">"
|
||||
+ BBCode.toHtml(area.getText()) + "</body></html>");
|
||||
preview.setCaretPosition(0);
|
||||
}
|
||||
cards.show(body, on ? PREVIEW_CARD : EDIT_CARD);
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,14 @@ import java.awt.Insets;
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* Options page for icon packs: which pack the user interface is drawn with, where
|
||||
* to look for more of them, and a viewer showing everything the pack contains.
|
||||
* Options page for how the client looks: the light or dark look-and-feel, and the icon
|
||||
* pack the user interface is drawn with — where to look for more of them, and a viewer
|
||||
* showing everything the active pack contains.
|
||||
*
|
||||
* <p>Picking a pack applies it right away so the change can be seen in the window
|
||||
* behind the dialog; cancelling puts the previous one back.
|
||||
* <p>Both apply right away so the change can be seen in the window behind the dialog;
|
||||
* cancelling puts the previous ones back.
|
||||
*/
|
||||
final class IconPackPanel extends JPanel {
|
||||
final class DesignPanel extends JPanel {
|
||||
|
||||
/** Size of the tiles in the icon viewer. */
|
||||
private static final int PREVIEW_SIZE = 32;
|
||||
@@ -44,18 +45,22 @@ final class IconPackPanel extends JPanel {
|
||||
private final Settings settings;
|
||||
private final String originalPackId;
|
||||
private final String originalPackDir;
|
||||
private final Settings.Appearance originalAppearance;
|
||||
|
||||
private final JComboBox<Settings.Appearance> appearanceCombo =
|
||||
new JComboBox<>(Settings.Appearance.values());
|
||||
private final JComboBox<Object> packCombo = new JComboBox<>();
|
||||
private final JLabel packInfo = new JLabel();
|
||||
private final JTextField packDirField;
|
||||
private final DefaultListModel<String> previewModel = new DefaultListModel<>();
|
||||
private final JList<String> preview = new JList<>(previewModel);
|
||||
|
||||
IconPackPanel(Settings settings) {
|
||||
DesignPanel(Settings settings) {
|
||||
super(new BorderLayout(0, 8));
|
||||
this.settings = settings;
|
||||
this.originalPackId = settings.iconPack;
|
||||
this.originalPackDir = settings.iconPackDir;
|
||||
this.originalAppearance = settings.appearance;
|
||||
this.packDirField = new JTextField(settings.iconPackDir, 18);
|
||||
|
||||
setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
|
||||
@@ -65,18 +70,23 @@ final class IconPackPanel extends JPanel {
|
||||
reloadPacks();
|
||||
}
|
||||
|
||||
/** Copies the chosen pack into the settings; the caller saves them. */
|
||||
/** Copies the chosen appearance and pack into the settings; the caller saves them. */
|
||||
void apply() {
|
||||
settings.appearance = selectedAppearance();
|
||||
settings.iconPackDir = packDirField.getText().trim();
|
||||
Object selected = packCombo.getSelectedItem();
|
||||
settings.iconPack = selected instanceof IconPack pack ? pack.id() : "";
|
||||
}
|
||||
|
||||
/** Puts back the pack the dialog started with, for a cancelled edit. */
|
||||
/** Puts back the appearance and pack the dialog started with, for a cancelled edit. */
|
||||
void revert() {
|
||||
settings.iconPack = originalPackId;
|
||||
settings.iconPackDir = originalPackDir;
|
||||
IconTheme.get().reload(settings);
|
||||
if (settings.appearance != originalAppearance) {
|
||||
settings.appearance = originalAppearance;
|
||||
LookAndFeelManager.switchTo(originalAppearance);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- layout ----
|
||||
@@ -100,7 +110,21 @@ final class IconPackPanel extends JPanel {
|
||||
}
|
||||
});
|
||||
|
||||
appearanceCombo.setToolTipText("Light or dark window colours");
|
||||
appearanceCombo.setSelectedItem(settings.appearance);
|
||||
appearanceCombo.setRenderer(new DefaultListCellRenderer() {
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
|
||||
boolean selected, boolean focus) {
|
||||
super.getListCellRendererComponent(list, value, index, selected, focus);
|
||||
setText(value == Settings.Appearance.DARK ? "Dark" : "Light");
|
||||
return this;
|
||||
}
|
||||
});
|
||||
appearanceCombo.addActionListener(e -> onAppearanceSelected());
|
||||
|
||||
int row = 0;
|
||||
addRow(p, c, row++, new JLabel("Theme:"), appearanceCombo);
|
||||
addRow(p, c, row++, new JLabel("Icon pack:"), packCombo);
|
||||
c.gridx = 1;
|
||||
c.gridy = row++;
|
||||
@@ -159,6 +183,18 @@ final class IconPackPanel extends JPanel {
|
||||
onPackSelected();
|
||||
}
|
||||
|
||||
private Settings.Appearance selectedAppearance() {
|
||||
Object selected = appearanceCombo.getSelectedItem();
|
||||
return selected instanceof Settings.Appearance a ? a : Settings.Appearance.LIGHT;
|
||||
}
|
||||
|
||||
private void onAppearanceSelected() {
|
||||
Settings.Appearance chosen = selectedAppearance();
|
||||
if (chosen == settings.appearance) return;
|
||||
settings.appearance = chosen;
|
||||
LookAndFeelManager.switchTo(chosen);
|
||||
}
|
||||
|
||||
private void onPackSelected() {
|
||||
Object selected = packCombo.getSelectedItem();
|
||||
IconPack pack = selected instanceof IconPack p ? p : null;
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.audio.VoiceInput;
|
||||
import com.ts3client.audio.VoiceOutput;
|
||||
import com.ts3client.audio.desktop.AudioDevices;
|
||||
import com.ts3client.config.Settings;
|
||||
|
||||
import javax.swing.Box;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JSlider;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.Insets;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Options dialog's "Playback / Capture" tab: device pickers, gain/volume, and the
|
||||
* noise-reduction pre-processing options. Gain and pre-processing changes are pushed
|
||||
* live via {@code applyLive}, which reaches both the connected microphone and the
|
||||
* dialog's own microphone test.
|
||||
*/
|
||||
final class DevicesPanel extends FormPanel {
|
||||
|
||||
private final JComboBox<AudioDevices.Device> inputCombo;
|
||||
private final JComboBox<AudioDevices.Device> outputCombo;
|
||||
private final JSlider inputGain;
|
||||
private final JSlider outputVol;
|
||||
private final JCheckBox denoiseCheck;
|
||||
private final JSlider denoiseLevel;
|
||||
private final JCheckBox typingCheck;
|
||||
private final JCheckBox agcCheck;
|
||||
|
||||
DevicesPanel(Settings settings, VoiceOutput livePlayback,
|
||||
Consumer<Consumer<VoiceInput>> applyLive,
|
||||
Runnable onInputDeviceChanged, Consumer<String> onOutputDeviceChanged) {
|
||||
GridBagConstraints c = gbc();
|
||||
|
||||
List<AudioDevices.Device> ins = AudioDevices.inputDevices();
|
||||
List<AudioDevices.Device> outs = AudioDevices.outputDevices();
|
||||
|
||||
inputCombo = new JComboBox<>(ins.toArray(new AudioDevices.Device[0]));
|
||||
outputCombo = new JComboBox<>(outs.toArray(new AudioDevices.Device[0]));
|
||||
selectOrDefault(inputCombo, settings.inputDevice);
|
||||
selectOrDefault(outputCombo, settings.outputDevice);
|
||||
|
||||
String deviceHint = "<html>Named devices are PipeWire's, and are routed through it "
|
||||
+ "(so per-application volume and rerouting keep working).<br>"
|
||||
+ "<b>ALSA:</b> entries talk to the sound card directly, taking it exclusively.</html>";
|
||||
inputCombo.setToolTipText(deviceHint);
|
||||
outputCombo.setToolTipText(deviceHint);
|
||||
limitWidth(inputCombo, FIELD_WIDTH);
|
||||
limitWidth(outputCombo, FIELD_WIDTH);
|
||||
|
||||
int row = 0;
|
||||
addRow(this, c, row++, new JLabel("Capture device (microphone):"), inputCombo);
|
||||
addRow(this, c, row++, new JLabel("Playback device (speakers):"), outputCombo);
|
||||
|
||||
inputGain = new JSlider(0, 200, (int) Math.round(settings.inputVolume * 100));
|
||||
outputVol = new JSlider(0, 200, (int) Math.round(settings.outputVolume * 100));
|
||||
limitWidth(inputGain, SLIDER_WIDTH);
|
||||
limitWidth(outputVol, SLIDER_WIDTH);
|
||||
addRow(this, c, row++, new JLabel("Microphone gain:"), inputGain);
|
||||
addRow(this, c, row++, new JLabel("Playback volume:"), outputVol);
|
||||
|
||||
outputVol.addChangeListener(e -> {
|
||||
if (livePlayback != null) livePlayback.setMasterVolume(outputVol.getValue() / 100.0);
|
||||
});
|
||||
inputGain.addChangeListener(e ->
|
||||
applyLive.accept(m -> m.setInputGain(inputGain.getValue() / 100.0)));
|
||||
inputCombo.addActionListener(e -> onInputDeviceChanged.run());
|
||||
outputCombo.addActionListener(e -> onOutputDeviceChanged.accept(comboValue(outputCombo)));
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
c.insets = new Insets(14, 4, 2, 4);
|
||||
add(new JLabel("Noise reduction"), c);
|
||||
c.insets = new Insets(4, 4, 4, 4);
|
||||
c.gridwidth = 1;
|
||||
|
||||
denoiseCheck = new JCheckBox("Remove background noise", settings.denoise);
|
||||
denoiseCheck.setToolTipText("Attempt to filter out background noises.");
|
||||
denoiseLevel = new JSlider(0, 100, (int) Math.round(settings.denoiserLevel * 100));
|
||||
limitWidth(denoiseLevel, SLIDER_WIDTH);
|
||||
denoiseLevel.setToolTipText("Higher = more aggressive noise removal.");
|
||||
typingCheck = new JCheckBox("Typing attenuation", settings.typingAttenuation);
|
||||
typingCheck.setToolTipText("<html><b>Typing attenuation</b> tries to detect and "
|
||||
+ "reduce the sounds made by typing.</html>");
|
||||
agcCheck = new JCheckBox("Automatic gain control (AGC)", settings.agc);
|
||||
agcCheck.setToolTipText("<html><b>Automatic gain control</b> normalises your "
|
||||
+ "microphone loudness to a target level, boosting quiet mics and taming "
|
||||
+ "loud ones.</html>");
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
add(denoiseCheck, c);
|
||||
c.gridwidth = 1;
|
||||
addRow(this, c, row++, new JLabel("Noise removal level:"), denoiseLevel);
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
add(typingCheck, c);
|
||||
c.gridy = row++;
|
||||
add(agcCheck, c);
|
||||
c.gridwidth = 1;
|
||||
|
||||
Runnable syncNoise = () -> {
|
||||
denoiseLevel.setEnabled(denoiseCheck.isSelected());
|
||||
applyLive.accept(m -> {
|
||||
m.setNoiseSuppression(denoiseCheck.isSelected());
|
||||
m.setDenoiserLevel(denoiseLevel.getValue() / 100.0);
|
||||
m.setTypingAttenuation(typingCheck.isSelected());
|
||||
m.setAgc(agcCheck.isSelected());
|
||||
});
|
||||
};
|
||||
denoiseCheck.addActionListener(e -> syncNoise.run());
|
||||
typingCheck.addActionListener(e -> syncNoise.run());
|
||||
agcCheck.addActionListener(e -> syncNoise.run());
|
||||
denoiseLevel.addChangeListener(e ->
|
||||
applyLive.accept(m -> m.setDenoiserLevel(denoiseLevel.getValue() / 100.0)));
|
||||
syncNoise.run();
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.weighty = 1;
|
||||
add(Box.createGlue(), c);
|
||||
}
|
||||
|
||||
/** Copies the form into {@code target}, without touching anything else. */
|
||||
void writeInto(Settings target) {
|
||||
target.inputDevice = comboValue(inputCombo);
|
||||
target.outputDevice = comboValue(outputCombo);
|
||||
target.inputVolume = inputGain.getValue() / 100.0;
|
||||
target.outputVolume = outputVol.getValue() / 100.0;
|
||||
target.denoise = denoiseCheck.isSelected();
|
||||
target.denoiserLevel = denoiseLevel.getValue() / 100.0;
|
||||
target.typingAttenuation = typingCheck.isSelected();
|
||||
target.agc = agcCheck.isSelected();
|
||||
}
|
||||
|
||||
private static void selectOrDefault(JComboBox<AudioDevices.Device> combo, String deviceId) {
|
||||
if (deviceId != null && !deviceId.isEmpty()) {
|
||||
for (int i = 0; i < combo.getItemCount(); i++) {
|
||||
if (deviceId.equals(combo.getItemAt(i).id())) {
|
||||
combo.setSelectedIndex(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
combo.setSelectedIndex(0);
|
||||
}
|
||||
|
||||
private static String comboValue(JComboBox<AudioDevices.Device> combo) {
|
||||
AudioDevices.Device d = (AudioDevices.Device) combo.getSelectedItem();
|
||||
return d == null ? "" : d.id();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ClientEntry;
|
||||
import com.ts3client.net.ServerModel;
|
||||
|
||||
import javax.swing.Icon;
|
||||
import javax.swing.JTree;
|
||||
import javax.swing.tree.DefaultMutableTreeNode;
|
||||
import javax.swing.tree.TreeModel;
|
||||
import javax.swing.tree.TreePath;
|
||||
import java.awt.BasicStroke;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Point;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.List;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/**
|
||||
* The {@link ServerTreePanel} tree, extended to draw where a drag-and-drop would
|
||||
* land: an insertion line between rows, or an outline around the row that will
|
||||
* receive the dragged node. Also keeps the server row permanently expanded and
|
||||
* paints the right-aligned group icon strip, both of which need to hook into
|
||||
* this component's own paint cycle.
|
||||
*/
|
||||
final class DropIndicatorTree extends JTree {
|
||||
|
||||
/** Gap kept between a row's label and the right-aligned icon strip. */
|
||||
private static final int BADGE_GAP = 8;
|
||||
/** Inset of the strip from the visible right edge. */
|
||||
private static final int BADGE_MARGIN = 4;
|
||||
|
||||
private final ServerModel model;
|
||||
private final GroupIcons groupIcons;
|
||||
/** Set while a drop would move a client into this channel row. */
|
||||
private TreePath highlight;
|
||||
/** The row the pointer is over, or -1 when it is over none. */
|
||||
private int hoverRow = -1;
|
||||
private Predicate<TreePath> pathEditable = path -> false;
|
||||
|
||||
DropIndicatorTree(TreeModel treeModel, ServerModel model, GroupIcons groupIcons) {
|
||||
super(treeModel);
|
||||
this.model = model;
|
||||
this.groupIcons = groupIcons;
|
||||
MouseAdapter hover = new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseMoved(MouseEvent e) {
|
||||
setHoverRow(rowAt(e.getY()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseDragged(MouseEvent e) {
|
||||
setHoverRow(-1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent e) {
|
||||
setHoverRow(-1);
|
||||
}
|
||||
};
|
||||
addMouseMotionListener(hover);
|
||||
addMouseListener(hover);
|
||||
// The selected row is filled across the full width below, before the rows
|
||||
// themselves are drawn, so this component must not clear its own background.
|
||||
setOpaque(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The row a point falls on, going by its vertical band alone: a row reaches
|
||||
* across the whole width, not just as far as its label.
|
||||
*/
|
||||
int rowAt(int y) {
|
||||
int row = getClosestRowForLocation(0, y);
|
||||
if (row < 0) return -1;
|
||||
Rectangle bounds = getRowBounds(row);
|
||||
return bounds != null && y >= bounds.y && y < bounds.y + bounds.height ? row : -1;
|
||||
}
|
||||
|
||||
/** The path a point falls on, by the same rule as {@link #rowAt(int)}. */
|
||||
TreePath pathAt(int y) {
|
||||
int row = rowAt(y);
|
||||
return row < 0 ? null : getPathForRow(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-reads the row the pointer is over. Scrolling moves the rows under a pointer
|
||||
* that never moved itself, so the enclosing scroll pane calls this on every scroll.
|
||||
*/
|
||||
void refreshHoverRow() {
|
||||
Point pointer = getMousePosition();
|
||||
setHoverRow(pointer == null ? -1 : rowAt(pointer.y));
|
||||
}
|
||||
|
||||
private void setHoverRow(int row) {
|
||||
if (row == hoverRow) return;
|
||||
repaintRow(hoverRow);
|
||||
hoverRow = row;
|
||||
repaintRow(row);
|
||||
}
|
||||
|
||||
private void repaintRow(int row) {
|
||||
if (row < 0) return;
|
||||
Rectangle bounds = getRowBounds(row);
|
||||
if (bounds != null) repaint(0, bounds.y, getWidth(), bounds.height);
|
||||
}
|
||||
|
||||
/** Which rows an inline editor may open on; nothing, until one is installed. */
|
||||
void setPathEditable(Predicate<TreePath> editable) {
|
||||
this.pathEditable = editable;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPathEditable(TreePath path) {
|
||||
return isEditable() && pathEditable.test(path);
|
||||
}
|
||||
|
||||
/** Keeps the server row permanently open; collapsing it would hide everything. */
|
||||
@Override
|
||||
public void setExpandedState(TreePath path, boolean state) {
|
||||
if (!state && path.getPathCount() == 1) return;
|
||||
super.setExpandedState(path, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Widens every repaint request to the full visible width. Swing only asks for
|
||||
* the row rectangle, which stops short of the right-aligned icon strip and would
|
||||
* leave it behind when a row's label changes width.
|
||||
*/
|
||||
@Override
|
||||
public void repaint(long tm, int x, int y, int width, int height) {
|
||||
Rectangle visible = getVisibleRect();
|
||||
super.repaint(tm, visible.x, y, visible.width, height);
|
||||
}
|
||||
|
||||
void highlightChannel(TreePath path) {
|
||||
if (path == highlight || (path != null && path.equals(highlight))) return;
|
||||
highlight = path;
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g) {
|
||||
Rectangle clip = g.getClipBounds();
|
||||
g.setColor(getBackground());
|
||||
g.fillRect(clip.x, clip.y, clip.width, clip.height);
|
||||
paintSelection(g);
|
||||
paintHover(g);
|
||||
super.paintComponent(g);
|
||||
paintBadges(g);
|
||||
JTree.DropLocation loc = getDropLocation();
|
||||
if (loc == null || loc.getPath() == null) return;
|
||||
|
||||
Graphics2D g2 = (Graphics2D) g.create();
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
g2.setColor(Theme.accent());
|
||||
if (highlight != null || loc.getChildIndex() < 0) {
|
||||
Rectangle r = getPathBounds(highlight != null ? highlight : loc.getPath());
|
||||
if (r != null) {
|
||||
g2.setStroke(new BasicStroke(2f));
|
||||
g2.drawRoundRect(r.x, r.y + 1, r.width - 1, r.height - 3, 4, 4);
|
||||
}
|
||||
} else {
|
||||
Rectangle line = insertLine(loc);
|
||||
if (line != null) {
|
||||
g2.fillRect(line.x, line.y - 1, line.width, 2);
|
||||
g2.fillOval(line.x - 3, line.y - 4, 7, 7);
|
||||
}
|
||||
}
|
||||
g2.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the selected row across the full width, in the colour the cell renderer
|
||||
* puts behind the label itself, so the selection covers the whole line rather
|
||||
* than stopping where the name ends.
|
||||
*/
|
||||
private void paintSelection(Graphics g) {
|
||||
int[] rows = getSelectionRows();
|
||||
if (rows == null) return;
|
||||
Rectangle visible = getVisibleRect();
|
||||
g.setColor(Theme.treeSelection());
|
||||
for (int row : rows) {
|
||||
Rectangle bounds = getRowBounds(row);
|
||||
if (bounds != null) g.fillRect(visible.x, bounds.y, visible.width, bounds.height);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Washes the row under the pointer across the full width it occupies — the same
|
||||
* area a click on it acts on. A selected row keeps its selection colour, and
|
||||
* spacers, which nothing can be done with, are left alone.
|
||||
*/
|
||||
private void paintHover(Graphics g) {
|
||||
if (hoverRow < 0 || getDropLocation() != null || isRowSelected(hoverRow)) return;
|
||||
Rectangle bounds = getRowBounds(hoverRow);
|
||||
TreePath path = getPathForRow(hoverRow);
|
||||
if (bounds == null || path == null || isSpacer(path)) return;
|
||||
|
||||
Rectangle visible = getVisibleRect();
|
||||
g.setColor(Theme.hover());
|
||||
g.fillRect(visible.x, bounds.y, visible.width, bounds.height);
|
||||
}
|
||||
|
||||
private boolean isSpacer(TreePath path) {
|
||||
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||
return obj instanceof ChannelNode && Spacers.isSpacer(((ChannelNode) obj).name);
|
||||
}
|
||||
|
||||
/** The 1px-tall strip where the insertion line goes, in tree coordinates. */
|
||||
private Rectangle insertLine(JTree.DropLocation loc) {
|
||||
DefaultMutableTreeNode parent = (DefaultMutableTreeNode) loc.getPath().getLastPathComponent();
|
||||
int index = loc.getChildIndex();
|
||||
if (index < parent.getChildCount()) {
|
||||
Rectangle r = getPathBounds(loc.getPath().pathByAddingChild(parent.getChildAt(index)));
|
||||
return r == null ? null : new Rectangle(r.x, r.y, getWidth() - r.x, 2);
|
||||
}
|
||||
if (parent.getChildCount() == 0) {
|
||||
Rectangle r = getPathBounds(loc.getPath());
|
||||
if (r == null) return null;
|
||||
int x = r.x + getRowHeight();
|
||||
return new Rectangle(x, r.y + r.height, getWidth() - x, 2);
|
||||
}
|
||||
// Past the last child: below that child's whole (expanded) subtree.
|
||||
TreePath lastChild = loc.getPath().pathByAddingChild(parent.getChildAt(parent.getChildCount() - 1));
|
||||
Rectangle head = getPathBounds(lastChild);
|
||||
Rectangle tail = getPathBounds(lastVisibleRow(lastChild));
|
||||
if (head == null || tail == null) return null;
|
||||
return new Rectangle(head.x, tail.y + tail.height, getWidth() - head.x, 2);
|
||||
}
|
||||
|
||||
private TreePath lastVisibleRow(TreePath path) {
|
||||
int row = getRowForPath(path);
|
||||
if (row < 0) return path;
|
||||
for (int i = row + 1; i < getRowCount(); i++) {
|
||||
if (!path.isDescendant(getPathForRow(i))) break;
|
||||
row = i;
|
||||
}
|
||||
return getPathForRow(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints the icons of every visible row — a client's group icons, a channel's
|
||||
* own icon — flush with the right edge of the viewport, the way TeamSpeak lines
|
||||
* them up. Drawing them separately from the cell renderer keeps the rows'
|
||||
* measured widths (and thus the selection highlight) tied to the label alone.
|
||||
*/
|
||||
private void paintBadges(Graphics g) {
|
||||
Rectangle visible = getVisibleRect();
|
||||
int right = visible.x + visible.width - BADGE_MARGIN;
|
||||
for (int row = 0; row < getRowCount(); row++) {
|
||||
Rectangle bounds = getRowBounds(row);
|
||||
if (bounds == null || bounds.y + bounds.height < visible.y) continue;
|
||||
if (bounds.y > visible.y + visible.height) break;
|
||||
|
||||
TreePath path = getPathForRow(row);
|
||||
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||
List<Icon> icons = badgesOf(obj);
|
||||
if (icons.isEmpty()) continue;
|
||||
|
||||
GroupIcons.Row strip = new GroupIcons.Row(icons);
|
||||
int x = Math.max(bounds.x + bounds.width + BADGE_GAP, right - strip.getIconWidth());
|
||||
strip.paintIcon(this, g, x, bounds.y + (bounds.height - strip.getIconHeight()) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
/** The icon strip a row shows on its right, empty when it has none (yet). */
|
||||
private List<Icon> badgesOf(Object node) {
|
||||
if (node instanceof ClientEntry) {
|
||||
ClientEntry cl = (ClientEntry) node;
|
||||
return groupIcons.iconsOf(
|
||||
model.serverGroupsOf(cl.serverGroupIds), model.channelGroup(cl.channelGroupId));
|
||||
}
|
||||
if (node instanceof ChannelNode) {
|
||||
ChannelNode ch = (ChannelNode) node;
|
||||
Icon icon = Spacers.isSpacer(ch.name) ? null : groupIcons.icon(ch.iconId);
|
||||
if (icon != null) return List.of(icon);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ public final class FileBrowserDialog extends JDialog {
|
||||
this.channelPassword = "";
|
||||
|
||||
JPanel content = new JPanel(new BorderLayout(0, 8));
|
||||
content.setBackground(Theme.WINDOW_BG);
|
||||
content.setBackground(Theme.windowBg());
|
||||
content.setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
|
||||
content.add(buildToolbar(), BorderLayout.NORTH);
|
||||
content.add(buildTable(), BorderLayout.CENTER);
|
||||
@@ -85,7 +85,7 @@ public final class FileBrowserDialog extends JDialog {
|
||||
private JPanel buildToolbar() {
|
||||
JPanel bar = new JPanel();
|
||||
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
|
||||
bar.setBackground(Theme.WINDOW_BG);
|
||||
bar.setBackground(Theme.windowBg());
|
||||
|
||||
upButton.addActionListener(e -> navigateUp());
|
||||
JButton refresh = new JButton("Refresh", Icons.of("FILE_REFRESH"));
|
||||
@@ -111,13 +111,13 @@ public final class FileBrowserDialog extends JDialog {
|
||||
bar.add(Box.createHorizontalStrut(6));
|
||||
bar.add(deleteButton);
|
||||
|
||||
pathLabel.setFont(Theme.UI_FONT);
|
||||
pathLabel.setForeground(Theme.TREE_TEXT);
|
||||
pathLabel.setFont(Theme.uiFont());
|
||||
pathLabel.setForeground(Theme.treeText());
|
||||
return bar;
|
||||
}
|
||||
|
||||
private JScrollPane buildTable() {
|
||||
table.setFont(Theme.UI_FONT);
|
||||
table.setFont(Theme.uiFont());
|
||||
table.setRowHeight(20);
|
||||
table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
|
||||
table.setFillsViewportHeight(true);
|
||||
@@ -137,17 +137,17 @@ public final class FileBrowserDialog extends JDialog {
|
||||
}
|
||||
});
|
||||
JScrollPane scroll = new JScrollPane(table);
|
||||
scroll.getViewport().setBackground(Theme.TREE_BG);
|
||||
scroll.getViewport().setBackground(Theme.treeBg());
|
||||
return scroll;
|
||||
}
|
||||
|
||||
private JScrollPane buildTransfers() {
|
||||
transfersPanel.setLayout(new BoxLayout(transfersPanel, BoxLayout.Y_AXIS));
|
||||
transfersPanel.setBackground(Theme.WINDOW_BG);
|
||||
transfersPanel.setBackground(Theme.windowBg());
|
||||
JScrollPane scroll = new JScrollPane(transfersPanel);
|
||||
scroll.setBorder(BorderFactory.createTitledBorder("Transfers"));
|
||||
scroll.setPreferredSize(new Dimension(10, 120));
|
||||
scroll.getViewport().setBackground(Theme.WINDOW_BG);
|
||||
scroll.getViewport().setBackground(Theme.windowBg());
|
||||
return scroll;
|
||||
}
|
||||
|
||||
@@ -326,10 +326,10 @@ public final class FileBrowserDialog extends JDialog {
|
||||
|
||||
TransferRow() {
|
||||
setLayout(new BorderLayout(8, 0));
|
||||
setBackground(Theme.WINDOW_BG);
|
||||
setBackground(Theme.windowBg());
|
||||
setBorder(BorderFactory.createEmptyBorder(3, 2, 3, 2));
|
||||
setMaximumSize(new Dimension(Integer.MAX_VALUE, 44));
|
||||
label.setFont(Theme.UI_FONT);
|
||||
label.setFont(Theme.uiFont());
|
||||
bar.setStringPainted(true);
|
||||
add(label, BorderLayout.NORTH);
|
||||
add(bar, BorderLayout.CENTER);
|
||||
|
||||
104
ts3-client/swing/src/main/java/com/ts3client/ui/FormPanel.java
Normal file
104
ts3-client/swing/src/main/java/com/ts3client/ui/FormPanel.java
Normal file
@@ -0,0 +1,104 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JSlider;
|
||||
import javax.swing.Scrollable;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.Insets;
|
||||
import java.awt.Rectangle;
|
||||
|
||||
/**
|
||||
* Base for a settings tab's field grid: follows the scroll pane's width instead of
|
||||
* demanding its own preferred one, so rows stay inside the dialog instead of scrolling
|
||||
* sideways. Also holds the small GridBagLayout helpers every such tab needs.
|
||||
*/
|
||||
class FormPanel extends JPanel implements Scrollable {
|
||||
|
||||
static final int FIELD_WIDTH = 240;
|
||||
static final int SLIDER_WIDTH = 200;
|
||||
static final int MIN_FIELD_WIDTH = 60;
|
||||
|
||||
FormPanel() {
|
||||
super(new GridBagLayout());
|
||||
setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredScrollableViewportSize() {
|
||||
return getPreferredSize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) {
|
||||
return 16;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) {
|
||||
return visible.height;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportWidth() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportHeight() {
|
||||
return false;
|
||||
}
|
||||
|
||||
static GridBagConstraints gbc() {
|
||||
GridBagConstraints c = new GridBagConstraints();
|
||||
c.insets = new Insets(4, 4, 4, 4);
|
||||
c.anchor = GridBagConstraints.WEST;
|
||||
c.fill = GridBagConstraints.HORIZONTAL;
|
||||
return c;
|
||||
}
|
||||
|
||||
static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, Component field) {
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.weightx = 0;
|
||||
c.gridwidth = 1;
|
||||
p.add(label, c);
|
||||
c.gridx = 1;
|
||||
c.weightx = 1;
|
||||
p.add(field, c);
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps a field's preferred width and lets it shrink: long device names and wide sliders
|
||||
* would otherwise force the form past the dialog's edge, where the scroll pane (which
|
||||
* never scrolls horizontally) simply clips them.
|
||||
*/
|
||||
static void limitWidth(JComponent comp, int preferredWidth) {
|
||||
int height = comp.getPreferredSize().height;
|
||||
comp.setPreferredSize(new Dimension(preferredWidth, height));
|
||||
comp.setMinimumSize(new Dimension(MIN_FIELD_WIDTH, height));
|
||||
}
|
||||
|
||||
static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel) {
|
||||
return sliderWithLabel(slider, valueLabel, 48);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairs a slider with its value readout. The readout gets a fixed width wide enough for
|
||||
* the longest value so the slider does not jump around as it is dragged.
|
||||
*/
|
||||
static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel, int labelWidth) {
|
||||
JPanel panel = new JPanel(new BorderLayout(6, 0));
|
||||
limitWidth(slider, SLIDER_WIDTH);
|
||||
panel.add(slider, BorderLayout.CENTER);
|
||||
valueLabel.setPreferredSize(new Dimension(labelWidth, valueLabel.getPreferredSize().height));
|
||||
panel.add(valueLabel, BorderLayout.EAST);
|
||||
return panel;
|
||||
}
|
||||
}
|
||||
108
ts3-client/swing/src/main/java/com/ts3client/ui/HtmlStyles.java
Normal file
108
ts3-client/swing/src/main/java/com/ts3client/ui/HtmlStyles.java
Normal file
@@ -0,0 +1,108 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.text.BBCode;
|
||||
|
||||
import javax.swing.JEditorPane;
|
||||
import javax.swing.JViewport;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.text.html.HTMLEditorKit;
|
||||
import javax.swing.text.html.StyleSheet;
|
||||
import java.awt.Color;
|
||||
|
||||
/**
|
||||
* The HTML panes showing TeamSpeak text — chat logs, client and channel information —
|
||||
* and the theme-dependent stylesheet they share.
|
||||
*
|
||||
* <p>Text that is already on screen keeps the colours its stylesheet was built with, so
|
||||
* a look-and-feel change re-installs the editor kit and parses the pane's content again
|
||||
* instead of only recolouring what is written afterwards.
|
||||
*/
|
||||
final class HtmlStyles {
|
||||
|
||||
private static final String BODY_STYLE = "HtmlStyles.bodyStyle";
|
||||
|
||||
private HtmlStyles() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bodyStyle extra CSS for {@code body}, e.g. font and margins; the text colour
|
||||
* is added by the theme
|
||||
* @return a read-only HTML pane whose stylesheet follows the current theme
|
||||
*/
|
||||
static JEditorPane pane(String bodyStyle) {
|
||||
JEditorPane pane = new JEditorPane() {
|
||||
@Override
|
||||
public void updateUI() {
|
||||
super.updateUI();
|
||||
restyle(this);
|
||||
}
|
||||
};
|
||||
pane.putClientProperty(BODY_STYLE, bodyStyle);
|
||||
pane.setEditable(false);
|
||||
installKit(pane, null, false);
|
||||
return pane;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints the scroll pane {@code pane} sits in with the pane's own background: text
|
||||
* only reaches as far as it goes, and the empty space below it would otherwise show
|
||||
* the window's colour instead of the log's.
|
||||
*/
|
||||
static void fillViewport(JEditorPane pane) {
|
||||
if (pane.getParent() instanceof JViewport viewport) {
|
||||
viewport.setBackground(pane.getBackground());
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-themes {@code pane}, keeping the text it shows. */
|
||||
private static void restyle(JEditorPane pane) {
|
||||
// Also runs from JEditorPane's constructor, before the pane is one of ours.
|
||||
if (pane.getClientProperty(BODY_STYLE) == null) return;
|
||||
|
||||
int length = pane.getDocument().getLength();
|
||||
String html = length == 0 ? null : pane.getText();
|
||||
// A chat log sits at its newest line; keep it there rather than jumping to the top.
|
||||
boolean atEnd = pane.getCaretPosition() >= length;
|
||||
// Not during the look-and-feel update that got us here: the pane is rebuilding its
|
||||
// views, and replacing the document underneath it would race with that.
|
||||
SwingUtilities.invokeLater(() -> installKit(pane, html, atEnd));
|
||||
}
|
||||
|
||||
private static void installKit(JEditorPane pane, String html, boolean atEnd) {
|
||||
HTMLEditorKit kit = new HTMLEditorKit();
|
||||
StyleSheet css = new StyleSheet();
|
||||
css.addStyleSheet(kit.getStyleSheet());
|
||||
|
||||
Object bodyStyle = pane.getClientProperty(BODY_STYLE);
|
||||
css.addRule("body { " + (bodyStyle == null ? "" : bodyStyle + " ")
|
||||
+ "color:" + hex(Theme.chatText()) + "; }");
|
||||
// Secondary text (timestamps, labels, placeholders), an author's name and the lines
|
||||
// about what happened on the server.
|
||||
css.addRule(".muted { color:" + hex(Theme.chatSystem()) + "; }");
|
||||
css.addRule(".name { color:" + hex(Theme.chatName()) + "; font-weight:bold; }");
|
||||
css.addRule(".event { color:" + hex(Theme.chatEvent()) + "; }");
|
||||
css.addRule("a { color:" + hex(Theme.chatName()) + "; text-decoration:none; }");
|
||||
// Client references look exactly like a message author's name.
|
||||
css.addRule("a." + BBCode.IDENTITY_LINK_CLASS
|
||||
+ " { color:" + hex(Theme.chatName()) + "; font-weight:bold; text-decoration:none; }");
|
||||
// Channel (and other TeamSpeak protocol) references stand out of a line the same
|
||||
// way a client's name does.
|
||||
css.addRule("a." + BBCode.TS_LINK_CLASS
|
||||
+ " { color:" + hex(Theme.chatName()) + "; font-weight:bold; text-decoration:none; }");
|
||||
// External links are underlined so they can't be mistaken for an identity.
|
||||
css.addRule("a." + BBCode.EXTERNAL_LINK_CLASS
|
||||
+ " { color:" + hex(Theme.link()) + "; text-decoration:underline; }");
|
||||
kit.setStyleSheet(css);
|
||||
|
||||
pane.setEditorKit(kit);
|
||||
fillViewport(pane);
|
||||
if (html != null) {
|
||||
pane.setText(html);
|
||||
pane.setCaretPosition(atEnd ? pane.getDocument().getLength() : 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static String hex(Color c) {
|
||||
return String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.IconRepository;
|
||||
import com.ts3client.net.TeamspeakConnection;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.DefaultListModel;
|
||||
import javax.swing.ImageIcon;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JFileChooser;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JList;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JSplitPane;
|
||||
import javax.swing.ListSelectionModel;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.Timer;
|
||||
import javax.swing.filechooser.FileNameExtensionFilter;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Window;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* TeamSpeak's icon viewer: the icons uploaded to this virtual server on one side (which
|
||||
* can be added to and removed from here) and the ones the icon pack ships on the other.
|
||||
*
|
||||
* <p>Server icons arrive through the {@link IconRepository}, which downloads them in the
|
||||
* background, so the lists are simply repainted until every icon has turned up.
|
||||
*/
|
||||
final class IconChooserDialog extends JDialog {
|
||||
|
||||
/** How often the lists are repainted while icons are still being downloaded. */
|
||||
private static final int REFRESH_MS = 300;
|
||||
/** Give up repainting once nothing has arrived for this long. */
|
||||
private static final int REFRESH_TIMEOUT_MS = 20_000;
|
||||
|
||||
private final TeamspeakConnection conn;
|
||||
private final GroupIcons groupIcons;
|
||||
|
||||
private final DefaultListModel<Long> remoteModel = new DefaultListModel<>();
|
||||
private final JList<Long> remoteList = new JList<>(remoteModel);
|
||||
private final JList<Long> localList = new JList<>(new DefaultListModel<>());
|
||||
private final JButton deleteButton = new JButton("Delete");
|
||||
private final JButton selectButton = new JButton("Select");
|
||||
private final JLabel status = new JLabel(" ");
|
||||
|
||||
private long chosenIconId;
|
||||
private boolean accepted;
|
||||
|
||||
IconChooserDialog(Window owner, TeamspeakConnection conn, GroupIcons groupIcons) {
|
||||
super(owner, "Icons", ModalityType.APPLICATION_MODAL);
|
||||
this.conn = conn;
|
||||
this.groupIcons = groupIcons;
|
||||
|
||||
configure(remoteList);
|
||||
configure(localList);
|
||||
DefaultListModel<Long> localModel = (DefaultListModel<Long>) localList.getModel();
|
||||
for (Long id : IconRepository.BUNDLED_IDS) localModel.addElement(id);
|
||||
|
||||
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT,
|
||||
group("Remote", remoteList, remoteButtons()),
|
||||
group("Local", localList, localFooter()));
|
||||
split.setResizeWeight(0.6);
|
||||
|
||||
status.setBorder(BorderFactory.createEmptyBorder(2, 8, 2, 8));
|
||||
status.setForeground(Theme.chatSystem());
|
||||
|
||||
getContentPane().setLayout(new BorderLayout());
|
||||
getContentPane().add(split, BorderLayout.CENTER);
|
||||
JPanel south = new JPanel(new BorderLayout());
|
||||
south.add(status, BorderLayout.NORTH);
|
||||
south.add(buttons(), BorderLayout.SOUTH);
|
||||
getContentPane().add(south, BorderLayout.SOUTH);
|
||||
|
||||
Dialogs.closeOnEscape(this);
|
||||
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||
setSize(new Dimension(520, 360));
|
||||
setLocationRelativeTo(owner);
|
||||
|
||||
loadRemoteIcons();
|
||||
startRefreshing();
|
||||
}
|
||||
|
||||
/** @return whether an icon was picked; {@link #getIconId()} then holds it */
|
||||
boolean isAccepted() {
|
||||
return accepted;
|
||||
}
|
||||
|
||||
long getIconId() {
|
||||
return chosenIconId;
|
||||
}
|
||||
|
||||
private void configure(JList<Long> list) {
|
||||
list.setLayoutOrientation(JList.HORIZONTAL_WRAP);
|
||||
list.setVisibleRowCount(-1);
|
||||
list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||
list.setFixedCellWidth(32);
|
||||
list.setFixedCellHeight(32);
|
||||
list.setCellRenderer(new IconCellRenderer());
|
||||
list.addListSelectionListener(e -> onSelectionChanged(list));
|
||||
list.addMouseListener(new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseClicked(MouseEvent e) {
|
||||
if (e.getClickCount() == 2 && list.getSelectedValue() != null) choose(list.getSelectedValue());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Keeps one list's selection exclusive: picking on one side clears the other. */
|
||||
private void onSelectionChanged(JList<Long> list) {
|
||||
JList<Long> other = list == remoteList ? localList : remoteList;
|
||||
if (list.getSelectedValue() != null) other.clearSelection();
|
||||
deleteButton.setEnabled(remoteList.getSelectedValue() != null);
|
||||
selectButton.setEnabled(selectedIcon() != 0);
|
||||
}
|
||||
|
||||
private long selectedIcon() {
|
||||
Long remote = remoteList.getSelectedValue();
|
||||
if (remote != null) return remote;
|
||||
Long local = localList.getSelectedValue();
|
||||
return local == null ? 0 : local;
|
||||
}
|
||||
|
||||
private JPanel group(String title, JList<Long> list, Component footer) {
|
||||
JPanel panel = new JPanel(new BorderLayout(0, 4));
|
||||
panel.setBorder(BorderFactory.createTitledBorder(title));
|
||||
panel.add(new JScrollPane(list), BorderLayout.CENTER);
|
||||
panel.add(footer, BorderLayout.SOUTH);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private Component remoteButtons() {
|
||||
JButton upload = new JButton("Upload");
|
||||
upload.addActionListener(a -> uploadIcon());
|
||||
deleteButton.setEnabled(false);
|
||||
deleteButton.addActionListener(a -> deleteSelectedIcon());
|
||||
JPanel row = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.LEFT, 4, 0));
|
||||
row.add(upload);
|
||||
row.add(deleteButton);
|
||||
return row;
|
||||
}
|
||||
|
||||
private Component localFooter() {
|
||||
com.ts3client.gfx.IconPack active = IconTheme.get().activePack();
|
||||
JLabel pack = new JLabel("Icon Pack: " + (active == null ? "none" : active.name()));
|
||||
pack.setBorder(BorderFactory.createEmptyBorder(0, 4, 0, 4));
|
||||
return pack;
|
||||
}
|
||||
|
||||
private JPanel buttons() {
|
||||
selectButton.setEnabled(false);
|
||||
selectButton.addActionListener(a -> choose(selectedIcon()));
|
||||
JButton cancel = new JButton("Cancel");
|
||||
cancel.addActionListener(a -> dispose());
|
||||
JPanel panel = new JPanel(new BorderLayout());
|
||||
JPanel right = new JPanel();
|
||||
right.add(selectButton);
|
||||
right.add(cancel);
|
||||
panel.add(right, BorderLayout.EAST);
|
||||
getRootPane().setDefaultButton(selectButton);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private void choose(long iconId) {
|
||||
if (iconId == 0) return;
|
||||
chosenIconId = iconId;
|
||||
accepted = true;
|
||||
dispose();
|
||||
}
|
||||
|
||||
private void loadRemoteIcons() {
|
||||
status.setText("Loading icons…");
|
||||
conn.requestServerIcons((ids, error) -> SwingUtilities.invokeLater(() -> {
|
||||
if (error != null) {
|
||||
status.setText("Could not list the server's icons: " + error);
|
||||
return;
|
||||
}
|
||||
status.setText(" ");
|
||||
remoteModel.clear();
|
||||
for (Long id : ids) remoteModel.addElement(id);
|
||||
}));
|
||||
}
|
||||
|
||||
private void uploadIcon() {
|
||||
JFileChooser chooser = new JFileChooser();
|
||||
chooser.setDialogTitle("Select an image to upload");
|
||||
chooser.setFileFilter(new FileNameExtensionFilter("Images", "png", "jpg", "jpeg", "gif", "bmp", "svg"));
|
||||
if (chooser.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) return;
|
||||
File file = chooser.getSelectedFile();
|
||||
status.setText("Uploading " + file.getName() + "…");
|
||||
conn.uploadIcon(file, (iconId, error) -> SwingUtilities.invokeLater(() -> {
|
||||
if (error != null) {
|
||||
status.setText(" ");
|
||||
JOptionPane.showMessageDialog(this, "Error uploading icon: " + error,
|
||||
"Error", JOptionPane.ERROR_MESSAGE);
|
||||
return;
|
||||
}
|
||||
loadRemoteIcons();
|
||||
startRefreshing();
|
||||
}));
|
||||
}
|
||||
|
||||
private void deleteSelectedIcon() {
|
||||
Long id = remoteList.getSelectedValue();
|
||||
if (id == null) return;
|
||||
int answer = JOptionPane.showConfirmDialog(this,
|
||||
"Permanently delete this icon from the server?", "Confirmation",
|
||||
JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE);
|
||||
if (answer != JOptionPane.YES_OPTION) return;
|
||||
conn.deleteIcon(id, error -> SwingUtilities.invokeLater(() -> {
|
||||
if (error != null) {
|
||||
JOptionPane.showMessageDialog(this, "Failed to delete remote icon file: " + error,
|
||||
"Error", JOptionPane.ERROR_MESSAGE);
|
||||
return;
|
||||
}
|
||||
remoteModel.removeElement(id);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Repaints while the repository is still fetching icons; it hands them over silently,
|
||||
* so the only way to show them as they land is to keep asking.
|
||||
*/
|
||||
private void startRefreshing() {
|
||||
Timer timer = new Timer(REFRESH_MS, null);
|
||||
long deadline = System.currentTimeMillis() + REFRESH_TIMEOUT_MS;
|
||||
timer.addActionListener(e -> {
|
||||
remoteList.repaint();
|
||||
localList.repaint();
|
||||
if (!isDisplayable() || System.currentTimeMillis() > deadline || allIconsLoaded()) timer.stop();
|
||||
});
|
||||
timer.start();
|
||||
}
|
||||
|
||||
private boolean allIconsLoaded() {
|
||||
for (int i = 0; i < remoteModel.size(); i++) {
|
||||
if (groupIcons.icon(remoteModel.get(i)) == null) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Draws one icon, blank until the repository has it. */
|
||||
private final class IconCellRenderer extends javax.swing.DefaultListCellRenderer {
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
|
||||
boolean selected, boolean focused) {
|
||||
super.getListCellRendererComponent(list, "", index, selected, focused);
|
||||
long id = value instanceof Long ? (Long) value : 0;
|
||||
ImageIcon icon = groupIcons.icon(id);
|
||||
setIcon(icon);
|
||||
setHorizontalAlignment(CENTER);
|
||||
setToolTipText("Icon " + id);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience for callers that only need the picked id. */
|
||||
static long pick(Window owner, TeamspeakConnection conn, GroupIcons groupIcons, long current) {
|
||||
IconChooserDialog dialog = new IconChooserDialog(owner, conn, groupIcons);
|
||||
dialog.setVisible(true);
|
||||
return dialog.isAccepted() ? dialog.getIconId() : current;
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ public final class Icons {
|
||||
}
|
||||
|
||||
public static ImageIcon clientIdle(int size) {
|
||||
return themed("PLAYER_OFF", size, g -> paintPerson(g, Theme.IDLE_CLIENT));
|
||||
return themed("PLAYER_OFF", size, g -> paintPerson(g, Theme.idleClient()));
|
||||
}
|
||||
|
||||
public static ImageIcon clientTalking() {
|
||||
@@ -129,7 +129,7 @@ public final class Icons {
|
||||
}
|
||||
|
||||
public static ImageIcon clientTalking(int size) {
|
||||
return themed("PLAYER_ON", size, g -> paintPerson(g, Theme.TALKING));
|
||||
return themed("PLAYER_ON", size, g -> paintPerson(g, Theme.talking()));
|
||||
}
|
||||
|
||||
public static ImageIcon clientAway() {
|
||||
@@ -137,7 +137,7 @@ public final class Icons {
|
||||
}
|
||||
|
||||
public static ImageIcon clientAway(int size) {
|
||||
return themed("AWAY", size, g -> paintPerson(g, Theme.AWAY));
|
||||
return themed("AWAY", size, g -> paintPerson(g, Theme.away()));
|
||||
}
|
||||
|
||||
/** A channel commander that is not talking. */
|
||||
@@ -151,7 +151,7 @@ public final class Icons {
|
||||
}
|
||||
|
||||
public static ImageIcon clientQuery() {
|
||||
return themed("SERVER_QUERY", g -> paintPerson(g, Theme.IDLE_CLIENT));
|
||||
return themed("SERVER_QUERY", g -> paintPerson(g, Theme.idleClient()));
|
||||
}
|
||||
|
||||
public static ImageIcon micMuted() {
|
||||
@@ -199,12 +199,12 @@ public final class Icons {
|
||||
|
||||
/** A channel commander that is not talking. */
|
||||
public static ImageIcon clientCommander(int size) {
|
||||
return themed("PLAYER_COMMANDER_OFF", size, g -> paintPerson(g, Theme.IDLE_CLIENT));
|
||||
return themed("PLAYER_COMMANDER_OFF", size, g -> paintPerson(g, Theme.idleClient()));
|
||||
}
|
||||
|
||||
/** A channel commander that is talking. */
|
||||
public static ImageIcon clientCommanderTalking(int size) {
|
||||
return themed("PLAYER_COMMANDER_ON", size, g -> paintPerson(g, Theme.TALKING));
|
||||
return themed("PLAYER_COMMANDER_ON", size, g -> paintPerson(g, Theme.talking()));
|
||||
}
|
||||
|
||||
// ---- toolbar / action icons ----
|
||||
@@ -243,9 +243,34 @@ public final class Icons {
|
||||
return themed("ACTIVATE_MICROPHONE", Icons::paintMicActive);
|
||||
}
|
||||
|
||||
/**
|
||||
* A fixed-width "channel icon / edit icon" label icon, used where a tab must not
|
||||
* change width as the object it shows changes (e.g. the info-panel-in-chat tab).
|
||||
*/
|
||||
public static ImageIcon channelClientPair() {
|
||||
ImageIcon channel = channel(true);
|
||||
ImageIcon edit = themed("EDIT", Icons::paintEdit);
|
||||
int gap = 8;
|
||||
int width = channel.getIconWidth() + gap + edit.getIconWidth();
|
||||
int height = Math.max(channel.getIconHeight(), edit.getIconHeight());
|
||||
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
g.drawImage(channel.getImage(), 0, (height - channel.getIconHeight()) / 2, null);
|
||||
g.setColor(Theme.idleClient());
|
||||
g.setFont(Theme.uiFont());
|
||||
java.awt.FontMetrics fm = g.getFontMetrics();
|
||||
int slashX = channel.getIconWidth() + (gap - fm.stringWidth("/")) / 2;
|
||||
g.drawString("/", slashX, (height + fm.getAscent()) / 2 - 1);
|
||||
g.drawImage(edit.getImage(), channel.getIconWidth() + gap,
|
||||
(height - edit.getIconHeight()) / 2, null);
|
||||
g.dispose();
|
||||
return new ImageIcon(img);
|
||||
}
|
||||
|
||||
/** The toolbar's away marker. */
|
||||
public static ImageIcon away() {
|
||||
return themed("AWAY", IconTheme.TOOLBAR_SIZE, g -> paintPerson(g, Theme.AWAY));
|
||||
return themed("AWAY", IconTheme.TOOLBAR_SIZE, g -> paintPerson(g, Theme.away()));
|
||||
}
|
||||
|
||||
public static ImageIcon settings() {
|
||||
@@ -304,7 +329,7 @@ public final class Icons {
|
||||
}
|
||||
|
||||
private static void paintMicMuted(Graphics2D g) {
|
||||
g.setColor(Theme.MUTED);
|
||||
g.setColor(Theme.muted());
|
||||
g.fillRoundRect(6, 2, 4, 7, 2, 2);
|
||||
g.setStroke(new BasicStroke(1.4f));
|
||||
g.drawArc(4, 6, 8, 6, 200, 140);
|
||||
@@ -314,7 +339,7 @@ public final class Icons {
|
||||
}
|
||||
|
||||
private static void paintSpeakerMuted(Graphics2D g) {
|
||||
g.setColor(Theme.MUTED);
|
||||
g.setColor(Theme.muted());
|
||||
g.fillRect(2, 6, 3, 4);
|
||||
int[] xs = {5, 9, 9, 5};
|
||||
int[] ys = {6, 3, 13, 10};
|
||||
@@ -325,7 +350,7 @@ public final class Icons {
|
||||
|
||||
/** Same shape as {@link #paintMicMuted}, but grey: unavailable, not muted by choice. */
|
||||
private static void paintMicDisabled(Graphics2D g) {
|
||||
g.setColor(Theme.IDLE_CLIENT);
|
||||
g.setColor(Theme.idleClient());
|
||||
g.fillRoundRect(6, 2, 4, 7, 2, 2);
|
||||
g.setStroke(new BasicStroke(1.4f));
|
||||
g.drawArc(4, 6, 8, 6, 200, 140);
|
||||
@@ -336,7 +361,7 @@ public final class Icons {
|
||||
|
||||
/** Same shape as {@link #paintSpeakerMuted}, but grey: unavailable, not muted by choice. */
|
||||
private static void paintSpeakerDisabled(Graphics2D g) {
|
||||
g.setColor(Theme.IDLE_CLIENT);
|
||||
g.setColor(Theme.idleClient());
|
||||
g.fillRect(2, 6, 3, 4);
|
||||
int[] xs = {5, 9, 9, 5};
|
||||
int[] ys = {6, 3, 13, 10};
|
||||
@@ -348,7 +373,7 @@ public final class Icons {
|
||||
/** {@link #paintMicMuted}, marked with a small dot: silenced, but not reported as muted. */
|
||||
private static void paintMicLocalMuted(Graphics2D g) {
|
||||
paintMicMuted(g);
|
||||
g.setColor(Theme.ACCENT);
|
||||
g.setColor(Theme.accent());
|
||||
g.fillOval(11, 10, 4, 4);
|
||||
}
|
||||
|
||||
@@ -362,7 +387,7 @@ public final class Icons {
|
||||
}
|
||||
|
||||
private static void paintDisconnect(Graphics2D g) {
|
||||
g.setColor(Theme.MUTED);
|
||||
g.setColor(Theme.muted());
|
||||
g.setStroke(new BasicStroke(2f));
|
||||
g.drawLine(3, 8, 8, 8);
|
||||
g.fillRoundRect(8, 5, 5, 6, 2, 2);
|
||||
@@ -389,7 +414,7 @@ public final class Icons {
|
||||
}
|
||||
|
||||
private static void paintMicActive(Graphics2D g) {
|
||||
g.setColor(Theme.TALKING);
|
||||
g.setColor(Theme.talking());
|
||||
g.fillOval(1, 1, 14, 14);
|
||||
g.setColor(Color.WHITE);
|
||||
g.fillRoundRect(6, 3, 4, 6, 2, 2);
|
||||
@@ -398,6 +423,18 @@ public final class Icons {
|
||||
g.drawLine(8, 11, 8, 13);
|
||||
}
|
||||
|
||||
/** A pencil glyph, drawn when no icon pack ships an "EDIT" icon. */
|
||||
private static void paintEdit(Graphics2D g) {
|
||||
g.setColor(new Color(0x37474F));
|
||||
g.setStroke(new BasicStroke(1.4f));
|
||||
g.drawLine(3, 13, 11, 5);
|
||||
g.drawLine(3, 13, 4, 10);
|
||||
g.drawLine(4, 10, 11, 3);
|
||||
g.drawLine(11, 3, 13, 5);
|
||||
g.drawLine(13, 5, 11, 7);
|
||||
g.drawLine(11, 5, 13, 7);
|
||||
}
|
||||
|
||||
private static void paintSettings(Graphics2D g) {
|
||||
g.setColor(new Color(0x37474F));
|
||||
g.setStroke(new BasicStroke(2f));
|
||||
@@ -413,7 +450,7 @@ public final class Icons {
|
||||
}
|
||||
|
||||
private static void paintApp(Graphics2D g) {
|
||||
g.setColor(Theme.ACCENT);
|
||||
g.setColor(Theme.accent());
|
||||
g.fillRoundRect(1, 1, 14, 14, 4, 4);
|
||||
g.setColor(Color.WHITE);
|
||||
g.setStroke(new BasicStroke(1.6f));
|
||||
|
||||
@@ -20,39 +20,100 @@ import java.util.List;
|
||||
*/
|
||||
public final class InfoPanel extends JScrollPane {
|
||||
|
||||
private final JEditorPane pane = new JEditorPane();
|
||||
/** Moves the whole info panel's content out to a persistent chat tab. */
|
||||
public interface DescriptionHandler {
|
||||
/** Shows/updates {@code html} in the info chat tab, creating it if needed. */
|
||||
void showInfoTab(String html, Runnable onClose);
|
||||
|
||||
/** Closes the info chat tab (the user chose to show details in the panel again). */
|
||||
void closeInfoTab();
|
||||
|
||||
/** Hides (or restores) the panel itself, freeing its space, while its content lives in the chat tab. */
|
||||
void setPanelHidden(boolean hidden);
|
||||
}
|
||||
|
||||
private static final String TOGGLE_HREF = "app:toggle-info-tab";
|
||||
|
||||
private final JEditorPane pane = HtmlStyles.pane("font-family:sans-serif; font-size:11px;");
|
||||
private DescriptionHandler descriptionHandler;
|
||||
private ChannelNode shownChannel;
|
||||
private ClientEntry shownClient;
|
||||
private ServerModel shownModel;
|
||||
private IconRepository shownIcons;
|
||||
private boolean inChatTab;
|
||||
|
||||
public InfoPanel() {
|
||||
javax.swing.text.html.HTMLEditorKit kit = new javax.swing.text.html.HTMLEditorKit();
|
||||
javax.swing.text.html.StyleSheet css = new javax.swing.text.html.StyleSheet();
|
||||
css.addStyleSheet(kit.getStyleSheet());
|
||||
css.addRule("a { color:" + hex(Theme.CHAT_NAME) + "; text-decoration:none; }");
|
||||
// Client references look exactly like a message author's name.
|
||||
css.addRule("a." + BBCode.IDENTITY_LINK_CLASS
|
||||
+ " { color:" + hex(Theme.CHAT_NAME) + "; font-weight:bold; text-decoration:none; }");
|
||||
// External links are underlined so they can't be mistaken for an identity.
|
||||
css.addRule("a." + BBCode.EXTERNAL_LINK_CLASS
|
||||
+ " { color:" + hex(Theme.LINK) + "; text-decoration:underline; }");
|
||||
kit.setStyleSheet(css);
|
||||
pane.setEditorKit(kit);
|
||||
pane.setEditable(false);
|
||||
pane.setBackground(Theme.CHAT_BG);
|
||||
pane.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
|
||||
pane.addHyperlinkListener(e -> {
|
||||
if (e.getEventType() == javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) {
|
||||
if (e.getEventType() != javax.swing.event.HyperlinkEvent.EventType.ACTIVATED) return;
|
||||
if (TOGGLE_HREF.equals(e.getDescription())) {
|
||||
inChatTab = !inChatTab;
|
||||
if (!inChatTab && descriptionHandler != null) descriptionHandler.closeInfoTab();
|
||||
render();
|
||||
} else {
|
||||
Links.open(e.getDescription(), this);
|
||||
}
|
||||
});
|
||||
setViewportView(pane);
|
||||
setBorder(BorderFactory.createLineBorder(new java.awt.Color(0xD0D0D0)));
|
||||
HtmlStyles.fillViewport(pane);
|
||||
clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateUI() {
|
||||
super.updateUI();
|
||||
setBorder(BorderFactory.createLineBorder(Theme.border()));
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
setHtml("<i style='color:#8a8a8a'>Select a channel or client to see details.</i>");
|
||||
shownChannel = null;
|
||||
shownClient = null;
|
||||
inChatTab = false;
|
||||
render();
|
||||
}
|
||||
|
||||
public void setDescriptionHandler(DescriptionHandler handler) {
|
||||
this.descriptionHandler = handler;
|
||||
}
|
||||
|
||||
/** Runs when the user closes the info chat tab; shows details in the panel again. */
|
||||
private void onInfoTabClosed() {
|
||||
inChatTab = false;
|
||||
render();
|
||||
}
|
||||
|
||||
public void showChannel(ChannelNode ch) {
|
||||
shownChannel = ch;
|
||||
shownClient = null;
|
||||
render();
|
||||
}
|
||||
|
||||
public void showClient(ClientEntry cl, ServerModel model, IconRepository icons) {
|
||||
shownClient = cl;
|
||||
shownChannel = null;
|
||||
shownModel = model;
|
||||
shownIcons = icons;
|
||||
render();
|
||||
}
|
||||
|
||||
private void render() {
|
||||
boolean selected = shownChannel != null || shownClient != null;
|
||||
boolean hide = inChatTab && selected;
|
||||
if (descriptionHandler != null) descriptionHandler.setPanelHidden(hide);
|
||||
if (hide) {
|
||||
descriptionHandler.showInfoTab(body(), this::onInfoTabClosed);
|
||||
} else {
|
||||
setHtml(body() + (selected ? toggleLink(true) : ""));
|
||||
}
|
||||
}
|
||||
|
||||
private String body() {
|
||||
if (shownChannel != null) return channelBody(shownChannel);
|
||||
if (shownClient != null) return clientBody(shownClient, shownModel, shownIcons);
|
||||
return "<i class='muted'>Select a channel or client to see details.</i>";
|
||||
}
|
||||
|
||||
private static String channelBody(ChannelNode ch) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(heading(esc(ch.name)));
|
||||
row(sb, "Type", ch.permanent ? "Permanent" : "Temporary");
|
||||
@@ -63,16 +124,16 @@ public final class InfoPanel extends JScrollPane {
|
||||
if (ch.description != null && !ch.description.isEmpty()) {
|
||||
sb.append("<div>").append(multiline(ch.description)).append("</div>");
|
||||
} else if (ch.descriptionLoaded) {
|
||||
sb.append("<i style='color:#8a8a8a'>No description.</i>");
|
||||
sb.append("<i class='muted'>No description.</i>");
|
||||
} else {
|
||||
sb.append("<i style='color:#8a8a8a'>Loading description…</i>");
|
||||
sb.append("<i class='muted'>Loading description…</i>");
|
||||
}
|
||||
setHtml(sb.toString());
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public void showClient(ClientEntry cl, ServerModel model, IconRepository icons) {
|
||||
private static String clientBody(ClientEntry cl, ServerModel model, IconRepository icons) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(heading(esc(cl.nickname) + (cl.self ? " <span style='color:#8a8a8a'>(you)</span>" : "")));
|
||||
sb.append(heading(esc(cl.nickname) + (cl.self ? " <span class='muted'>(you)</span>" : "")));
|
||||
|
||||
List<Group> serverGroups = model.serverGroupsOf(cl.serverGroupIds);
|
||||
if (serverGroups.isEmpty()) {
|
||||
@@ -105,12 +166,17 @@ public final class InfoPanel extends JScrollPane {
|
||||
if (cl.description != null && !cl.description.isEmpty()) {
|
||||
sb.append("<hr><div>").append(multiline(cl.description)).append("</div>");
|
||||
}
|
||||
setHtml(sb.toString());
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** The link that toggles between showing details here and in a chat tab. */
|
||||
private static String toggleLink(boolean toChat) {
|
||||
return "<div style='margin-top:8px'><a href='" + TOGGLE_HREF + "' style='font-size:10px'>"
|
||||
+ (toChat ? "Show in chat tab" : "Show here") + "</a></div>";
|
||||
}
|
||||
|
||||
private void setHtml(String body) {
|
||||
pane.setText("<html><body style='font-family:sans-serif;font-size:11px;color:#202020'>"
|
||||
+ body + "</body></html>");
|
||||
pane.setText("<html><body>" + body + "</body></html>");
|
||||
pane.setCaretPosition(0);
|
||||
}
|
||||
|
||||
@@ -131,7 +197,7 @@ public final class InfoPanel extends JScrollPane {
|
||||
}
|
||||
|
||||
private static void row(StringBuilder sb, String label, String value) {
|
||||
sb.append("<div style='margin:1px 0'><span style='color:#5a6b7b'>")
|
||||
sb.append("<div style='margin:1px 0'><span class='muted'>")
|
||||
.append(label).append(":</span> ").append(value).append("</div>");
|
||||
}
|
||||
|
||||
@@ -144,7 +210,4 @@ public final class InfoPanel extends JScrollPane {
|
||||
return BBCode.escape(s);
|
||||
}
|
||||
|
||||
private static String hex(java.awt.Color c) {
|
||||
return String.format("#%06X", c.getRGB() & 0xFFFFFF);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ public final class LevelMeter extends JComponent {
|
||||
g.fillRoundRect(0, 0, w - 1, h - 1, 6, 6);
|
||||
|
||||
int level = dbToX(levelDb, w - 2);
|
||||
g.setColor(transmitting ? Theme.TALKING : new Color(0x5A9BD4));
|
||||
g.setColor(transmitting ? Theme.talking() : new Color(0x5A9BD4));
|
||||
g.fillRoundRect(1, 1, Math.max(0, level), h - 3, 5, 5);
|
||||
|
||||
if (showThreshold) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.formdev.flatlaf.FlatDarkLaf;
|
||||
import com.formdev.flatlaf.FlatLaf;
|
||||
import com.formdev.flatlaf.FlatLightLaf;
|
||||
import com.ts3client.config.Settings;
|
||||
|
||||
import java.awt.Window;
|
||||
|
||||
/**
|
||||
* Installs FlatLaf in the requested variant and keeps {@link Theme} in step with it.
|
||||
*
|
||||
* <p>The client's own look-and-feel defaults — rounded controls, and the darker palette
|
||||
* the dark theme is built on — live beside this class as FlatLaf properties files, which
|
||||
* FlatLaf loads on top of the theme's own.
|
||||
*/
|
||||
public final class LookAndFeelManager {
|
||||
|
||||
/** Where our own FlatLaf properties files sit. */
|
||||
private static final String DEFAULTS_PACKAGE = "com.ts3client.ui.laf";
|
||||
|
||||
static {
|
||||
// Registered once: FlatLaf keeps every registration, and install() runs again on
|
||||
// each theme switch.
|
||||
FlatLaf.registerCustomDefaultsSource(DEFAULTS_PACKAGE);
|
||||
}
|
||||
|
||||
private LookAndFeelManager() {
|
||||
}
|
||||
|
||||
/** Installs the look-and-feel for {@code appearance}. Call before building any window. */
|
||||
public static void install(Settings.Appearance appearance) {
|
||||
FlatLaf laf = appearance == Settings.Appearance.DARK ? new FlatDarkLaf() : new FlatLightLaf();
|
||||
// Whatever look-and-feel stays installed on failure, Theme falls back to its own palette.
|
||||
FlatLaf.setup(laf);
|
||||
Theme.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the look-and-feel of the running client. Components that read {@link Theme}
|
||||
* while painting follow immediately; the ones that copied a colour when they were built
|
||||
* are refreshed by the look-and-feel update.
|
||||
*/
|
||||
public static void switchTo(Settings.Appearance appearance) {
|
||||
install(appearance);
|
||||
FlatLaf.updateUI();
|
||||
for (Window w : Window.getWindows()) {
|
||||
w.repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,22 +12,15 @@ import com.ts3client.sound.SoundNotifier;
|
||||
import com.ts3client.sound.SoundPlayer;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JCheckBoxMenuItem;
|
||||
import javax.swing.JFrame;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JMenu;
|
||||
import javax.swing.JMenuBar;
|
||||
import javax.swing.JMenuItem;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JPopupMenu;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.JToggleButton;
|
||||
import javax.swing.JToolBar;
|
||||
import javax.swing.KeyStroke;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
@@ -65,25 +58,14 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
/** The tab that owns the capture device; null when nobody is capturing. */
|
||||
private ServerTab micTab;
|
||||
|
||||
private JMenu bookmarksMenu;
|
||||
private JCheckBoxMenuItem awayItem;
|
||||
private JMenuItem awayStatusItem;
|
||||
private JCheckBoxMenuItem commanderItem;
|
||||
private MainMenuBar menuBar;
|
||||
private javax.swing.Timer statusTimer;
|
||||
|
||||
private final JLabel statusLabel = new JLabel("Not connected");
|
||||
private final JLabel codecLabel = new JLabel();
|
||||
|
||||
/** Mirrors the active server's own client state next to the clock. */
|
||||
private TrayController tray;
|
||||
|
||||
private JToolBar toolbar;
|
||||
private JButton connectButton;
|
||||
private JButton disconnectButton;
|
||||
private JToggleButton activeButton;
|
||||
private JToggleButton micButton;
|
||||
private JToggleButton speakerButton;
|
||||
private DropDownToggleButton awayButton;
|
||||
private MainToolbar toolbar;
|
||||
private StatusBar statusBar;
|
||||
|
||||
private boolean pttPressed;
|
||||
|
||||
@@ -117,22 +99,24 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
setMinimumSize(new Dimension(720, 480));
|
||||
|
||||
tray = new TrayController(this, this::quit);
|
||||
setJMenuBar(buildMenuBar());
|
||||
menuBar = buildMenuBar();
|
||||
setJMenuBar(menuBar);
|
||||
|
||||
toolbar = buildToolbar();
|
||||
add(toolbar, BorderLayout.NORTH);
|
||||
// Switching the icon pack in the options dialog re-decorates the whole window.
|
||||
IconTheme.get().addListener(() -> SwingUtilities.invokeLater(this::rebuildIcons));
|
||||
add(tabPane, BorderLayout.CENTER);
|
||||
add(buildStatusBar(), BorderLayout.SOUTH);
|
||||
statusBar = new StatusBar();
|
||||
statusBar.setVisible(settings.showStatusBar);
|
||||
add(statusBar, BorderLayout.SOUTH);
|
||||
|
||||
codecLabel.setText(audio.description());
|
||||
statusBar.setCodec(audio.description());
|
||||
|
||||
ServerTab first = newTab();
|
||||
selectTab(first);
|
||||
first.chat().appendSystem("Welcome to the TS3J Swing client.");
|
||||
first.chat().appendSystem("Use Connections → Connect to join a server.");
|
||||
first.chat().appendSystem(tray.status());
|
||||
|
||||
statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus());
|
||||
statusTimer.start();
|
||||
@@ -150,160 +134,156 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
|
||||
// ---- UI construction ----
|
||||
|
||||
private JMenuBar buildMenuBar() {
|
||||
JMenuBar bar = new JMenuBar();
|
||||
|
||||
JMenu connections = new JMenu("Connections");
|
||||
JMenuItem connect = new JMenuItem("Connect…", Icons.of("CONNECT"));
|
||||
connect.setAccelerator(KeyStroke.getKeyStroke("control S"));
|
||||
connect.addActionListener(e -> showConnectDialog());
|
||||
JMenuItem disconnect = new JMenuItem("Disconnect", Icons.of("DISCONNECT"));
|
||||
disconnect.addActionListener(e -> doDisconnect());
|
||||
JMenuItem closeTab = new JMenuItem("Close tab", Icons.of("CLOSE_BUTTON"));
|
||||
closeTab.setAccelerator(KeyStroke.getKeyStroke("control W"));
|
||||
closeTab.addActionListener(e -> closeTab(selected));
|
||||
JMenuItem quit = new JMenuItem("Quit", Icons.of("QUIT"));
|
||||
quit.addActionListener(e -> quit());
|
||||
connections.add(connect);
|
||||
connections.add(disconnect);
|
||||
connections.add(closeTab);
|
||||
connections.addSeparator();
|
||||
connections.add(quit);
|
||||
|
||||
bookmarksMenu = new JMenu("Bookmarks");
|
||||
rebuildBookmarksMenu();
|
||||
|
||||
JMenu self = new JMenu("Self");
|
||||
JMenuItem mute = new JMenuItem("Toggle microphone", Icons.of("CAPTURE"));
|
||||
mute.addActionListener(e -> micButton.doClick());
|
||||
JMenuItem deaf = new JMenuItem("Toggle speakers", Icons.of("PLAYBACK"));
|
||||
deaf.addActionListener(e -> speakerButton.doClick());
|
||||
awayItem = new JCheckBoxMenuItem("Away", Icons.of("AWAY"), false);
|
||||
awayItem.addActionListener(e -> toggleAway());
|
||||
awayStatusItem = new JMenuItem("Set away status…", Icons.of("EDIT"));
|
||||
awayStatusItem.addActionListener(e -> setAwayStatus());
|
||||
commanderItem = new JCheckBoxMenuItem("Channel commander", Icons.of("CHANNEL_COMMANDER"), false);
|
||||
commanderItem.addActionListener(e -> {
|
||||
if (selected != null) selected.setCommander(commanderItem.isSelected());
|
||||
});
|
||||
JMenuItem nick = new JMenuItem("Change nickname…", Icons.of("CHANGE_NICKNAME"));
|
||||
nick.addActionListener(e -> changeNickname());
|
||||
self.add(mute);
|
||||
self.add(deaf);
|
||||
self.addSeparator();
|
||||
self.add(awayItem);
|
||||
self.add(awayStatusItem);
|
||||
self.add(commanderItem);
|
||||
self.addSeparator();
|
||||
self.add(nick);
|
||||
|
||||
JMenu tools = new JMenu("Tools");
|
||||
JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER"));
|
||||
identitiesItem.addActionListener(e -> showIdentities());
|
||||
JMenuItem options = new JMenuItem("Options…", Icons.of("SETTINGS"));
|
||||
options.addActionListener(e -> showSettings());
|
||||
tools.add(identitiesItem);
|
||||
tools.addSeparator();
|
||||
tools.add(options);
|
||||
|
||||
JMenu help = new JMenu("Help");
|
||||
JMenuItem about = new JMenuItem("About", Icons.of("ABOUT"));
|
||||
about.addActionListener(e -> showAbout());
|
||||
help.add(about);
|
||||
|
||||
bar.add(connections);
|
||||
bar.add(bookmarksMenu);
|
||||
bar.add(self);
|
||||
bar.add(tools);
|
||||
bar.add(help);
|
||||
return bar;
|
||||
private MainMenuBar buildMenuBar() {
|
||||
return new MainMenuBar(bookmarks, new MainMenuBar.Listener() {
|
||||
@Override
|
||||
public void onConnect() {
|
||||
showConnectDialog();
|
||||
}
|
||||
|
||||
private void rebuildBookmarksMenu() {
|
||||
bookmarksMenu.removeAll();
|
||||
for (Bookmark b : bookmarks.all()) {
|
||||
JMenuItem item = new JMenuItem(b.displayName(), Icons.of("SERVER_GREEN"));
|
||||
item.addActionListener(e -> connectToBookmark(b));
|
||||
bookmarksMenu.add(item);
|
||||
}
|
||||
if (!bookmarks.all().isEmpty()) bookmarksMenu.addSeparator();
|
||||
JMenuItem addCurrent = new JMenuItem("Add current server…", Icons.of("BOOKMARK_ADD"));
|
||||
addCurrent.addActionListener(e -> addCurrentServerBookmark());
|
||||
JMenuItem manage = new JMenuItem("Manage bookmarks…", Icons.of("BOOKMARK_MANAGER"));
|
||||
manage.addActionListener(e -> new BookmarksDialog(this, bookmarks, identities,
|
||||
this::connectToBookmark, this::rebuildBookmarksMenu).setVisible(true));
|
||||
bookmarksMenu.add(addCurrent);
|
||||
bookmarksMenu.add(manage);
|
||||
@Override
|
||||
public void onDisconnect() {
|
||||
doDisconnect();
|
||||
}
|
||||
|
||||
private JToolBar buildToolbar() {
|
||||
JToolBar tb = new JToolBar();
|
||||
tb.setFloatable(false);
|
||||
tb.setBackground(Theme.TOOLBAR_BG);
|
||||
tb.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
|
||||
@Override
|
||||
public void onCloseTab() {
|
||||
closeTab(selected);
|
||||
}
|
||||
|
||||
connectButton = new JButton(Icons.connect());
|
||||
connectButton.setToolTipText("Connect to a server");
|
||||
connectButton.addActionListener(e -> showConnectDialog());
|
||||
@Override
|
||||
public void onQuit() {
|
||||
quit();
|
||||
}
|
||||
|
||||
disconnectButton = new JButton(Icons.disconnect());
|
||||
disconnectButton.setToolTipText("Disconnect");
|
||||
disconnectButton.addActionListener(e -> doDisconnect());
|
||||
@Override
|
||||
public void onToggleMic() {
|
||||
toolbar.clickMic();
|
||||
}
|
||||
|
||||
activeButton = new JToggleButton(Icons.micActive());
|
||||
activeButton.setToolTipText("Speak on this server (moves the microphone to this tab)");
|
||||
activeButton.addActionListener(e -> {
|
||||
if (selected != null && selected.isConnected()) setMicTab(selected);
|
||||
updateToolbar();
|
||||
@Override
|
||||
public void onToggleSpeaker() {
|
||||
toolbar.clickSpeaker();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAwayToggle(boolean away) {
|
||||
toggleAway(away);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAwayStatus() {
|
||||
setAwayStatus();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCommanderToggle(boolean commander) {
|
||||
if (selected != null) selected.setCommander(commander);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChangeNickname() {
|
||||
changeNickname();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShowIdentities() {
|
||||
showIdentities();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShowSettings() {
|
||||
showSettings();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onShowAbout() {
|
||||
showAbout();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConnectBookmark(Bookmark bookmark) {
|
||||
connectToBookmark(bookmark);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAddCurrentServerBookmark() {
|
||||
addCurrentServerBookmark();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onManageBookmarks() {
|
||||
new BookmarksDialog(MainFrame.this, bookmarks, identities,
|
||||
MainFrame.this::connectToBookmark, menuBar::rebuildBookmarks).setVisible(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
micButton = new JToggleButton(Icons.mic());
|
||||
micButton.setToolTipText("Mute / unmute microphone on this server");
|
||||
micButton.addActionListener(e -> {
|
||||
private MainToolbar buildToolbar() {
|
||||
return new MainToolbar(settings, new MainToolbar.Listener() {
|
||||
@Override
|
||||
public void onConnect() {
|
||||
showConnectDialog();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnect() {
|
||||
doDisconnect();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivate() {
|
||||
moveMicrophoneToSelectedTab();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMicMuteToggle(boolean muted) {
|
||||
if (selected == null) return;
|
||||
selected.setMicMuted(micButton.isSelected());
|
||||
selected.setMicMuted(muted);
|
||||
updateToolbar();
|
||||
});
|
||||
}
|
||||
|
||||
speakerButton = new JToggleButton(Icons.speaker());
|
||||
speakerButton.setToolTipText("Deafen / undeafen (mute speakers) on this server");
|
||||
speakerButton.addActionListener(e -> {
|
||||
@Override
|
||||
public void onDeafenToggle(boolean deafened) {
|
||||
if (selected == null) return;
|
||||
selected.setDeafened(speakerButton.isSelected());
|
||||
selected.setDeafened(deafened);
|
||||
updateToolbar();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAwayToggle(boolean away) {
|
||||
toggleAway(away);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JPopupMenu buildAwayMenu() {
|
||||
return MainFrame.this.buildAwayMenu();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSettings() {
|
||||
showSettings();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMasterVolumeChanged() {
|
||||
applyOutputSettingsToAllTabs();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStatusBarVisibilityChanged(boolean visible) {
|
||||
statusBar.setVisible(visible);
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
});
|
||||
|
||||
awayButton = new DropDownToggleButton(Icons.away(),
|
||||
"Away on this server (the arrow offers the global actions and presets)",
|
||||
this::buildAwayMenu);
|
||||
awayButton.addActionListener(e -> {
|
||||
if (selected == null) return;
|
||||
// The plain toggle carries no message; the menu is where messages are chosen.
|
||||
selected.setAway(awayButton.isSelected(), "");
|
||||
updateToolbar();
|
||||
});
|
||||
|
||||
JButton settingsButton = new JButton(Icons.settings());
|
||||
settingsButton.setToolTipText("Options");
|
||||
settingsButton.addActionListener(e -> showSettings());
|
||||
|
||||
tb.add(connectButton);
|
||||
tb.add(disconnectButton);
|
||||
tb.addSeparator();
|
||||
tb.add(activeButton);
|
||||
tb.add(micButton);
|
||||
tb.add(speakerButton);
|
||||
tb.add(awayButton);
|
||||
tb.addSeparator();
|
||||
tb.add(settingsButton);
|
||||
tb.add(Box.createHorizontalGlue());
|
||||
return tb;
|
||||
}
|
||||
|
||||
/** Rebuilds the icon-bearing chrome after the active icon pack changed. */
|
||||
private void rebuildIcons() {
|
||||
setIconImage(Icons.app().getImage());
|
||||
setJMenuBar(buildMenuBar());
|
||||
menuBar = buildMenuBar();
|
||||
setJMenuBar(menuBar);
|
||||
remove(toolbar);
|
||||
toolbar = buildToolbar();
|
||||
add(toolbar, BorderLayout.NORTH);
|
||||
@@ -314,18 +294,6 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
repaint();
|
||||
}
|
||||
|
||||
private JPanel buildStatusBar() {
|
||||
JPanel bar = new JPanel(new BorderLayout());
|
||||
bar.setBackground(Theme.STATUS_BG);
|
||||
bar.setBorder(BorderFactory.createEmptyBorder(3, 8, 3, 8));
|
||||
statusLabel.setFont(Theme.UI_FONT);
|
||||
codecLabel.setFont(Theme.UI_FONT);
|
||||
codecLabel.setForeground(Theme.CHAT_SYSTEM);
|
||||
bar.add(statusLabel, BorderLayout.WEST);
|
||||
bar.add(codecLabel, BorderLayout.EAST);
|
||||
return bar;
|
||||
}
|
||||
|
||||
// ---- tab management ----
|
||||
|
||||
private ServerTab newTab() {
|
||||
@@ -646,7 +614,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
if (joinChannel.isSelected()) bookmark.channel = channelPath;
|
||||
bookmarks.add(bookmark);
|
||||
bookmarks.save();
|
||||
rebuildBookmarksMenu();
|
||||
menuBar.rebuildBookmarks();
|
||||
}
|
||||
|
||||
/** The away button's drop-down: the global actions, the presets and their editor. */
|
||||
@@ -683,9 +651,9 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
return menu;
|
||||
}
|
||||
|
||||
private void toggleAway() {
|
||||
private void toggleAway(boolean away) {
|
||||
if (selected == null) return;
|
||||
selected.setAway(awayItem.isSelected(), "");
|
||||
selected.setAway(away, "");
|
||||
updateToolbar();
|
||||
}
|
||||
|
||||
@@ -783,7 +751,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
soundPlayer.setOutputDevice(settings.outputDevice);
|
||||
for (ServerTab tab : tabs) {
|
||||
if (tab.connection().getPlayback() == null) continue;
|
||||
tab.connection().getPlayback().setMasterVolume(settings.outputVolume);
|
||||
tab.connection().getPlayback().setMasterVolume(settings.effectiveOutputVolume());
|
||||
tab.connection().getPlayback().setOutputDevice(settings.outputDevice);
|
||||
}
|
||||
}
|
||||
@@ -803,7 +771,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
"An open-source TeamSpeak 3 desktop client built on the ts3j\n" +
|
||||
"reverse-engineered protocol library, with native Opus voice,\n" +
|
||||
"voice-activation detection and push-to-talk.\n\n" +
|
||||
codecLabel.getText(),
|
||||
statusBar.codecText(),
|
||||
"About TS3J", JOptionPane.INFORMATION_MESSAGE);
|
||||
}
|
||||
|
||||
@@ -811,28 +779,15 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
|
||||
private void updateToolbar() {
|
||||
boolean connected = selected != null && selected.isConnected();
|
||||
disconnectButton.setEnabled(connected);
|
||||
micButton.setEnabled(connected);
|
||||
speakerButton.setEnabled(connected);
|
||||
activeButton.setEnabled(connected);
|
||||
awayItem.setEnabled(connected);
|
||||
awayStatusItem.setEnabled(connected);
|
||||
// The menu's actions are global, so the arrow stays live while any server is up.
|
||||
awayButton.setEnabled(tabs.stream().anyMatch(ServerTab::isConnected));
|
||||
awayButton.setToggleEnabled(connected);
|
||||
commanderItem.setEnabled(connected);
|
||||
boolean anyConnected = tabs.stream().anyMatch(ServerTab::isConnected);
|
||||
|
||||
boolean micMuted = connected && selected.isMicMuted();
|
||||
boolean deaf = connected && selected.isDeafened();
|
||||
micButton.setSelected(micMuted);
|
||||
micButton.setIcon(micMuted ? Icons.micMutedLarge() : Icons.mic());
|
||||
speakerButton.setSelected(deaf);
|
||||
speakerButton.setIcon(deaf ? Icons.speakerMutedLarge() : Icons.speaker());
|
||||
activeButton.setSelected(selected != null && selected == micTab);
|
||||
boolean active = selected != null && selected == micTab;
|
||||
boolean away = connected && selected.isAway();
|
||||
awayItem.setSelected(away);
|
||||
awayButton.setSelected(away);
|
||||
commanderItem.setSelected(connected && selected.isCommander());
|
||||
boolean commander = connected && selected.isCommander();
|
||||
toolbar.refresh(connected, anyConnected, micMuted, deaf, active, away);
|
||||
menuBar.refresh(connected, away, commander);
|
||||
updateTray();
|
||||
}
|
||||
|
||||
@@ -856,7 +811,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
}
|
||||
|
||||
private void updateStatusLabel() {
|
||||
statusLabel.setText(selected == null ? "Not connected" : selected.status());
|
||||
statusBar.setStatus(selected == null ? "Not connected" : selected.status());
|
||||
}
|
||||
|
||||
private void updateConnectionStatus() {
|
||||
@@ -869,6 +824,6 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
double ping = tab.connection().getPingMillis();
|
||||
if (ping >= 0) s.append(" | ping ").append(Math.round(ping)).append(" ms");
|
||||
if (tab != micTab) s.append(" | microphone on another tab");
|
||||
statusLabel.setText(s.toString());
|
||||
statusBar.setStatus(s.toString());
|
||||
}
|
||||
}
|
||||
|
||||
155
ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java
Normal file
155
ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java
Normal file
@@ -0,0 +1,155 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.config.Bookmark;
|
||||
import com.ts3client.config.Bookmarks;
|
||||
|
||||
import javax.swing.JCheckBoxMenuItem;
|
||||
import javax.swing.JMenu;
|
||||
import javax.swing.JMenuBar;
|
||||
import javax.swing.JMenuItem;
|
||||
import javax.swing.KeyStroke;
|
||||
|
||||
/**
|
||||
* The main window's menu bar: Connections, Bookmarks, Self, Tools and Help.
|
||||
* Talks to {@link MainFrame} only through {@link Listener}; the away/commander
|
||||
* item states are pushed in by {@link #refresh}, mirroring how {@link MainToolbar}
|
||||
* is kept in sync.
|
||||
*/
|
||||
final class MainMenuBar extends JMenuBar {
|
||||
|
||||
interface Listener {
|
||||
void onConnect();
|
||||
|
||||
void onDisconnect();
|
||||
|
||||
void onCloseTab();
|
||||
|
||||
void onQuit();
|
||||
|
||||
void onToggleMic();
|
||||
|
||||
void onToggleSpeaker();
|
||||
|
||||
/** The plain toggle carries no message; "Set away status…" is where messages are chosen. */
|
||||
void onAwayToggle(boolean away);
|
||||
|
||||
void onAwayStatus();
|
||||
|
||||
void onCommanderToggle(boolean commander);
|
||||
|
||||
void onChangeNickname();
|
||||
|
||||
void onShowIdentities();
|
||||
|
||||
void onShowSettings();
|
||||
|
||||
void onShowAbout();
|
||||
|
||||
void onConnectBookmark(Bookmark bookmark);
|
||||
|
||||
void onAddCurrentServerBookmark();
|
||||
|
||||
void onManageBookmarks();
|
||||
}
|
||||
|
||||
private final Bookmarks bookmarks;
|
||||
private final Listener listener;
|
||||
|
||||
private JMenu bookmarksMenu;
|
||||
private JCheckBoxMenuItem awayItem;
|
||||
private JMenuItem awayStatusItem;
|
||||
private JCheckBoxMenuItem commanderItem;
|
||||
|
||||
MainMenuBar(Bookmarks bookmarks, Listener listener) {
|
||||
this.bookmarks = bookmarks;
|
||||
this.listener = listener;
|
||||
|
||||
JMenu connections = new JMenu("Connections");
|
||||
JMenuItem connect = new JMenuItem("Connect…", Icons.of("CONNECT"));
|
||||
connect.setAccelerator(KeyStroke.getKeyStroke("control S"));
|
||||
connect.addActionListener(e -> listener.onConnect());
|
||||
JMenuItem disconnect = new JMenuItem("Disconnect", Icons.of("DISCONNECT"));
|
||||
disconnect.addActionListener(e -> listener.onDisconnect());
|
||||
JMenuItem closeTab = new JMenuItem("Close tab", Icons.of("CLOSE_BUTTON"));
|
||||
closeTab.setAccelerator(KeyStroke.getKeyStroke("control W"));
|
||||
closeTab.addActionListener(e -> listener.onCloseTab());
|
||||
JMenuItem quit = new JMenuItem("Quit", Icons.of("QUIT"));
|
||||
quit.addActionListener(e -> listener.onQuit());
|
||||
connections.add(connect);
|
||||
connections.add(disconnect);
|
||||
connections.add(closeTab);
|
||||
connections.addSeparator();
|
||||
connections.add(quit);
|
||||
|
||||
bookmarksMenu = new JMenu("Bookmarks");
|
||||
rebuildBookmarks();
|
||||
|
||||
JMenu self = new JMenu("Self");
|
||||
JMenuItem mute = new JMenuItem("Toggle microphone", Icons.of("CAPTURE"));
|
||||
mute.addActionListener(e -> listener.onToggleMic());
|
||||
JMenuItem deaf = new JMenuItem("Toggle speakers", Icons.of("PLAYBACK"));
|
||||
deaf.addActionListener(e -> listener.onToggleSpeaker());
|
||||
awayItem = new JCheckBoxMenuItem("Away", Icons.of("AWAY"), false);
|
||||
awayItem.addActionListener(e -> listener.onAwayToggle(awayItem.isSelected()));
|
||||
awayStatusItem = new JMenuItem("Set away status…", Icons.of("EDIT"));
|
||||
awayStatusItem.addActionListener(e -> listener.onAwayStatus());
|
||||
commanderItem = new JCheckBoxMenuItem("Channel commander", Icons.of("CHANNEL_COMMANDER"), false);
|
||||
commanderItem.addActionListener(e -> listener.onCommanderToggle(commanderItem.isSelected()));
|
||||
JMenuItem nick = new JMenuItem("Change nickname…", Icons.of("CHANGE_NICKNAME"));
|
||||
nick.addActionListener(e -> listener.onChangeNickname());
|
||||
self.add(mute);
|
||||
self.add(deaf);
|
||||
self.addSeparator();
|
||||
self.add(awayItem);
|
||||
self.add(awayStatusItem);
|
||||
self.add(commanderItem);
|
||||
self.addSeparator();
|
||||
self.add(nick);
|
||||
|
||||
JMenu tools = new JMenu("Tools");
|
||||
JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER"));
|
||||
identitiesItem.addActionListener(e -> listener.onShowIdentities());
|
||||
JMenuItem options = new JMenuItem("Options…", Icons.of("SETTINGS"));
|
||||
options.addActionListener(e -> listener.onShowSettings());
|
||||
tools.add(identitiesItem);
|
||||
tools.addSeparator();
|
||||
tools.add(options);
|
||||
|
||||
JMenu help = new JMenu("Help");
|
||||
JMenuItem about = new JMenuItem("About", Icons.of("ABOUT"));
|
||||
about.addActionListener(e -> listener.onShowAbout());
|
||||
help.add(about);
|
||||
|
||||
add(connections);
|
||||
add(bookmarksMenu);
|
||||
add(self);
|
||||
add(tools);
|
||||
add(help);
|
||||
}
|
||||
|
||||
/** Reflects the current tab's away/commander state on the menu items. */
|
||||
void refresh(boolean connected, boolean away, boolean commander) {
|
||||
awayItem.setEnabled(connected);
|
||||
awayStatusItem.setEnabled(connected);
|
||||
commanderItem.setEnabled(connected);
|
||||
awayItem.setSelected(away);
|
||||
commanderItem.setSelected(commander);
|
||||
}
|
||||
|
||||
/** Re-lists the saved bookmarks; called after they change (add, remove, manage…). */
|
||||
void rebuildBookmarks() {
|
||||
bookmarksMenu.removeAll();
|
||||
for (Bookmark b : bookmarks.all()) {
|
||||
JMenuItem item = new JMenuItem(b.displayName(), Icons.of("SERVER_GREEN"));
|
||||
item.addActionListener(e -> listener.onConnectBookmark(b));
|
||||
bookmarksMenu.add(item);
|
||||
}
|
||||
if (!bookmarks.all().isEmpty()) bookmarksMenu.addSeparator();
|
||||
JMenuItem addCurrent = new JMenuItem("Add current server…", Icons.of("BOOKMARK_ADD"));
|
||||
addCurrent.addActionListener(e -> listener.onAddCurrentServerBookmark());
|
||||
JMenuItem manage = new JMenuItem("Manage bookmarks…", Icons.of("BOOKMARK_MANAGER"));
|
||||
manage.addActionListener(e -> listener.onManageBookmarks());
|
||||
bookmarksMenu.add(addCurrent);
|
||||
bookmarksMenu.add(manage);
|
||||
}
|
||||
}
|
||||
226
ts3-client/swing/src/main/java/com/ts3client/ui/MainToolbar.java
Normal file
226
ts3-client/swing/src/main/java/com/ts3client/ui/MainToolbar.java
Normal file
@@ -0,0 +1,226 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.config.Settings;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBoxMenuItem;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JPopupMenu;
|
||||
import javax.swing.JSlider;
|
||||
import javax.swing.JToggleButton;
|
||||
import javax.swing.JToolBar;
|
||||
import javax.swing.event.PopupMenuEvent;
|
||||
import javax.swing.event.PopupMenuListener;
|
||||
import java.awt.Component;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FlowLayout;
|
||||
|
||||
/**
|
||||
* The main window's toolbar: connect/disconnect, the per-tab mic/speaker/away
|
||||
* controls, and the Master Volume slider on the right. A right-click anywhere on
|
||||
* the bar opens a customization menu for the window's optional chrome (this
|
||||
* toolbar has no say over the status bar's own visibility beyond reporting the
|
||||
* toggle).
|
||||
*
|
||||
* <p>Talks to {@link MainFrame} only through {@link Listener}, so it owns no
|
||||
* connection/tab state itself — {@link #refresh} is handed everything it
|
||||
* needs to redraw each time the selection changes.
|
||||
*/
|
||||
final class MainToolbar extends JToolBar {
|
||||
|
||||
interface Listener {
|
||||
void onConnect();
|
||||
|
||||
void onDisconnect();
|
||||
|
||||
/** Moves the microphone to the tab on screen. */
|
||||
void onActivate();
|
||||
|
||||
void onMicMuteToggle(boolean muted);
|
||||
|
||||
void onDeafenToggle(boolean deafened);
|
||||
|
||||
/** The plain toggle carries no message; the drop-down arrow is where messages are chosen. */
|
||||
void onAwayToggle(boolean away);
|
||||
|
||||
JPopupMenu buildAwayMenu();
|
||||
|
||||
void onSettings();
|
||||
|
||||
/** The slider changed; push the new master volume to every open connection. */
|
||||
void onMasterVolumeChanged();
|
||||
|
||||
void onStatusBarVisibilityChanged(boolean visible);
|
||||
}
|
||||
|
||||
private final Settings settings;
|
||||
private final Listener listener;
|
||||
|
||||
private final JButton connectButton;
|
||||
private final JButton disconnectButton;
|
||||
private final JToggleButton activeButton;
|
||||
private final JToggleButton micButton;
|
||||
private final JToggleButton speakerButton;
|
||||
private final DropDownToggleButton awayButton;
|
||||
private final JComponent masterVolumePanel;
|
||||
|
||||
MainToolbar(Settings settings, Listener listener) {
|
||||
this.settings = settings;
|
||||
this.listener = listener;
|
||||
|
||||
setFloatable(false);
|
||||
setBackground(Theme.toolbarBg());
|
||||
setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
|
||||
setComponentPopupMenu(buildContextMenu());
|
||||
|
||||
connectButton = new JButton(Icons.connect());
|
||||
connectButton.setToolTipText("Connect to a server");
|
||||
connectButton.addActionListener(e -> listener.onConnect());
|
||||
|
||||
disconnectButton = new JButton(Icons.disconnect());
|
||||
disconnectButton.setToolTipText("Disconnect");
|
||||
disconnectButton.addActionListener(e -> listener.onDisconnect());
|
||||
|
||||
activeButton = new JToggleButton(Icons.micActive());
|
||||
activeButton.setToolTipText("Speak on this server (moves the microphone to this tab)");
|
||||
activeButton.addActionListener(e -> listener.onActivate());
|
||||
|
||||
micButton = new JToggleButton(Icons.mic());
|
||||
micButton.setToolTipText("Mute / unmute microphone on this server");
|
||||
micButton.addActionListener(e -> listener.onMicMuteToggle(micButton.isSelected()));
|
||||
|
||||
speakerButton = new JToggleButton(Icons.speaker());
|
||||
speakerButton.setToolTipText("Deafen / undeafen (mute speakers) on this server");
|
||||
speakerButton.addActionListener(e -> listener.onDeafenToggle(speakerButton.isSelected()));
|
||||
|
||||
awayButton = new DropDownToggleButton(Icons.away(),
|
||||
"Away on this server (the arrow offers the global actions and presets)",
|
||||
listener::buildAwayMenu);
|
||||
awayButton.addActionListener(e -> listener.onAwayToggle(awayButton.isSelected()));
|
||||
|
||||
JButton settingsButton = new JButton(Icons.settings());
|
||||
settingsButton.setToolTipText("Options");
|
||||
settingsButton.addActionListener(e -> listener.onSettings());
|
||||
|
||||
add(connectButton);
|
||||
add(disconnectButton);
|
||||
addSeparator();
|
||||
add(activeButton);
|
||||
add(micButton);
|
||||
add(speakerButton);
|
||||
add(awayButton);
|
||||
addSeparator();
|
||||
add(settingsButton);
|
||||
add(Box.createHorizontalGlue());
|
||||
masterVolumePanel = buildMasterVolumeControl();
|
||||
masterVolumePanel.setVisible(settings.showMasterVolumeSlider);
|
||||
add(masterVolumePanel);
|
||||
}
|
||||
|
||||
/** Reflects the current tab's state on the buttons. */
|
||||
void refresh(boolean connected, boolean anyConnected, boolean micMuted, boolean deafened,
|
||||
boolean active, boolean away) {
|
||||
disconnectButton.setEnabled(connected);
|
||||
micButton.setEnabled(connected);
|
||||
speakerButton.setEnabled(connected);
|
||||
activeButton.setEnabled(connected);
|
||||
// The away menu's actions are global, so the arrow stays live while any server is up.
|
||||
awayButton.setEnabled(anyConnected);
|
||||
awayButton.setToggleEnabled(connected);
|
||||
|
||||
micButton.setSelected(micMuted);
|
||||
micButton.setIcon(micMuted ? Icons.micMutedLarge() : Icons.mic());
|
||||
speakerButton.setSelected(deafened);
|
||||
speakerButton.setIcon(deafened ? Icons.speakerMutedLarge() : Icons.speaker());
|
||||
activeButton.setSelected(active);
|
||||
awayButton.setSelected(away);
|
||||
}
|
||||
|
||||
/** Simulates a click, for the "Toggle microphone" menu item. */
|
||||
void clickMic() {
|
||||
micButton.doClick();
|
||||
}
|
||||
|
||||
/** Simulates a click, for the "Toggle speakers" menu item. */
|
||||
void clickSpeaker() {
|
||||
speakerButton.doClick();
|
||||
}
|
||||
|
||||
/** The Master Volume slider: multiplies both voice and notification volume. */
|
||||
private JComponent buildMasterVolumeControl() {
|
||||
JPanel panel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0));
|
||||
panel.setOpaque(false);
|
||||
panel.setAlignmentY(Component.CENTER_ALIGNMENT);
|
||||
|
||||
JLabel icon = new JLabel(Icons.speaker());
|
||||
icon.setToolTipText("Master volume (voice + notifications)");
|
||||
icon.setAlignmentY(Component.CENTER_ALIGNMENT);
|
||||
|
||||
JSlider slider = new JSlider(0, 200, (int) Math.round(settings.masterVolume * 100));
|
||||
slider.setOpaque(false);
|
||||
slider.setToolTipText("Master volume (voice + notifications) — scroll to adjust");
|
||||
slider.setAlignmentY(Component.CENTER_ALIGNMENT);
|
||||
slider.setPreferredSize(new Dimension(135, slider.getPreferredSize().height));
|
||||
slider.addChangeListener(e -> {
|
||||
settings.masterVolume = slider.getValue() / 100.0;
|
||||
listener.onMasterVolumeChanged();
|
||||
// Only touch the disk once the drag (or a wheel step) has settled.
|
||||
if (!slider.getValueIsAdjusting()) settings.save();
|
||||
});
|
||||
slider.addMouseWheelListener(e -> {
|
||||
int step = e.getWheelRotation() < 0 ? 5 : -5;
|
||||
slider.setValue(slider.getValue() + step);
|
||||
e.consume();
|
||||
});
|
||||
|
||||
panel.add(icon);
|
||||
panel.add(slider);
|
||||
return panel;
|
||||
}
|
||||
|
||||
/** Right-click on the toolbar: toggles for the window's optional chrome. */
|
||||
private JPopupMenu buildContextMenu() {
|
||||
JPopupMenu menu = new JPopupMenu();
|
||||
|
||||
JCheckBoxMenuItem statusBarItem = new JCheckBoxMenuItem("Show Status Bar", settings.showStatusBar);
|
||||
statusBarItem.addActionListener(e -> {
|
||||
settings.showStatusBar = statusBarItem.isSelected();
|
||||
settings.save();
|
||||
listener.onStatusBarVisibilityChanged(settings.showStatusBar);
|
||||
});
|
||||
|
||||
JCheckBoxMenuItem volumeItem = new JCheckBoxMenuItem("Show Master Volume Slider", settings.showMasterVolumeSlider);
|
||||
volumeItem.addActionListener(e -> {
|
||||
settings.showMasterVolumeSlider = volumeItem.isSelected();
|
||||
settings.save();
|
||||
masterVolumePanel.setVisible(settings.showMasterVolumeSlider);
|
||||
revalidate();
|
||||
repaint();
|
||||
});
|
||||
|
||||
menu.add(statusBarItem);
|
||||
menu.add(volumeItem);
|
||||
// Either toggle can also be flipped elsewhere (or via settings.properties), so
|
||||
// re-sync the ticks each time the menu is about to be shown.
|
||||
menu.addPopupMenuListener(new PopupMenuListener() {
|
||||
@Override
|
||||
public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
|
||||
statusBarItem.setSelected(settings.showStatusBar);
|
||||
volumeItem.setSelected(settings.showMasterVolumeSlider);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void popupMenuCanceled(PopupMenuEvent e) {
|
||||
}
|
||||
});
|
||||
return menu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ClientEntry;
|
||||
|
||||
import javax.swing.DefaultCellEditor;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.JTree;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.Timer;
|
||||
import javax.swing.tree.DefaultMutableTreeNode;
|
||||
import javax.swing.tree.DefaultTreeCellEditor;
|
||||
import javax.swing.tree.DefaultTreeCellRenderer;
|
||||
import javax.swing.tree.TreePath;
|
||||
import java.awt.Component;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.EventObject;
|
||||
import java.util.function.IntPredicate;
|
||||
|
||||
/**
|
||||
* Turns our own row in the server tree into a text field on a double click, the way
|
||||
* TeamSpeak renames a client in place. Only that one row is editable; every other
|
||||
* double click keeps its usual meaning (joining a channel, opening a chat).
|
||||
*
|
||||
* <p>The field opens on the current nickname with it selected, so typing replaces it,
|
||||
* Enter commits and Escape leaves the name alone.
|
||||
*/
|
||||
final class NicknameCellEditor extends DefaultTreeCellEditor {
|
||||
|
||||
/** Pulls the field left by its own border, so the text sits where the label did. */
|
||||
private static final int FIELD_NUDGE = 2;
|
||||
|
||||
/** Long enough for the click's own release to have been handled first. */
|
||||
private static final int SELECT_DELAY_MS = 80;
|
||||
|
||||
private final JTextField field;
|
||||
/** Whether a client id is our own — the tree learns ours only once connected. */
|
||||
private final IntPredicate isSelf;
|
||||
|
||||
private NicknameCellEditor(JTree tree, DefaultTreeCellRenderer renderer, JTextField field,
|
||||
IntPredicate isSelf) {
|
||||
super(tree, renderer, new DefaultCellEditor(field));
|
||||
this.field = field;
|
||||
this.isSelf = isSelf;
|
||||
}
|
||||
|
||||
static NicknameCellEditor create(JTree tree, DefaultTreeCellRenderer renderer, IntPredicate isSelf) {
|
||||
JTextField field = new JTextField();
|
||||
// Only our own row is edited, and that name is bold in the tree.
|
||||
field.setFont(Theme.uiBold());
|
||||
return new NicknameCellEditor(tree, renderer, field, isSelf);
|
||||
}
|
||||
|
||||
/** Whether a path is the row this editor works on. */
|
||||
boolean editsPath(TreePath path) {
|
||||
ClientEntry client = clientOf(path);
|
||||
return client != null && isSelf.test(client.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Editing is started by the tree's own double-click handling, which works a row by
|
||||
* its whole line; Swing's would only see clicks that land on the label, and its
|
||||
* click-pause-click timer would rename on clicks meant as a selection.
|
||||
*/
|
||||
@Override
|
||||
public boolean isCellEditable(EventObject event) {
|
||||
return !(event instanceof MouseEvent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Component getTreeCellEditorComponent(JTree tree, Object value, boolean isSelected,
|
||||
boolean expanded, boolean leaf, int row) {
|
||||
// The node itself has to reach super, whose renderer measures the row from it;
|
||||
// the text it derives is the node's toString, so the nickname is filled in after.
|
||||
Component c = super.getTreeCellEditorComponent(tree, value, isSelected, expanded, leaf, row);
|
||||
ClientEntry client = clientOf(value);
|
||||
if (client != null) {
|
||||
field.setText(client.nickname);
|
||||
field.selectAll();
|
||||
}
|
||||
SwingUtilities.invokeLater(field::requestFocusInWindow);
|
||||
// The double click that opened the field ends in a release, which a text
|
||||
// component turns into a caret placement — undoing any selection made before
|
||||
// it. Selecting after that has been delivered is what leaves the whole name
|
||||
// selected, ready to be typed over.
|
||||
Timer select = new Timer(SELECT_DELAY_MS, e -> field.selectAll());
|
||||
select.setRepeats(false);
|
||||
select.start();
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the row's own icon while it is being edited. The inherited version knows
|
||||
* only the renderer's default leaf/branch icons, which would swap a client's state
|
||||
* icon for a blank page for as long as the field is open.
|
||||
*/
|
||||
@Override
|
||||
protected void determineOffset(JTree tree, Object value, boolean isSelected, boolean expanded,
|
||||
boolean leaf, int row) {
|
||||
super.determineOffset(tree, value, isSelected, expanded, leaf, row);
|
||||
Component c = renderer.getTreeCellRendererComponent(tree, value, isSelected, expanded, leaf,
|
||||
row, false);
|
||||
if (!(c instanceof JLabel)) return;
|
||||
editingIcon = ((JLabel) c).getIcon();
|
||||
offset = editingIcon == null ? 0
|
||||
: editingIcon.getIconWidth() + renderer.getIconTextGap() - FIELD_NUDGE;
|
||||
}
|
||||
|
||||
/** The edited nickname, trimmed; empty when nothing usable was typed. */
|
||||
String editedNickname() {
|
||||
String text = field.getText();
|
||||
return text == null ? "" : text.trim();
|
||||
}
|
||||
|
||||
private static ClientEntry clientOf(TreePath path) {
|
||||
return path == null ? null : clientOf(path.getLastPathComponent());
|
||||
}
|
||||
|
||||
private static ClientEntry clientOf(Object node) {
|
||||
if (!(node instanceof DefaultMutableTreeNode)) return null;
|
||||
Object obj = ((DefaultMutableTreeNode) node).getUserObject();
|
||||
return obj instanceof ClientEntry ? (ClientEntry) obj : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ClientEntry;
|
||||
import com.ts3client.net.Group;
|
||||
import com.ts3client.net.TeamspeakConnection;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.GrayFilter;
|
||||
import javax.swing.ImageIcon;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.SwingConstants;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Every regular server group on the server, as a scrollable checkbox list. Groups
|
||||
* the local client has no power to (un)assign are shown too, so the client's full
|
||||
* membership is visible, but greyed out and disabled.
|
||||
*/
|
||||
final class ServerGroupsDialog extends JDialog {
|
||||
|
||||
private final TeamspeakConnection conn;
|
||||
private final GroupIcons groupIcons;
|
||||
private final int clientId;
|
||||
private final ServerTreePanel.Actions actions;
|
||||
|
||||
private final JPanel list = new JPanel();
|
||||
private final JLabel header = new JLabel();
|
||||
/** Cached grayscale icons, keyed by the source icon's identity. */
|
||||
private final Map<ImageIcon, ImageIcon> grayscale = new HashMap<>();
|
||||
|
||||
ServerGroupsDialog(MainFrame owner, TeamspeakConnection conn, GroupIcons groupIcons,
|
||||
ClientEntry client, ServerTreePanel.Actions actions) {
|
||||
super(owner, "Server Groups", true);
|
||||
this.conn = conn;
|
||||
this.groupIcons = groupIcons;
|
||||
this.clientId = client.id;
|
||||
this.actions = actions;
|
||||
|
||||
list.setLayout(new BoxLayout(list, BoxLayout.Y_AXIS));
|
||||
JScrollPane scroll = new JScrollPane(list);
|
||||
scroll.setPreferredSize(new Dimension(280, 360));
|
||||
scroll.getVerticalScrollBar().setUnitIncrement(16);
|
||||
|
||||
header.setBorder(BorderFactory.createEmptyBorder(8, 10, 4, 10));
|
||||
header.setHorizontalAlignment(SwingConstants.LEFT);
|
||||
|
||||
JButton close = new JButton("Close");
|
||||
close.addActionListener(a -> dispose());
|
||||
JPanel buttons = new JPanel();
|
||||
buttons.add(close);
|
||||
getRootPane().setDefaultButton(close);
|
||||
|
||||
getContentPane().setLayout(new BorderLayout());
|
||||
getContentPane().add(header, BorderLayout.NORTH);
|
||||
getContentPane().add(scroll, BorderLayout.CENTER);
|
||||
getContentPane().add(buttons, BorderLayout.SOUTH);
|
||||
|
||||
Dialogs.closeOnEscape(this);
|
||||
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||
refresh();
|
||||
pack();
|
||||
setLocationRelativeTo(owner);
|
||||
}
|
||||
|
||||
/** Rebuilds the list from the current model state; closes the dialog if the client left. */
|
||||
void refresh() {
|
||||
ClientEntry client = conn.getModel().getClient(clientId);
|
||||
if (client == null) {
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
header.setText(client.nickname);
|
||||
|
||||
int addPower = conn.getModel().selfPermissionValue("i_group_needed_member_add_power");
|
||||
int removePower = conn.getModel().selfPermissionValue("i_group_needed_member_remove_power");
|
||||
List<Group> groups = conn.getModel().allServerGroups();
|
||||
|
||||
list.removeAll();
|
||||
for (Group g : groups) {
|
||||
boolean assigned = contains(client.serverGroupIds, g.id);
|
||||
boolean allowed = ClientMenu.canAssign(g, assigned, addPower, removePower);
|
||||
|
||||
ImageIcon icon = groupIcons.iconOf(g);
|
||||
JCheckBox box = new JCheckBox(g.name, allowed ? icon : grayscale(icon));
|
||||
box.setSelected(assigned);
|
||||
box.setEnabled(allowed);
|
||||
box.setAlignmentX(0f);
|
||||
if (allowed) {
|
||||
box.addActionListener(a -> actions.setClientServerGroup(client, g, box.isSelected()));
|
||||
}
|
||||
list.add(box);
|
||||
}
|
||||
list.revalidate();
|
||||
list.repaint();
|
||||
}
|
||||
|
||||
private ImageIcon grayscale(ImageIcon icon) {
|
||||
if (icon == null) return null;
|
||||
return grayscale.computeIfAbsent(icon,
|
||||
i -> new ImageIcon(GrayFilter.createDisabledImage(i.getImage())));
|
||||
}
|
||||
|
||||
private static boolean contains(int[] ids, int id) {
|
||||
for (int i : ids) if (i == id) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelSettings;
|
||||
|
||||
import javax.swing.JMenuItem;
|
||||
import javax.swing.JPopupMenu;
|
||||
|
||||
/**
|
||||
* The context menu for the server itself — the tree's root row.
|
||||
*/
|
||||
final class ServerMenu {
|
||||
|
||||
private ServerMenu() {
|
||||
}
|
||||
|
||||
static JPopupMenu build(ServerTreePanel.Actions actions) {
|
||||
JPopupMenu menu = new JPopupMenu();
|
||||
JMenuItem create = new JMenuItem("Create Channel", Icons.of("CHANNEL_CREATE"));
|
||||
create.addActionListener(a -> actions.createChannel(null, ChannelSettings.Type.PERMANENT));
|
||||
menu.add(create);
|
||||
JMenuItem spacer = new JMenuItem("Create Spacer",
|
||||
Icons.ofAny("CHANNEL_CREATE_SPACER", "CHANNEL_CREATE"));
|
||||
spacer.setToolTipText("Create a new spacer");
|
||||
spacer.addActionListener(a -> actions.createSpacer());
|
||||
menu.add(spacer);
|
||||
return menu;
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,11 @@ import com.ts3client.config.IdentityStore;
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ClientEntry;
|
||||
import com.ts3client.net.ConnectionListener;
|
||||
import com.ts3client.net.TeamspeakConnection;
|
||||
import com.ts3client.sound.SoundNotifier;
|
||||
import com.ts3client.text.TsLink;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JSplitPane;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.util.ArrayList;
|
||||
@@ -24,10 +22,12 @@ import java.awt.Component;
|
||||
* client keeps several of these side by side; {@link MainFrame} shows one at a
|
||||
* time and owns the toolbar, menus and status bar that act on it.
|
||||
*
|
||||
* <p>Everything that is per-server lives here: the connection, its microphone
|
||||
* and speaker mute state, the away/commander flags and the chat history.
|
||||
* <p>ServerTab itself owns the connection, the panels and this tab's identity
|
||||
* (title/status); the mic/away/deafen flags live in {@link ServerTabSelfState},
|
||||
* tree context-menu actions and selection in {@link ServerTabTreeActions}, and
|
||||
* {@link com.ts3client.net.ConnectionListener} callbacks in {@link ServerTabConnectionEvents}.
|
||||
*/
|
||||
final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
||||
final class ServerTab implements ServerTabConnectionEvents.Listener {
|
||||
|
||||
private final MainFrame host;
|
||||
private final Settings settings;
|
||||
@@ -35,10 +35,16 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
||||
|
||||
private final TeamspeakConnection conn;
|
||||
private final GroupIcons groupIcons;
|
||||
private final ServerTabConnectionEvents events;
|
||||
private final ServerTabSelfState selfState;
|
||||
private final ServerTabTreeActions treeActions;
|
||||
private final ServerTreePanel treePanel;
|
||||
private final ChatPanel chatPanel;
|
||||
private final InfoPanel infoPanel = new InfoPanel();
|
||||
private final JComponent component;
|
||||
private JSplitPane leftColumn;
|
||||
private int normalDividerSize;
|
||||
private int savedDividerLocation = -1;
|
||||
|
||||
/** Label shown in the tab bar: the server name once known, the address before that. */
|
||||
private String title = "New connection";
|
||||
@@ -48,43 +54,61 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
||||
/** Identity used for the current connection, so it can be saved into a bookmark. */
|
||||
private String identityId = "";
|
||||
|
||||
private boolean micMuted;
|
||||
private boolean micLocalMuted;
|
||||
private boolean deafened;
|
||||
private boolean away;
|
||||
/** Away message currently published, empty when away carries no message. */
|
||||
private String awayMessage = "";
|
||||
private boolean commander;
|
||||
|
||||
private Object currentSelection;
|
||||
|
||||
ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio,
|
||||
SoundNotifier sounds) {
|
||||
this.host = host;
|
||||
this.settings = settings;
|
||||
this.identities = identities;
|
||||
this.conn = new TeamspeakConnection(settings, audio, this, sounds);
|
||||
|
||||
// ServerTabConnectionEvents must exist before the connection (which needs a
|
||||
// listener up front), and TeamspeakConnection must exist before the tree/chat
|
||||
// panels and the other tab helpers that read from it — so wiring finishes with
|
||||
// an explicit attach() once everything is built. Nothing fires callbacks before then.
|
||||
this.events = new ServerTabConnectionEvents(host, this, this);
|
||||
this.conn = new TeamspeakConnection(settings, audio, events, sounds);
|
||||
this.groupIcons = new GroupIcons(conn.getIcons());
|
||||
this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, this);
|
||||
this.selfState = new ServerTabSelfState(conn);
|
||||
this.chatPanel = new ChatPanel();
|
||||
this.treeActions = new ServerTabTreeActions(host, this, conn, chatPanel, infoPanel, groupIcons);
|
||||
this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, treeActions);
|
||||
treeActions.attach(treePanel);
|
||||
events.attach(conn, treePanel, chatPanel, selfState, treeActions);
|
||||
|
||||
chatPanel.setSendHandler(this::onSendChat);
|
||||
chatPanel.setLinkHandler(new ChatPanel.LinkHandler() {
|
||||
@Override
|
||||
public void onClientLink(TsLink.Ref ref, Component source, int x, int y) {
|
||||
ServerTab.this.onClientLink(ref, source, x, y);
|
||||
treeActions.handleClientLink(ref, source, x, y);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChannelLink(TsLink.Ref ref, Component source, int x, int y) {
|
||||
ServerTab.this.onChannelLink(ref, source, x, y);
|
||||
treeActions.handleChannelLink(ref, source, x, y);
|
||||
}
|
||||
});
|
||||
chatPanel.setInputEnabled(false);
|
||||
|
||||
JSplitPane leftColumn = new JSplitPane(JSplitPane.VERTICAL_SPLIT, treePanel, infoPanel);
|
||||
infoPanel.setDescriptionHandler(new InfoPanel.DescriptionHandler() {
|
||||
@Override
|
||||
public void showInfoTab(String html, Runnable onClose) {
|
||||
chatPanel.openDescriptionTab("info", Icons.channelClientPair(), "", html, onClose);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void closeInfoTab() {
|
||||
chatPanel.closeNoteTab("info");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPanelHidden(boolean hidden) {
|
||||
ServerTab.this.setInfoPanelHidden(hidden);
|
||||
}
|
||||
});
|
||||
|
||||
leftColumn = new JSplitPane(JSplitPane.VERTICAL_SPLIT, treePanel, infoPanel);
|
||||
leftColumn.setResizeWeight(0.68);
|
||||
leftColumn.setContinuousLayout(true);
|
||||
normalDividerSize = leftColumn.getDividerSize();
|
||||
|
||||
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftColumn, chatPanel);
|
||||
split.setResizeWeight(0.55);
|
||||
@@ -137,40 +161,32 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
||||
}
|
||||
|
||||
boolean isMicMuted() {
|
||||
return micMuted;
|
||||
return selfState.isMicMuted();
|
||||
}
|
||||
|
||||
boolean isMicLocalMuted() {
|
||||
return micLocalMuted;
|
||||
return selfState.isMicLocalMuted();
|
||||
}
|
||||
|
||||
boolean isDeafened() {
|
||||
return deafened;
|
||||
return selfState.isDeafened();
|
||||
}
|
||||
|
||||
boolean isAway() {
|
||||
return away;
|
||||
return selfState.isAway();
|
||||
}
|
||||
|
||||
String awayMessage() {
|
||||
return awayMessage;
|
||||
return selfState.awayMessage();
|
||||
}
|
||||
|
||||
boolean isCommander() {
|
||||
return commander;
|
||||
return selfState.isCommander();
|
||||
}
|
||||
|
||||
/** What the local client looks like on this server, for the tray icon. */
|
||||
SelfState selfState() {
|
||||
if (!conn.isConnected()) return SelfState.DISCONNECTED;
|
||||
if (deafened) return SelfState.DEAFENED;
|
||||
if (micMuted) return SelfState.MIC_MUTED;
|
||||
if (micLocalMuted) return SelfState.MIC_LOCAL_MUTED;
|
||||
if (away) return SelfState.AWAY;
|
||||
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
|
||||
boolean talking = self != null && self.talking;
|
||||
if (commander) return talking ? SelfState.COMMANDER_TALKING : SelfState.COMMANDER;
|
||||
return talking ? SelfState.TALKING : SelfState.IDLE;
|
||||
return selfState.compute();
|
||||
}
|
||||
|
||||
/** Path of the channel we are in, or empty when not connected. */
|
||||
@@ -194,8 +210,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
||||
title = address + ":" + port;
|
||||
chatPanel.appendSystem("Connecting to " + address + ":" + port
|
||||
+ (channel == null || channel.isBlank() ? "" : " (channel \"" + channel + "\")") + " …");
|
||||
onStatus("Loading identity…");
|
||||
host.tabUpdated(this);
|
||||
events.onStatus("Loading identity…");
|
||||
|
||||
// Resolving may have to generate a first identity, so keep it off the EDT.
|
||||
new Thread(() -> {
|
||||
@@ -204,7 +219,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
||||
entry = identities.resolve(settings, identityId);
|
||||
} catch (Exception e) {
|
||||
connecting = false;
|
||||
onError("Could not load identity: " + e.getMessage());
|
||||
events.onError("Could not load identity: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
@@ -229,26 +244,36 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
||||
if (conn.isConnected()) conn.disconnectBlocking("Leaving");
|
||||
}
|
||||
|
||||
// ---- ServerTabConnectionEvents.Listener ----
|
||||
|
||||
@Override
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setConnecting(boolean connecting) {
|
||||
this.connecting = connecting;
|
||||
}
|
||||
|
||||
// ---- self state ----
|
||||
|
||||
void setMicMuted(boolean muted) {
|
||||
micMuted = muted;
|
||||
conn.setMicMuted(muted);
|
||||
chatPanel.appendSystem(muted ? "Microphone muted." : "Microphone active.");
|
||||
selfState.setMicMuted(muted);
|
||||
}
|
||||
|
||||
/** TS3's "Local Mic Mute": silences capture without publishing a status change. */
|
||||
void setMicLocalMuted(boolean muted) {
|
||||
micLocalMuted = muted;
|
||||
conn.setMicLocalMuted(muted);
|
||||
chatPanel.appendSystem(muted ? "Microphone locally muted." : "Microphone locally unmuted.");
|
||||
selfState.setMicLocalMuted(muted);
|
||||
}
|
||||
|
||||
void setDeafened(boolean deaf) {
|
||||
deafened = deaf;
|
||||
conn.setDeafened(deaf);
|
||||
if (deaf) micMuted = true;
|
||||
chatPanel.appendSystem(deaf ? "Speakers muted (deafened)." : "Speakers active.");
|
||||
selfState.setDeafened(deaf);
|
||||
}
|
||||
|
||||
/** Hands the capture device to (or takes it from) this connection. */
|
||||
@@ -261,16 +286,11 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
||||
* message outlives coming back, so toggling away again restores it
|
||||
*/
|
||||
void setAway(boolean away, String message) {
|
||||
this.away = away;
|
||||
if (message != null) this.awayMessage = message;
|
||||
conn.setAway(away, awayMessage);
|
||||
chatPanel.appendSystem(!away ? "No longer away."
|
||||
: awayMessage.isEmpty() ? "Away." : "Away: " + awayMessage);
|
||||
selfState.setAway(away, message);
|
||||
}
|
||||
|
||||
void setCommander(boolean commander) {
|
||||
this.commander = commander;
|
||||
conn.setChannelCommander(commander);
|
||||
selfState.setCommander(commander);
|
||||
}
|
||||
|
||||
void setNickname(String nickname) {
|
||||
@@ -302,33 +322,6 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
||||
}
|
||||
}
|
||||
|
||||
/** A client link in the chat log was clicked: show the same menu as the tree does. */
|
||||
private void onClientLink(TsLink.Ref ref, Component source, int x, int y) {
|
||||
ClientEntry client = conn.getModel().getClient(ref.id);
|
||||
// The id is only valid for the session the link was made in; fall back to
|
||||
// the unique id (and finally the nickname) so older links still resolve.
|
||||
if (client == null || (!ref.uniqueId.isEmpty() && !ref.uniqueId.equals(client.uniqueId))) {
|
||||
ClientEntry byUid = ref.uniqueId.isEmpty() ? null
|
||||
: conn.getModel().findClientByUniqueId(ref.uniqueId);
|
||||
if (byUid == null && !ref.name.isEmpty()) byUid = conn.getModel().findClientByName(ref.name);
|
||||
if (byUid != null) client = byUid;
|
||||
}
|
||||
if (client == null) {
|
||||
chatPanel.appendSystem("That client is no longer on the server.");
|
||||
return;
|
||||
}
|
||||
ClientMenu.build(client, client.id == conn.getSelfClientId(), this).show(source, x, y);
|
||||
}
|
||||
|
||||
private void onChannelLink(TsLink.Ref ref, Component source, int x, int y) {
|
||||
ChannelNode channel = conn.getModel().getChannel(ref.id);
|
||||
if (channel == null) {
|
||||
chatPanel.appendSystem("That channel no longer exists.");
|
||||
return;
|
||||
}
|
||||
ChannelMenu.build(channel, this).show(source, x, y);
|
||||
}
|
||||
|
||||
private String peerName(int clientId) {
|
||||
ClientEntry c = conn.getModel().getClient(clientId);
|
||||
return c != null ? c.nickname : "Client " + clientId;
|
||||
@@ -361,233 +354,24 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
|
||||
return self == null ? null : conn.getModel().getChannel(self.channelId);
|
||||
}
|
||||
|
||||
// ---- ServerTreePanel.Actions ----
|
||||
|
||||
@Override
|
||||
public void joinChannel(int channelId) {
|
||||
if (conn.isConnected()) conn.joinChannel(channelId, null);
|
||||
/** Opens the file repository browser for a channel, as the "Browse Files" hotkey/menu action does. */
|
||||
void browseFiles(ChannelNode channel) {
|
||||
treeActions.browseFiles(channel);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void moveClientToChannel(ClientEntry client, ChannelNode target) {
|
||||
if (!conn.isConnected()) return;
|
||||
if (client.id == conn.getSelfClientId()) {
|
||||
conn.joinChannel(target.id, null);
|
||||
/** Collapses the info panel to nothing while its content lives in the chat tab, or restores it. */
|
||||
private void setInfoPanelHidden(boolean hidden) {
|
||||
if (hidden == !infoPanel.isVisible()) return;
|
||||
if (hidden) {
|
||||
savedDividerLocation = leftColumn.getDividerLocation();
|
||||
infoPanel.setVisible(false);
|
||||
leftColumn.setDividerSize(0);
|
||||
leftColumn.setDividerLocation(1.0);
|
||||
} else {
|
||||
conn.moveClient(client.id, target.id, null);
|
||||
infoPanel.setVisible(true);
|
||||
leftColumn.setDividerSize(normalDividerSize);
|
||||
if (savedDividerLocation >= 0) leftColumn.setDividerLocation(savedDividerLocation);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void moveChannel(ChannelNode channel, int newParentId, int orderPredecessorId) {
|
||||
if (conn.isConnected()) conn.moveChannel(channel.id, newParentId, orderPredecessorId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void openPrivateChat(ClientEntry client) {
|
||||
chatPanel.openPrivateChat(client.id, client.nickname);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pokeClient(ClientEntry client) {
|
||||
String msg = JOptionPane.showInputDialog(host, "Poke message for " + client.nickname + ":", "Poke!");
|
||||
if (msg != null) conn.poke(client.id, msg);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void kickClientFromChannel(ClientEntry client) {
|
||||
String reason = kickReason("Kick Client from Channel", client);
|
||||
if (reason != null) conn.kickFromChannel(client.id, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void kickClientFromServer(ClientEntry client) {
|
||||
String reason = kickReason("Kick Client from Server", client);
|
||||
if (reason != null) conn.kickFromServer(client.id, reason);
|
||||
}
|
||||
|
||||
private String kickReason(String title, ClientEntry client) {
|
||||
if (!conn.isConnected()) return null;
|
||||
return ReasonDialog.prompt(host, title, "Reason for kicking " + client.nickname + ":",
|
||||
ReasonDialog.KICK_REASON_LIMIT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void banClient(ClientEntry client) {
|
||||
if (!conn.isConnected()) return;
|
||||
BanDialog dialog = new BanDialog(host, client.nickname);
|
||||
dialog.setVisible(true);
|
||||
if (dialog.isConfirmed()) conn.banClient(client.id, dialog.getSeconds(), dialog.getReason());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggleClientMute(ClientEntry client) {
|
||||
if (conn.getPlayback() == null) return;
|
||||
boolean now = !conn.getPlayback().isClientMuted(client.id);
|
||||
conn.getPlayback().setClientMuted(client.id, now);
|
||||
chatPanel.appendSystem((now ? "Muted " : "Unmuted ") + client.nickname + ".");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showConnectionInfo(ClientEntry client) {
|
||||
if (!conn.isConnected()) return;
|
||||
new ConnectionInfoDialog(host, conn, client.id, client.nickname).setVisible(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void moveClientToOwnChannel(ClientEntry client) {
|
||||
if (!conn.isConnected() || client.id == conn.getSelfClientId()) return;
|
||||
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
|
||||
if (self != null) conn.moveClient(client.id, self.channelId, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setChannelSubscribed(ChannelNode channel, boolean family, boolean subscribed) {
|
||||
if (conn.isConnected()) conn.setChannelsSubscribed(channel.familyIds(family), subscribed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void browseFiles(ChannelNode channel) {
|
||||
if (!conn.canTransferFiles()) return;
|
||||
new FileBrowserDialog(host, conn, channel).setVisible(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isClientLocallyMuted(int clientId) {
|
||||
return conn.getPlayback() != null && conn.getPlayback().isClientMuted(clientId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSelectionChanged(Object userObject) {
|
||||
currentSelection = userObject;
|
||||
renderInfo();
|
||||
if (!conn.isConnected()) return;
|
||||
if (userObject instanceof ChannelNode) {
|
||||
ChannelNode ch = (ChannelNode) userObject;
|
||||
if (!ch.descriptionLoaded) conn.requestChannelInfo(ch.id);
|
||||
} else if (userObject instanceof ClientEntry) {
|
||||
conn.requestClientInfo(((ClientEntry) userObject).id);
|
||||
}
|
||||
}
|
||||
|
||||
private void renderInfo() {
|
||||
Object sel = currentSelection;
|
||||
if (sel instanceof ChannelNode) {
|
||||
infoPanel.showChannel((ChannelNode) sel);
|
||||
} else if (sel instanceof ClientEntry) {
|
||||
infoPanel.showClient((ClientEntry) sel, conn.getModel(), conn.getIcons());
|
||||
} else {
|
||||
infoPanel.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ConnectionListener (marshal to EDT) ----
|
||||
|
||||
@Override
|
||||
public void onStatus(String text) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
status = text;
|
||||
host.tabUpdated(this);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConnected() {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
connecting = false;
|
||||
treePanel.setSelfClientId(conn.getSelfClientId());
|
||||
micMuted = false;
|
||||
micLocalMuted = false;
|
||||
deafened = false;
|
||||
away = false;
|
||||
awayMessage = "";
|
||||
commander = false;
|
||||
chatPanel.setInputEnabled(true);
|
||||
chatPanel.appendSystem("Connected.");
|
||||
host.tabConnected(this);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnected(String reason) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
connecting = false;
|
||||
conn.getModel().clear();
|
||||
treePanel.showDisconnected();
|
||||
currentSelection = null;
|
||||
infoPanel.clear();
|
||||
chatPanel.setInputEnabled(false);
|
||||
chatPanel.closePrivateChats();
|
||||
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
|
||||
host.tabDisconnected(this);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onModelChanged() {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
treePanel.rebuild();
|
||||
renderInfo();
|
||||
String name = conn.getModel().getServerName();
|
||||
if (conn.isConnected() && name != null && !name.isBlank() && !name.equals(title)) {
|
||||
title = name;
|
||||
host.tabUpdated(this);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInfoUpdated() {
|
||||
SwingUtilities.invokeLater(this::renderInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIconsUpdated() {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
treePanel.refreshRowSizes();
|
||||
renderInfo();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChat(ChatScope scope, int fromClientId, String fromName, String message) {
|
||||
switch (scope) {
|
||||
case PRIVATE:
|
||||
chatPanel.appendPrivateMessage(fromClientId, fromName, fromClientId, fromName, message);
|
||||
break;
|
||||
case SERVER:
|
||||
chatPanel.appendServerMessage(fromClientId, fromName, message);
|
||||
break;
|
||||
default:
|
||||
chatPanel.appendChannelMessage(fromClientId, fromName, message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTalkStateChanged(int clientId, boolean talking) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
treePanel.refreshVisual();
|
||||
if (clientId == conn.getSelfClientId()) host.selfStateChanged(this);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(String message) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
chatPanel.appendSystem("Error: " + message);
|
||||
status = message;
|
||||
host.tabUpdated(this);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPoke(String fromName, String message) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
chatPanel.appendSystem("You were poked by " + fromName + ": " + message);
|
||||
host.selectTab(this);
|
||||
JOptionPane.showMessageDialog(host, fromName + " poked you:\n\n" + message,
|
||||
"Poke", JOptionPane.INFORMATION_MESSAGE);
|
||||
});
|
||||
leftColumn.revalidate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ConnectionListener;
|
||||
import com.ts3client.net.TeamspeakConnection;
|
||||
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.SwingUtilities;
|
||||
|
||||
/**
|
||||
* Marshals {@link ConnectionListener} callbacks onto the EDT and fans them out to
|
||||
* this tab's views (tree, chat, info panel) and to {@link MainFrame}.
|
||||
*
|
||||
* <p>Constructed before the {@link TeamspeakConnection} it listens to exists — the
|
||||
* connection's constructor needs a listener up front — so {@link #attach} wires the
|
||||
* rest of the tab in once everything else has been built.
|
||||
*/
|
||||
final class ServerTabConnectionEvents implements ConnectionListener {
|
||||
|
||||
/** The handful of {@link ServerTab} fields this class updates but can't reach directly. */
|
||||
interface Listener {
|
||||
void setStatus(String status);
|
||||
|
||||
void setTitle(String title);
|
||||
|
||||
void setConnecting(boolean connecting);
|
||||
}
|
||||
|
||||
private final MainFrame host;
|
||||
private final ServerTab tab;
|
||||
private final Listener listener;
|
||||
|
||||
private TeamspeakConnection conn;
|
||||
private ServerTreePanel treePanel;
|
||||
private ChatPanel chatPanel;
|
||||
private ServerTabSelfState selfState;
|
||||
private ServerTabTreeActions treeActions;
|
||||
|
||||
ServerTabConnectionEvents(MainFrame host, ServerTab tab, Listener listener) {
|
||||
this.host = host;
|
||||
this.tab = tab;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
void attach(TeamspeakConnection conn, ServerTreePanel treePanel, ChatPanel chatPanel,
|
||||
ServerTabSelfState selfState, ServerTabTreeActions treeActions) {
|
||||
this.conn = conn;
|
||||
this.treePanel = treePanel;
|
||||
this.chatPanel = chatPanel;
|
||||
this.selfState = selfState;
|
||||
this.treeActions = treeActions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStatus(String text) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
listener.setStatus(text);
|
||||
host.tabUpdated(tab);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConnected() {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
listener.setConnecting(false);
|
||||
treePanel.setSelfClientId(conn.getSelfClientId());
|
||||
selfState.resetOnConnect();
|
||||
chatPanel.setInputEnabled(true);
|
||||
chatPanel.appendSystem("Connected.");
|
||||
host.tabConnected(tab);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnected(String reason) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
listener.setConnecting(false);
|
||||
conn.getModel().clear();
|
||||
treePanel.showDisconnected();
|
||||
treeActions.clearSelection();
|
||||
chatPanel.setInputEnabled(false);
|
||||
chatPanel.closePrivateChats();
|
||||
chatPanel.closeNoteTabs();
|
||||
chatPanel.appendSystem("Disconnected" + (reason == null || reason.isEmpty() ? "." : ": " + reason));
|
||||
host.tabDisconnected(tab);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onModelChanged() {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
treePanel.rebuild();
|
||||
treeActions.renderInfo();
|
||||
treeActions.refreshGroupsDialog();
|
||||
String name = conn.getModel().getServerName();
|
||||
if (conn.isConnected() && name != null && !name.isBlank() && !name.equals(tab.title())) {
|
||||
listener.setTitle(name);
|
||||
host.tabUpdated(tab);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInfoUpdated() {
|
||||
SwingUtilities.invokeLater(treeActions::renderInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onIconsUpdated() {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
treePanel.refreshRowSizes();
|
||||
treeActions.renderInfo();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChat(ChatScope scope, int fromClientId, String fromName, String message) {
|
||||
switch (scope) {
|
||||
case PRIVATE:
|
||||
chatPanel.appendPrivateMessage(fromClientId, fromName, fromClientId, fromName, message);
|
||||
break;
|
||||
case SERVER:
|
||||
chatPanel.appendServerMessage(fromClientId, fromName, message);
|
||||
break;
|
||||
default:
|
||||
chatPanel.appendChannelMessage(fromClientId, fromName, message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTalkStateChanged(int clientId, boolean talking) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
treePanel.refreshVisual();
|
||||
if (clientId == conn.getSelfClientId()) host.selfStateChanged(tab);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(String message) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
chatPanel.appendSystem("Error: " + message);
|
||||
listener.setStatus(message);
|
||||
host.tabUpdated(tab);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerLog(String message) {
|
||||
SwingUtilities.invokeLater(() -> chatPanel.appendServerLog(message));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPoke(String fromName, String message) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
chatPanel.appendSystem("You were poked by " + fromName + ": " + message);
|
||||
host.selectTab(tab);
|
||||
JOptionPane.showMessageDialog(host, fromName + " poked you:\n\n" + message,
|
||||
"Poke", JOptionPane.INFORMATION_MESSAGE);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,14 @@ final class ServerTabPane extends JPanel {
|
||||
private final Listener listener;
|
||||
private final JTabbedPane tabbed = new JTabbedPane();
|
||||
private final List<ServerTab> tabs = new ArrayList<>();
|
||||
private final TabDragReorder dragReorder = new TabDragReorder(tabbed, this::moveTab);
|
||||
|
||||
/** True while the tab strip is in use, i.e. more than one connection is open. */
|
||||
private boolean tabbedMode;
|
||||
/** Suppresses selection callbacks while we rearrange the pane ourselves. */
|
||||
private boolean updating;
|
||||
/** Remembered so a drag-reorder can rebuild the tab labels without the caller's help. */
|
||||
private ServerTab lastMicTab;
|
||||
|
||||
ServerTabPane(Listener listener) {
|
||||
super(new BorderLayout());
|
||||
@@ -51,6 +54,24 @@ final class ServerTabPane extends JPanel {
|
||||
if (i >= 0 && i < tabs.size()) listener.selectTab(tabs.get(i));
|
||||
});
|
||||
tabbed.addMouseWheelListener(this::onWheel);
|
||||
dragReorder.attach(tabbed);
|
||||
}
|
||||
|
||||
/** Drags a tab from one position to another, keeping the current selection on screen. */
|
||||
private void moveTab(int from, int to) {
|
||||
if (from < 0 || to < 0 || from >= tabs.size() || to >= tabs.size() || from == to) return;
|
||||
ServerTab selected = tabbed.getSelectedIndex() >= 0 && tabbed.getSelectedIndex() < tabs.size()
|
||||
? tabs.get(tabbed.getSelectedIndex()) : null;
|
||||
tabs.add(to, tabs.remove(from));
|
||||
updating = true;
|
||||
try {
|
||||
tabbed.removeAll();
|
||||
for (ServerTab tab : tabs) tabbed.addTab(tab.title(), tab.component());
|
||||
if (selected != null) tabbed.setSelectedIndex(tabs.indexOf(selected));
|
||||
} finally {
|
||||
updating = false;
|
||||
}
|
||||
refresh(lastMicTab);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +114,7 @@ final class ServerTabPane extends JPanel {
|
||||
|
||||
/** Refreshes the tab labels; {@code micTab} is marked as owning the microphone. */
|
||||
void refresh(ServerTab micTab) {
|
||||
lastMicTab = micTab;
|
||||
if (!tabbedMode) return;
|
||||
for (int i = 0; i < tabs.size(); i++) {
|
||||
ServerTab tab = tabs.get(i);
|
||||
@@ -133,7 +155,7 @@ final class ServerTabPane extends JPanel {
|
||||
cell.setBorder(BorderFactory.createEmptyBorder(1, 0, 1, 0));
|
||||
|
||||
JLabel label = new JLabel(tab.title(), hasMic ? Icons.micActiveSmall() : null, JLabel.LEADING);
|
||||
label.setFont(Theme.UI_FONT);
|
||||
label.setFont(Theme.uiFont());
|
||||
label.setToolTipText(hasMic ? "Speaking on this server" : tab.status());
|
||||
// A label with a tooltip swallows mouse events, so select the tab explicitly.
|
||||
label.addMouseListener(new MouseAdapter() {
|
||||
@@ -143,14 +165,16 @@ final class ServerTabPane extends JPanel {
|
||||
}
|
||||
});
|
||||
cell.add(label);
|
||||
dragReorder.attach(cell);
|
||||
dragReorder.attach(label);
|
||||
|
||||
JButton close = new JButton("✕");
|
||||
close.setFont(Theme.UI_FONT);
|
||||
close.setFont(Theme.uiFont());
|
||||
close.setFocusable(false);
|
||||
close.setBorder(BorderFactory.createEmptyBorder());
|
||||
close.setContentAreaFilled(false);
|
||||
close.setMargin(new Insets(0, 0, 0, 0));
|
||||
close.setForeground(Theme.CHAT_SYSTEM);
|
||||
close.setForeground(Theme.chatSystem());
|
||||
close.setPreferredSize(new Dimension(14, 14));
|
||||
close.setToolTipText("Close this connection");
|
||||
close.addActionListener(e -> listener.closeTab(tab));
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ClientEntry;
|
||||
import com.ts3client.net.TeamspeakConnection;
|
||||
|
||||
/**
|
||||
* Mic/speaker/away/commander flags for one connection, plus forwarding them into
|
||||
* {@link TeamspeakConnection}. Split out of {@link ServerTab} because the flags
|
||||
* are reset together in one place (on (re)connect) and read from several
|
||||
* (tray icon, toolbar, menu checkmarks).
|
||||
*/
|
||||
final class ServerTabSelfState {
|
||||
|
||||
private final TeamspeakConnection conn;
|
||||
|
||||
private boolean micMuted;
|
||||
private boolean micLocalMuted;
|
||||
private boolean deafened;
|
||||
private boolean away;
|
||||
/** Away message currently published, empty when away carries no message. */
|
||||
private String awayMessage = "";
|
||||
private boolean commander;
|
||||
|
||||
ServerTabSelfState(TeamspeakConnection conn) {
|
||||
this.conn = conn;
|
||||
}
|
||||
|
||||
boolean isMicMuted() {
|
||||
return micMuted;
|
||||
}
|
||||
|
||||
boolean isMicLocalMuted() {
|
||||
return micLocalMuted;
|
||||
}
|
||||
|
||||
boolean isDeafened() {
|
||||
return deafened;
|
||||
}
|
||||
|
||||
boolean isAway() {
|
||||
return away;
|
||||
}
|
||||
|
||||
String awayMessage() {
|
||||
return awayMessage;
|
||||
}
|
||||
|
||||
boolean isCommander() {
|
||||
return commander;
|
||||
}
|
||||
|
||||
void setMicMuted(boolean muted) {
|
||||
micMuted = muted;
|
||||
conn.setMicMuted(muted);
|
||||
}
|
||||
|
||||
/** TS3's "Local Mic Mute": silences capture without publishing a status change. */
|
||||
void setMicLocalMuted(boolean muted) {
|
||||
micLocalMuted = muted;
|
||||
conn.setMicLocalMuted(muted);
|
||||
}
|
||||
|
||||
void setDeafened(boolean deaf) {
|
||||
deafened = deaf;
|
||||
conn.setDeafened(deaf);
|
||||
if (deaf) micMuted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param message the away message, or null to keep the one already set — the
|
||||
* message outlives coming back, so toggling away again restores it
|
||||
*/
|
||||
void setAway(boolean away, String message) {
|
||||
this.away = away;
|
||||
if (message != null) this.awayMessage = message;
|
||||
conn.setAway(away, awayMessage);
|
||||
}
|
||||
|
||||
void setCommander(boolean commander) {
|
||||
this.commander = commander;
|
||||
conn.setChannelCommander(commander);
|
||||
}
|
||||
|
||||
/** Clears all flags on a fresh connection; the server starts us out clean, so nothing to publish. */
|
||||
void resetOnConnect() {
|
||||
micMuted = false;
|
||||
micLocalMuted = false;
|
||||
deafened = false;
|
||||
away = false;
|
||||
awayMessage = "";
|
||||
commander = false;
|
||||
}
|
||||
|
||||
/** What the local client looks like on this server, for the tray icon. */
|
||||
SelfState compute() {
|
||||
if (!conn.isConnected()) return SelfState.DISCONNECTED;
|
||||
if (deafened) return SelfState.DEAFENED;
|
||||
if (micMuted) return SelfState.MIC_MUTED;
|
||||
if (micLocalMuted) return SelfState.MIC_LOCAL_MUTED;
|
||||
if (away) return SelfState.AWAY;
|
||||
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
|
||||
boolean talking = self != null && self.talking;
|
||||
if (commander) return talking ? SelfState.COMMANDER_TALKING : SelfState.COMMANDER;
|
||||
return talking ? SelfState.TALKING : SelfState.IDLE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ChannelSettings;
|
||||
import com.ts3client.net.ClientEntry;
|
||||
import com.ts3client.net.Group;
|
||||
import com.ts3client.net.TeamspeakConnection;
|
||||
import com.ts3client.text.TsLink;
|
||||
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.Component;
|
||||
|
||||
/**
|
||||
* Context-menu and drag/drop actions for one server's tree, plus tracking which
|
||||
* node is selected so the info panel stays in sync. Also handles client/channel
|
||||
* links clicked in the chat log, which open the same menus as the tree does.
|
||||
*
|
||||
* <p>Constructed before the {@link ServerTreePanel} it drives exists — the tree's
|
||||
* constructor needs an {@link ServerTreePanel.Actions} up front — so {@link #attach}
|
||||
* wires the tree back in once it has been built.
|
||||
*/
|
||||
final class ServerTabTreeActions implements ServerTreePanel.Actions {
|
||||
|
||||
private final MainFrame host;
|
||||
private final ServerTab tab;
|
||||
private final TeamspeakConnection conn;
|
||||
private final ChatPanel chatPanel;
|
||||
private final InfoPanel infoPanel;
|
||||
private final GroupIcons groupIcons;
|
||||
private ServerTreePanel treePanel;
|
||||
|
||||
private Object currentSelection;
|
||||
/** The one open {@link ServerGroupsDialog}, if any, refreshed whenever the model changes. */
|
||||
private ServerGroupsDialog groupsDialog;
|
||||
|
||||
ServerTabTreeActions(MainFrame host, ServerTab tab, TeamspeakConnection conn,
|
||||
ChatPanel chatPanel, InfoPanel infoPanel, GroupIcons groupIcons) {
|
||||
this.host = host;
|
||||
this.tab = tab;
|
||||
this.conn = conn;
|
||||
this.chatPanel = chatPanel;
|
||||
this.infoPanel = infoPanel;
|
||||
this.groupIcons = groupIcons;
|
||||
}
|
||||
|
||||
void attach(ServerTreePanel treePanel) {
|
||||
this.treePanel = treePanel;
|
||||
}
|
||||
|
||||
/** Called by {@link ServerTabConnectionEvents} whenever the model changes, to live-refresh an open dialog. */
|
||||
void refreshGroupsDialog() {
|
||||
if (groupsDialog != null) groupsDialog.refresh();
|
||||
}
|
||||
|
||||
/** Refreshes the info panel for whatever is currently selected (or clears it). */
|
||||
void renderInfo() {
|
||||
Object sel = currentSelection;
|
||||
if (sel instanceof ChannelNode) {
|
||||
infoPanel.showChannel((ChannelNode) sel);
|
||||
} else if (sel instanceof ClientEntry) {
|
||||
infoPanel.showClient((ClientEntry) sel, conn.getModel(), conn.getIcons());
|
||||
} else {
|
||||
infoPanel.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops the selection on disconnect, since the model it points into is gone. */
|
||||
void clearSelection() {
|
||||
currentSelection = null;
|
||||
infoPanel.clear();
|
||||
}
|
||||
|
||||
/** A client link in the chat log was clicked: show the same menu as the tree does. */
|
||||
void handleClientLink(TsLink.Ref ref, Component source, int x, int y) {
|
||||
ClientEntry client = conn.getModel().getClient(ref.id);
|
||||
// The id is only valid for the session the link was made in; fall back to
|
||||
// the unique id (and finally the nickname) so older links still resolve.
|
||||
if (client == null || (!ref.uniqueId.isEmpty() && !ref.uniqueId.equals(client.uniqueId))) {
|
||||
ClientEntry byUid = ref.uniqueId.isEmpty() ? null
|
||||
: conn.getModel().findClientByUniqueId(ref.uniqueId);
|
||||
if (byUid == null && !ref.name.isEmpty()) byUid = conn.getModel().findClientByName(ref.name);
|
||||
if (byUid != null) client = byUid;
|
||||
}
|
||||
if (client == null) {
|
||||
chatPanel.appendSystem("That client is no longer on the server.");
|
||||
return;
|
||||
}
|
||||
ClientMenu.build(client, client.id == conn.getSelfClientId(), false, conn.getModel(), groupIcons, this)
|
||||
.show(source, x, y);
|
||||
}
|
||||
|
||||
void handleChannelLink(TsLink.Ref ref, Component source, int x, int y) {
|
||||
ChannelNode channel = conn.getModel().getChannel(ref.id);
|
||||
if (channel == null) {
|
||||
chatPanel.appendSystem("That channel no longer exists.");
|
||||
return;
|
||||
}
|
||||
ChannelMenu.build(channel, this).show(source, x, y);
|
||||
}
|
||||
|
||||
// ---- ServerTreePanel.Actions ----
|
||||
|
||||
@Override
|
||||
public void joinChannel(int channelId) {
|
||||
if (conn.isConnected()) conn.joinChannel(channelId, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void moveClientToChannel(ClientEntry client, ChannelNode target) {
|
||||
if (!conn.isConnected()) return;
|
||||
if (client.id == conn.getSelfClientId()) {
|
||||
conn.joinChannel(target.id, null);
|
||||
} else {
|
||||
conn.moveClient(client.id, target.id, null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void moveChannel(ChannelNode channel, int newParentId, int orderPredecessorId) {
|
||||
if (conn.isConnected()) conn.moveChannel(channel.id, newParentId, orderPredecessorId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void openPrivateChat(ClientEntry client) {
|
||||
chatPanel.openPrivateChat(client.id, client.nickname);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pokeClient(ClientEntry client) {
|
||||
String msg = JOptionPane.showInputDialog(host, "Poke message for " + client.nickname + ":", "Poke!");
|
||||
if (msg != null) conn.poke(client.id, msg);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void kickClientFromChannel(ClientEntry client) {
|
||||
String reason = kickReason("Kick Client from Channel", client);
|
||||
if (reason != null) conn.kickFromChannel(client.id, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void kickClientFromServer(ClientEntry client) {
|
||||
String reason = kickReason("Kick Client from Server", client);
|
||||
if (reason != null) conn.kickFromServer(client.id, reason);
|
||||
}
|
||||
|
||||
private String kickReason(String title, ClientEntry client) {
|
||||
if (!conn.isConnected()) return null;
|
||||
return ReasonDialog.prompt(host, title, "Reason for kicking " + client.nickname + ":",
|
||||
ReasonDialog.KICK_REASON_LIMIT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void banClient(ClientEntry client) {
|
||||
if (!conn.isConnected()) return;
|
||||
BanDialog dialog = new BanDialog(host, client.nickname);
|
||||
dialog.setVisible(true);
|
||||
if (dialog.isConfirmed()) conn.banClient(client.id, dialog.getSeconds(), dialog.getReason());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggleClientMute(ClientEntry client) {
|
||||
if (conn.getPlayback() == null) return;
|
||||
boolean now = !conn.getPlayback().isClientMuted(client.id);
|
||||
conn.getPlayback().setClientMuted(client.id, now);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void findClientInTree(ClientEntry client) {
|
||||
host.selectTab(tab);
|
||||
treePanel.selectClient(client.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showConnectionInfo(ClientEntry client) {
|
||||
if (!conn.isConnected()) return;
|
||||
new ConnectionInfoDialog(host, conn, client.id, client.nickname).setVisible(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void moveClientToOwnChannel(ClientEntry client) {
|
||||
if (!conn.isConnected() || client.id == conn.getSelfClientId()) return;
|
||||
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
|
||||
if (self != null) conn.moveClient(client.id, self.channelId, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setClientServerGroup(ClientEntry client, Group group, boolean assign) {
|
||||
if (conn.isConnected()) conn.setClientServerGroup(client.databaseId, group.id, assign);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setClientChannelGroup(ClientEntry client, Group group) {
|
||||
if (conn.isConnected()) conn.setClientChannelGroup(client.databaseId, client.channelId, group.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showServerGroupsDialog(ClientEntry client) {
|
||||
if (!conn.isConnected()) return;
|
||||
groupsDialog = new ServerGroupsDialog(host, conn, groupIcons, client, this);
|
||||
groupsDialog.setVisible(true);
|
||||
groupsDialog = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the description from the server before prompting, since the model only
|
||||
* carries one for clients whose info has been looked at.
|
||||
*/
|
||||
@Override
|
||||
public void changeClientDescription(ClientEntry client) {
|
||||
if (!conn.isConnected()) return;
|
||||
conn.requestClientDescription(client.id, (description, error) -> SwingUtilities.invokeLater(() -> {
|
||||
if (error != null) {
|
||||
JOptionPane.showMessageDialog(host, "Could not read the description: " + error,
|
||||
"Error", JOptionPane.ERROR_MESSAGE);
|
||||
return;
|
||||
}
|
||||
String edited = (String) JOptionPane.showInputDialog(host,
|
||||
"Description for " + client.nickname + ":", "Change Description",
|
||||
JOptionPane.PLAIN_MESSAGE, Icons.of("EDIT"), null, description);
|
||||
if (edited == null || edited.equals(description)) return;
|
||||
conn.setClientDescription(client.id, edited, failure -> SwingUtilities.invokeLater(() -> {
|
||||
if (failure != null) {
|
||||
JOptionPane.showMessageDialog(host, "Could not change the description: " + failure,
|
||||
"Error", JOptionPane.ERROR_MESSAGE);
|
||||
}
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void renameSelf(String nickname) {
|
||||
host.changeNickname(nickname);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setChannelSubscribed(ChannelNode channel, boolean family, boolean subscribed) {
|
||||
if (conn.isConnected()) conn.setChannelsSubscribed(channel.familyIds(family), subscribed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void editChannel(ChannelNode channel) {
|
||||
if (!conn.isConnected()) return;
|
||||
ChannelDialog.toEdit(host, conn, groupIcons, channel).setVisible(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void createChannel(ChannelNode parent, ChannelSettings.Type type) {
|
||||
if (!conn.isConnected()) return;
|
||||
ChannelSettings defaults = new ChannelSettings();
|
||||
defaults.type = type;
|
||||
ChannelDialog.toCreate(host, conn, groupIcons,
|
||||
parent == null ? "Create Channel" : "Create Sub-Channel of " + parent.name,
|
||||
parent == null ? 0 : parent.id, defaults).setVisible(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void createSpacer() {
|
||||
if (!conn.isConnected()) return;
|
||||
ChannelSettings defaults = new ChannelSettings();
|
||||
// Spacers are cosmetic root channels, so they outlive everyone and, being channels,
|
||||
// need a name no other channel has — which is what the tag's number is for.
|
||||
defaults.name = Spacers.freeName(conn.getModel().childrenOf(0));
|
||||
ChannelDialog.toCreate(host, conn, groupIcons, "Create Spacer", 0, defaults)
|
||||
.setVisible(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isConnected() {
|
||||
return conn.isConnected();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void browseFiles(ChannelNode channel) {
|
||||
if (!conn.canTransferFiles()) return;
|
||||
new FileBrowserDialog(host, conn, channel).setVisible(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isClientLocallyMuted(int clientId) {
|
||||
return conn.getPlayback() != null && conn.getPlayback().isClientMuted(clientId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSelectionChanged(Object userObject) {
|
||||
currentSelection = userObject;
|
||||
renderInfo();
|
||||
if (!conn.isConnected()) return;
|
||||
if (userObject instanceof ChannelNode) {
|
||||
ChannelNode ch = (ChannelNode) userObject;
|
||||
if (!ch.descriptionLoaded) conn.requestChannelInfo(ch.id);
|
||||
} else if (userObject instanceof ClientEntry) {
|
||||
conn.requestClientInfo(((ClientEntry) userObject).id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ClientEntry;
|
||||
|
||||
import javax.swing.ImageIcon;
|
||||
import javax.swing.JTree;
|
||||
import javax.swing.tree.DefaultMutableTreeNode;
|
||||
import javax.swing.tree.DefaultTreeCellRenderer;
|
||||
import java.awt.Component;
|
||||
import java.util.function.IntPredicate;
|
||||
|
||||
/**
|
||||
* Draws a {@link ServerTreePanel} row: the status icon and the label. The group
|
||||
* icon strip is painted separately, right-aligned, by {@link DropIndicatorTree}.
|
||||
*/
|
||||
final class ServerTreeCellRenderer extends DefaultTreeCellRenderer {
|
||||
|
||||
/** Whether a client id is our own, which the panel learns on connecting. */
|
||||
private final IntPredicate isSelf;
|
||||
|
||||
ServerTreeCellRenderer(IntPredicate isSelf) {
|
||||
this.isSelf = isSelf;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel,
|
||||
boolean expanded, boolean leaf, int row,
|
||||
boolean hasFocus) {
|
||||
super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus);
|
||||
setBackgroundNonSelectionColor(Theme.treeBg());
|
||||
setBackgroundSelectionColor(Theme.treeSelection());
|
||||
setBorderSelectionColor(Theme.treeSelection());
|
||||
|
||||
Object obj = ((DefaultMutableTreeNode) value).getUserObject();
|
||||
if (obj instanceof ChannelNode) {
|
||||
ChannelNode c = (ChannelNode) obj;
|
||||
Spacers.Spacer spacer = Spacers.parse(c.name);
|
||||
if (spacer != null) {
|
||||
setText(Spacers.render(spacer, 40));
|
||||
setIcon(null);
|
||||
setForeground(Theme.idleClient());
|
||||
setFont(Theme.uiFont());
|
||||
} else {
|
||||
setText(c.name);
|
||||
setIcon(iconFor(c));
|
||||
setForeground(Theme.channelText());
|
||||
setFont(Theme.uiBold());
|
||||
}
|
||||
} else if (obj instanceof ClientEntry) {
|
||||
ClientEntry cl = (ClientEntry) obj;
|
||||
String label = cl.nickname;
|
||||
if (cl.away && !cl.awayMessage.isEmpty()) label += " [" + cl.awayMessage + "]";
|
||||
setText(label);
|
||||
setIcon(iconFor(cl));
|
||||
// Talking shows in the client's icon, as it does in TeamSpeak. Marking the
|
||||
// name as well would also re-measure the row mid-speech, which leaves the
|
||||
// nickname clipped to an ellipsis for as long as it lasts. Our own name is
|
||||
// bold throughout, so its width never changes either.
|
||||
setForeground(Theme.treeText());
|
||||
setFont(isSelf.test(cl.id) ? Theme.uiBold() : Theme.uiFont());
|
||||
} else {
|
||||
// root / server
|
||||
setText(String.valueOf(obj));
|
||||
setIcon(Icons.server());
|
||||
setForeground(Theme.serverText());
|
||||
setFont(Theme.uiBold());
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
private ImageIcon iconFor(ChannelNode c) {
|
||||
if (c.hasPassword) return Icons.channelLocked(c.subscribed);
|
||||
if (c.maxClients >= 0 && c.clients.size() >= c.maxClients) return Icons.channelFull(c.subscribed);
|
||||
return Icons.channel(c.subscribed);
|
||||
}
|
||||
|
||||
/** The client's state, in the order the official client gives them priority. */
|
||||
private ImageIcon iconFor(ClientEntry cl) {
|
||||
if (cl.isQuery()) return Icons.clientQuery();
|
||||
if (!cl.outputHardware) return Icons.speakerDisabled();
|
||||
if (cl.outputMuted) return Icons.speakerMuted();
|
||||
if (!cl.inputHardware) return Icons.micDisabled();
|
||||
if (cl.inputMuted) return Icons.micMuted();
|
||||
if (cl.away) return Icons.clientAway();
|
||||
if (cl.channelCommander) {
|
||||
return cl.talking ? Icons.clientCommanderTalking() : Icons.clientCommander();
|
||||
}
|
||||
if (cl.talking) return Icons.clientTalking();
|
||||
return Icons.clientIdle();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ClientEntry;
|
||||
import com.ts3client.net.ServerModel;
|
||||
import com.ts3client.text.TsLink;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JTree;
|
||||
import javax.swing.TransferHandler;
|
||||
import javax.swing.tree.DefaultMutableTreeNode;
|
||||
import javax.swing.tree.TreePath;
|
||||
import java.awt.datatransfer.DataFlavor;
|
||||
import java.awt.datatransfer.Transferable;
|
||||
import java.awt.datatransfer.UnsupportedFlavorException;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Drag-and-drop for the {@link ServerTreePanel} tree: dragging a client onto a
|
||||
* channel moves it there, dragging a channel reorders/reparents it, and dropping
|
||||
* either outside the tree yields its TS3 link BBCode, which the chat input accepts
|
||||
* as plain text.
|
||||
*/
|
||||
final class ServerTreeDragAndDrop {
|
||||
|
||||
/** Carries the dragged node inside this JVM; drops elsewhere get the BBCode text. */
|
||||
private static final DataFlavor NODE_FLAVOR = new DataFlavor(
|
||||
DataFlavor.javaJVMLocalObjectMimeType + ";class=java.lang.Object",
|
||||
"TeamSpeak tree node");
|
||||
|
||||
private final ServerModel model;
|
||||
private final ServerTreePanel.Actions actions;
|
||||
private final DropIndicatorTree tree;
|
||||
/** Looks up the tree path currently showing a given channel/client, or {@code null}. */
|
||||
private final Function<Object, TreePath> pathOf;
|
||||
|
||||
ServerTreeDragAndDrop(ServerModel model, ServerTreePanel.Actions actions, DropIndicatorTree tree,
|
||||
Function<Object, TreePath> pathOf) {
|
||||
this.model = model;
|
||||
this.actions = actions;
|
||||
this.tree = tree;
|
||||
this.pathOf = pathOf;
|
||||
}
|
||||
|
||||
TransferHandler transferHandler() {
|
||||
return new TreeTransferHandler();
|
||||
}
|
||||
|
||||
private static DefaultMutableTreeNode nodeOf(TreePath path) {
|
||||
return path == null ? null : (DefaultMutableTreeNode) path.getLastPathComponent();
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel a dragged client would land in, or {@code null} if the drop makes
|
||||
* no sense (outside a channel, or the channel the client is already in).
|
||||
*/
|
||||
private ChannelNode resolveClientDrop(JTree.DropLocation loc, ClientEntry dragged) {
|
||||
DefaultMutableTreeNode target = nodeOf(loc.getPath());
|
||||
if (target == null) return null;
|
||||
Object obj = target.getUserObject();
|
||||
ChannelNode channel = null;
|
||||
if (obj instanceof ChannelNode) {
|
||||
channel = (ChannelNode) obj;
|
||||
} else if (obj instanceof ClientEntry) {
|
||||
channel = model.getChannel(((ClientEntry) obj).channelId);
|
||||
}
|
||||
if (channel == null || Spacers.isSpacer(channel.name)) return null;
|
||||
if (channel.id == dragged.channelId) return null;
|
||||
return channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* The new parent and predecessor for a dragged channel as {@code {cpid, order}},
|
||||
* or {@code null} if this drop is not a legal (or meaningful) move.
|
||||
*/
|
||||
private int[] resolveChannelDrop(JTree.DropLocation loc, ChannelNode dragged) {
|
||||
DefaultMutableTreeNode target = nodeOf(loc.getPath());
|
||||
if (target == null) return null;
|
||||
|
||||
DefaultMutableTreeNode parentNode;
|
||||
int insertIndex;
|
||||
if (loc.getChildIndex() >= 0) {
|
||||
parentNode = target;
|
||||
insertIndex = loc.getChildIndex();
|
||||
} else {
|
||||
// Dropped onto a node: become its last subchannel.
|
||||
parentNode = target.getUserObject() instanceof ClientEntry
|
||||
? (DefaultMutableTreeNode) target.getParent() : target;
|
||||
if (parentNode == null) return null;
|
||||
insertIndex = parentNode.getChildCount();
|
||||
}
|
||||
|
||||
int parentId = 0;
|
||||
Object parentObj = parentNode.getUserObject();
|
||||
if (parentObj instanceof ChannelNode) {
|
||||
ChannelNode parent = (ChannelNode) parentObj;
|
||||
if (Spacers.isSpacer(parent.name)) return null;
|
||||
if (isSelfOrDescendant(parent, dragged)) return null; // would detach the subtree
|
||||
parentId = parent.id;
|
||||
} else if (parentNode.getParent() != null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int predecessorId = 0;
|
||||
for (int i = 0; i < insertIndex && i < parentNode.getChildCount(); i++) {
|
||||
Object o = ((DefaultMutableTreeNode) parentNode.getChildAt(i)).getUserObject();
|
||||
if (o instanceof ChannelNode && ((ChannelNode) o).id != dragged.id) {
|
||||
predecessorId = ((ChannelNode) o).id;
|
||||
}
|
||||
}
|
||||
if (parentId == dragged.parentId && predecessorId == dragged.order) return null; // no-op
|
||||
return new int[]{parentId, predecessorId};
|
||||
}
|
||||
|
||||
/** Whether {@code candidate} is {@code ancestor} itself or sits below it. */
|
||||
private boolean isSelfOrDescendant(ChannelNode candidate, ChannelNode ancestor) {
|
||||
ChannelNode c = candidate;
|
||||
for (int guard = 0; c != null && guard < 64; guard++) {
|
||||
if (c.id == ancestor.id) return true;
|
||||
c = model.getChannel(c.parentId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private final class TreeTransferHandler extends TransferHandler {
|
||||
@Override
|
||||
public int getSourceActions(JComponent c) {
|
||||
return COPY | MOVE;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Transferable createTransferable(JComponent c) {
|
||||
TreePath path = tree.getSelectionPath();
|
||||
if (path == null) return null;
|
||||
Object obj = nodeOf(path).getUserObject();
|
||||
if (obj instanceof ClientEntry) {
|
||||
ClientEntry cl = (ClientEntry) obj;
|
||||
return new NodeTransferable(cl, TsLink.clientBBCode(cl.id, cl.uniqueId, cl.nickname));
|
||||
}
|
||||
if (obj instanceof ChannelNode) {
|
||||
ChannelNode ch = (ChannelNode) obj;
|
||||
// Spacers have no meaningful link text, but can still be re-ordered.
|
||||
return new NodeTransferable(ch, Spacers.isSpacer(ch.name)
|
||||
? null : TsLink.channelBBCode(ch.id, ch.name));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canImport(TransferSupport support) {
|
||||
if (!support.isDrop() || !support.isDataFlavorSupported(NODE_FLAVOR)) {
|
||||
tree.highlightChannel(null);
|
||||
return false;
|
||||
}
|
||||
if ((support.getSourceDropActions() & MOVE) == MOVE) support.setDropAction(MOVE);
|
||||
Object resolved = resolve(support);
|
||||
// A client always lands *inside* a channel, so mark that channel instead of
|
||||
// drawing a line that would suggest a position among its clients.
|
||||
tree.highlightChannel(resolved instanceof ChannelNode
|
||||
? pathOf.apply(resolved) : null);
|
||||
// The indicator spans the full width, past the row rectangles Swing
|
||||
// repaints on its own when the drop location moves.
|
||||
tree.repaint();
|
||||
return resolved != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void exportDone(JComponent source, Transferable data, int action) {
|
||||
tree.highlightChannel(null);
|
||||
tree.repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean importData(TransferSupport support) {
|
||||
if (!canImport(support)) return false;
|
||||
Object dragged = draggedNode(support);
|
||||
Object resolved = resolve(support);
|
||||
tree.highlightChannel(null);
|
||||
if (dragged instanceof ClientEntry) {
|
||||
actions.moveClientToChannel((ClientEntry) dragged, (ChannelNode) resolved);
|
||||
} else if (dragged instanceof ChannelNode) {
|
||||
int[] place = (int[]) resolved;
|
||||
actions.moveChannel((ChannelNode) dragged, place[0], place[1]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The destination for this drop: a ChannelNode, an {@code {cpid, order}} pair, or null. */
|
||||
private Object resolve(TransferSupport support) {
|
||||
Object dragged = draggedNode(support);
|
||||
if (!(support.getDropLocation() instanceof JTree.DropLocation)) return null;
|
||||
JTree.DropLocation loc = (JTree.DropLocation) support.getDropLocation();
|
||||
if (dragged instanceof ClientEntry) return resolveClientDrop(loc, (ClientEntry) dragged);
|
||||
if (dragged instanceof ChannelNode) return resolveChannelDrop(loc, (ChannelNode) dragged);
|
||||
return null;
|
||||
}
|
||||
|
||||
private Object draggedNode(TransferSupport support) {
|
||||
try {
|
||||
return support.getTransferable().getTransferData(NODE_FLAVOR);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Offers the dragged node locally and its TS3 link BBCode to other applications. */
|
||||
private static final class NodeTransferable implements Transferable {
|
||||
private final Object node;
|
||||
private final String text;
|
||||
|
||||
NodeTransferable(Object node, String text) {
|
||||
this.node = node;
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataFlavor[] getTransferDataFlavors() {
|
||||
return text == null ? new DataFlavor[]{NODE_FLAVOR}
|
||||
: new DataFlavor[]{NODE_FLAVOR, DataFlavor.stringFlavor};
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isDataFlavorSupported(DataFlavor flavor) {
|
||||
return NODE_FLAVOR.equals(flavor) || (text != null && DataFlavor.stringFlavor.equals(flavor));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException {
|
||||
if (NODE_FLAVOR.equals(flavor)) return node;
|
||||
if (text != null && DataFlavor.stringFlavor.equals(flavor)) return text;
|
||||
throw new UnsupportedFlavorException(flavor);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,20 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.ChannelSettings;
|
||||
import com.ts3client.net.ClientEntry;
|
||||
import com.ts3client.net.Group;
|
||||
import com.ts3client.net.ServerModel;
|
||||
import com.ts3client.text.TsLink;
|
||||
|
||||
import javax.swing.DropMode;
|
||||
import javax.swing.Icon;
|
||||
import javax.swing.ImageIcon;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTree;
|
||||
import javax.swing.JViewport;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.TransferHandler;
|
||||
import javax.swing.plaf.basic.BasicTreeUI;
|
||||
import javax.swing.tree.DefaultMutableTreeNode;
|
||||
import javax.swing.tree.DefaultTreeModel;
|
||||
import javax.swing.tree.DefaultTreeCellRenderer;
|
||||
import javax.swing.tree.TreeModel;
|
||||
import javax.swing.tree.TreePath;
|
||||
import java.awt.BasicStroke;
|
||||
import java.awt.Component;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.datatransfer.DataFlavor;
|
||||
import java.awt.datatransfer.Transferable;
|
||||
import java.awt.datatransfer.UnsupportedFlavorException;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.List;
|
||||
@@ -60,6 +46,23 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
/** Moves a client into the channel we are currently in. */
|
||||
void moveClientToOwnChannel(ClientEntry client);
|
||||
|
||||
/** Opens the channel editor for a channel. */
|
||||
void editChannel(ChannelNode channel);
|
||||
|
||||
/**
|
||||
* Opens the channel creator.
|
||||
*
|
||||
* @param parent the channel the new one goes below, or {@code null} for a top-level one
|
||||
* @param type the channel type to start with
|
||||
*/
|
||||
void createChannel(ChannelNode parent, ChannelSettings.Type type);
|
||||
|
||||
/** Opens the channel creator pre-filled as a spacer, which is always top-level. */
|
||||
void createSpacer();
|
||||
|
||||
/** Whether the server is there to act on at all. */
|
||||
boolean isConnected();
|
||||
|
||||
/** Open the file repository browser for a channel. */
|
||||
void browseFiles(ChannelNode channel);
|
||||
|
||||
@@ -72,6 +75,9 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
|
||||
boolean isClientLocallyMuted(int clientId);
|
||||
|
||||
/** Selects and scrolls to a client in the tree, e.g. from a chat/log link. */
|
||||
void findClientInTree(ClientEntry client);
|
||||
|
||||
/** A channel or client node was selected (or {@code null} when cleared). */
|
||||
void onSelectionChanged(Object userObject);
|
||||
|
||||
@@ -85,19 +91,47 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
* siblings, or 0 to place it first
|
||||
*/
|
||||
void moveChannel(ChannelNode channel, int newParentId, int orderPredecessorId);
|
||||
}
|
||||
|
||||
/** Carries the dragged node inside this JVM; drops elsewhere get the BBCode text. */
|
||||
private static final DataFlavor NODE_FLAVOR = new DataFlavor(
|
||||
DataFlavor.javaJVMLocalObjectMimeType + ";class=java.lang.Object",
|
||||
"TeamSpeak tree node");
|
||||
/** Assigns or removes a server group for a client. */
|
||||
void setClientServerGroup(ClientEntry client, Group group, boolean assign);
|
||||
|
||||
/** Assigns a channel group for a client in its current channel. */
|
||||
void setClientChannelGroup(ClientEntry client, Group group);
|
||||
|
||||
/** Opens the full server-groups list dialog for a client. */
|
||||
void showServerGroupsDialog(ClientEntry client);
|
||||
|
||||
/** Edits a client's description — our own included. */
|
||||
void changeClientDescription(ClientEntry client);
|
||||
|
||||
/** A new nickname for ourselves, typed into the tree row. */
|
||||
void renameSelf(String nickname);
|
||||
}
|
||||
|
||||
private final DropIndicatorTree tree;
|
||||
private final DefaultMutableTreeNode root = new DefaultMutableTreeNode();
|
||||
private final DefaultTreeModel treeModel = new DefaultTreeModel(root);
|
||||
/**
|
||||
* The nodes only mirror the connection's model, so an edited row is not written
|
||||
* back into the tree: the new nickname goes to the server, and the row follows
|
||||
* once it reports the rename.
|
||||
*/
|
||||
private final DefaultTreeModel treeModel = new DefaultTreeModel(root) {
|
||||
@Override
|
||||
public void valueForPathChanged(TreePath path, Object newValue) {
|
||||
String nickname = String.valueOf(newValue).trim();
|
||||
Object edited = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||
// The server answers a rename to the name already held with "nickname is
|
||||
// already in use", so a field closed unchanged is simply left alone.
|
||||
if (edited instanceof ClientEntry && nickname.equals(((ClientEntry) edited).nickname)) return;
|
||||
if (!nickname.isEmpty()) actions.renameSelf(nickname);
|
||||
}
|
||||
};
|
||||
/** True while {@link #rebuild()} clears and restores the selection, to swallow the transient null in between. */
|
||||
private boolean rebuilding;
|
||||
private final ServerModel model;
|
||||
private final GroupIcons groupIcons;
|
||||
private final Actions actions;
|
||||
private final NicknameCellEditor nicknameEditor;
|
||||
private int selfClientId = -1;
|
||||
|
||||
public ServerTreePanel(ServerModel model, GroupIcons groupIcons, Actions actions) {
|
||||
@@ -105,7 +139,7 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
this.groupIcons = groupIcons;
|
||||
this.actions = actions;
|
||||
root.setUserObject("Not connected");
|
||||
this.tree = new DropIndicatorTree(treeModel);
|
||||
this.tree = new DropIndicatorTree(treeModel, model, groupIcons);
|
||||
tree.setRootVisible(true);
|
||||
// The server node is the only top-level row and always stays open, so it gets
|
||||
// no expand control. Nesting is tightened too: horizontal space in this view
|
||||
@@ -117,22 +151,31 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
ui.setRightChildIndent(10);
|
||||
}
|
||||
tree.setRowHeight(20);
|
||||
tree.setBackground(Theme.TREE_BG);
|
||||
tree.setFont(Theme.UI_FONT);
|
||||
tree.setCellRenderer(new Renderer());
|
||||
tree.setBackground(Theme.treeBg());
|
||||
tree.setFont(Theme.uiFont());
|
||||
ServerTreeCellRenderer renderer = new ServerTreeCellRenderer(id -> id == selfClientId);
|
||||
tree.setCellRenderer(renderer);
|
||||
nicknameEditor = NicknameCellEditor.create(tree, renderer, id -> id == selfClientId);
|
||||
tree.setCellEditor(nicknameEditor);
|
||||
tree.setEditable(true);
|
||||
tree.setPathEditable(nicknameEditor::editsPath);
|
||||
tree.setInvokesStopCellEditing(true);
|
||||
setViewportView(tree);
|
||||
getViewport().setBackground(Theme.TREE_BG);
|
||||
// The icon strip is drawn against the viewport's right edge, so the blitted
|
||||
// pixels a scroll would reuse are stale; repaint the whole viewport instead.
|
||||
getViewport().setScrollMode(JViewport.SIMPLE_SCROLL_MODE);
|
||||
// Scrolling slides the rows past a pointer that has not moved, so the outlined
|
||||
// row has to be worked out again.
|
||||
getViewport().addChangeListener(e -> tree.refreshHoverRow());
|
||||
|
||||
// Within the tree a drag moves the client or channel; dropped elsewhere it
|
||||
// yields the TS3 link BBCode, which the chat input accepts as plain text.
|
||||
tree.setDragEnabled(true);
|
||||
tree.setDropMode(DropMode.ON_OR_INSERT);
|
||||
tree.setTransferHandler(new TreeTransferHandler());
|
||||
tree.setTransferHandler(new ServerTreeDragAndDrop(model, actions, tree, this::pathOf).transferHandler());
|
||||
|
||||
tree.addTreeSelectionListener(e -> {
|
||||
if (rebuilding) return;
|
||||
TreePath path = tree.getSelectionPath();
|
||||
Object obj = null;
|
||||
if (path != null) {
|
||||
@@ -144,6 +187,7 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
tree.addMouseListener(new MouseAdapter() {
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e) {
|
||||
selectRowUnder(e);
|
||||
maybePopup(e);
|
||||
}
|
||||
|
||||
@@ -159,8 +203,12 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
if (obj instanceof ChannelNode && !Spacers.isSpacer(((ChannelNode) obj).name)) {
|
||||
actions.joinChannel(((ChannelNode) obj).id);
|
||||
} else if (obj instanceof ClientEntry) {
|
||||
if (((ClientEntry) obj).id == selfClientId) {
|
||||
tree.startEditingAtPath(tree.pathAt(e.getY()));
|
||||
} else {
|
||||
actions.openPrivateChat((ClientEntry) obj);
|
||||
}
|
||||
}
|
||||
} else if (SwingUtilities.isMiddleMouseButton(e) && obj instanceof ClientEntry) {
|
||||
actions.showConnectionInfo((ClientEntry) obj);
|
||||
}
|
||||
@@ -168,12 +216,37 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the row a press landed on. A row acts on its whole line, but Swing's own
|
||||
* hit testing stops at the end of the label, so a press further right would leave
|
||||
* the selection where it was.
|
||||
*/
|
||||
private void selectRowUnder(MouseEvent e) {
|
||||
if (!SwingUtilities.isLeftMouseButton(e) && !e.isPopupTrigger()) return;
|
||||
TreePath path = tree.pathAt(e.getY());
|
||||
if (path == null || path.equals(tree.getSelectionPath())) return;
|
||||
Rectangle bounds = tree.getPathBounds(path);
|
||||
// Left of the label is the expand handle, which Swing works the row without selecting it.
|
||||
if (bounds != null && e.getX() < bounds.x) return;
|
||||
tree.setSelectionPath(path);
|
||||
}
|
||||
|
||||
public void setSelfClientId(int id) {
|
||||
this.selfClientId = id;
|
||||
}
|
||||
|
||||
/** Selects and scrolls to the row showing {@code client}, if it is currently visible. */
|
||||
public void selectClient(int clientId) {
|
||||
ClientEntry client = model.getClient(clientId);
|
||||
if (client == null) return;
|
||||
TreePath path = pathOf(client);
|
||||
if (path == null) return;
|
||||
tree.setSelectionPath(path);
|
||||
tree.scrollPathToVisible(path);
|
||||
}
|
||||
|
||||
private Object nodeAt(MouseEvent e) {
|
||||
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
|
||||
TreePath path = tree.pathAt(e.getY());
|
||||
if (path == null) return null;
|
||||
DefaultMutableTreeNode n = (DefaultMutableTreeNode) path.getLastPathComponent();
|
||||
return n.getUserObject();
|
||||
@@ -181,7 +254,7 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
|
||||
private void maybePopup(MouseEvent e) {
|
||||
if (!e.isPopupTrigger()) return;
|
||||
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
|
||||
TreePath path = tree.pathAt(e.getY());
|
||||
if (path == null) return;
|
||||
tree.setSelectionPath(path);
|
||||
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||
@@ -189,11 +262,15 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
showClientMenu((ClientEntry) obj, e);
|
||||
} else if (obj instanceof ChannelNode) {
|
||||
showChannelMenu((ChannelNode) obj, e);
|
||||
} else if (path.getPathCount() == 1 && actions.isConnected()) {
|
||||
// The root row is the server itself, whose user object is just its name.
|
||||
ServerMenu.build(actions).show(tree, e.getX(), e.getY());
|
||||
}
|
||||
}
|
||||
|
||||
private void showClientMenu(ClientEntry client, MouseEvent e) {
|
||||
ClientMenu.build(client, client.id == selfClientId, actions).show(tree, e.getX(), e.getY());
|
||||
ClientMenu.build(client, client.id == selfClientId, true, model, groupIcons, actions)
|
||||
.show(tree, e.getX(), e.getY());
|
||||
}
|
||||
|
||||
private void showChannelMenu(ChannelNode channel, MouseEvent e) {
|
||||
@@ -201,10 +278,12 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
ChannelMenu.build(channel, actions).show(tree, e.getX(), e.getY());
|
||||
}
|
||||
|
||||
// ---- drag and drop ----
|
||||
|
||||
private static DefaultMutableTreeNode nodeOf(TreePath path) {
|
||||
return path == null ? null : (DefaultMutableTreeNode) path.getLastPathComponent();
|
||||
@Override
|
||||
public void updateUI() {
|
||||
super.updateUI();
|
||||
// The rows stop at the last channel, so the space below them belongs to the tree
|
||||
// rather than to the window behind it.
|
||||
if (getViewport() != null) getViewport().setBackground(Theme.treeBg());
|
||||
}
|
||||
|
||||
/** The path of the tree node showing {@code target}, or {@code null}. */
|
||||
@@ -218,332 +297,16 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel a dragged client would land in, or {@code null} if the drop makes
|
||||
* no sense (outside a channel, or the channel the client is already in).
|
||||
* Rebuilds the tree from the model, preserving full expansion. Rebuilding
|
||||
* replaces every tree node, which would otherwise drop the current
|
||||
* selection on every update (a client talking, a group change, …); the
|
||||
* previously selected channel/client is restored by identity once the new
|
||||
* nodes are in place, so a selection sticks until the user changes it.
|
||||
*/
|
||||
private ChannelNode resolveClientDrop(JTree.DropLocation loc, ClientEntry dragged) {
|
||||
DefaultMutableTreeNode target = nodeOf(loc.getPath());
|
||||
if (target == null) return null;
|
||||
Object obj = target.getUserObject();
|
||||
ChannelNode channel = null;
|
||||
if (obj instanceof ChannelNode) {
|
||||
channel = (ChannelNode) obj;
|
||||
} else if (obj instanceof ClientEntry) {
|
||||
channel = model.getChannel(((ClientEntry) obj).channelId);
|
||||
}
|
||||
if (channel == null || Spacers.isSpacer(channel.name)) return null;
|
||||
if (channel.id == dragged.channelId) return null;
|
||||
return channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* The new parent and predecessor for a dragged channel as {@code {cpid, order}},
|
||||
* or {@code null} if this drop is not a legal (or meaningful) move.
|
||||
*/
|
||||
private int[] resolveChannelDrop(JTree.DropLocation loc, ChannelNode dragged) {
|
||||
DefaultMutableTreeNode target = nodeOf(loc.getPath());
|
||||
if (target == null) return null;
|
||||
|
||||
DefaultMutableTreeNode parentNode;
|
||||
int insertIndex;
|
||||
if (loc.getChildIndex() >= 0) {
|
||||
parentNode = target;
|
||||
insertIndex = loc.getChildIndex();
|
||||
} else {
|
||||
// Dropped onto a node: become its last subchannel.
|
||||
parentNode = target.getUserObject() instanceof ClientEntry
|
||||
? (DefaultMutableTreeNode) target.getParent() : target;
|
||||
if (parentNode == null) return null;
|
||||
insertIndex = parentNode.getChildCount();
|
||||
}
|
||||
|
||||
int parentId = 0;
|
||||
Object parentObj = parentNode.getUserObject();
|
||||
if (parentObj instanceof ChannelNode) {
|
||||
ChannelNode parent = (ChannelNode) parentObj;
|
||||
if (Spacers.isSpacer(parent.name)) return null;
|
||||
if (isSelfOrDescendant(parent, dragged)) return null; // would detach the subtree
|
||||
parentId = parent.id;
|
||||
} else if (parentNode != root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int predecessorId = 0;
|
||||
for (int i = 0; i < insertIndex && i < parentNode.getChildCount(); i++) {
|
||||
Object o = ((DefaultMutableTreeNode) parentNode.getChildAt(i)).getUserObject();
|
||||
if (o instanceof ChannelNode && ((ChannelNode) o).id != dragged.id) {
|
||||
predecessorId = ((ChannelNode) o).id;
|
||||
}
|
||||
}
|
||||
if (parentId == dragged.parentId && predecessorId == dragged.order) return null; // no-op
|
||||
return new int[]{parentId, predecessorId};
|
||||
}
|
||||
|
||||
/** Whether {@code candidate} is {@code ancestor} itself or sits below it. */
|
||||
private boolean isSelfOrDescendant(ChannelNode candidate, ChannelNode ancestor) {
|
||||
ChannelNode c = candidate;
|
||||
for (int guard = 0; c != null && guard < 64; guard++) {
|
||||
if (c.id == ancestor.id) return true;
|
||||
c = model.getChannel(c.parentId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private final class TreeTransferHandler extends TransferHandler {
|
||||
@Override
|
||||
public int getSourceActions(JComponent c) {
|
||||
return COPY | MOVE;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Transferable createTransferable(JComponent c) {
|
||||
TreePath path = tree.getSelectionPath();
|
||||
if (path == null) return null;
|
||||
Object obj = nodeOf(path).getUserObject();
|
||||
if (obj instanceof ClientEntry) {
|
||||
ClientEntry cl = (ClientEntry) obj;
|
||||
return new NodeTransferable(cl, TsLink.clientBBCode(cl.id, cl.uniqueId, cl.nickname));
|
||||
}
|
||||
if (obj instanceof ChannelNode) {
|
||||
ChannelNode ch = (ChannelNode) obj;
|
||||
// Spacers have no meaningful link text, but can still be re-ordered.
|
||||
return new NodeTransferable(ch, Spacers.isSpacer(ch.name)
|
||||
? null : TsLink.channelBBCode(ch.id, ch.name));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canImport(TransferSupport support) {
|
||||
if (!support.isDrop() || !support.isDataFlavorSupported(NODE_FLAVOR)) {
|
||||
tree.highlightChannel(null);
|
||||
return false;
|
||||
}
|
||||
if ((support.getSourceDropActions() & MOVE) == MOVE) support.setDropAction(MOVE);
|
||||
Object resolved = resolve(support);
|
||||
// A client always lands *inside* a channel, so mark that channel instead of
|
||||
// drawing a line that would suggest a position among its clients.
|
||||
tree.highlightChannel(resolved instanceof ChannelNode
|
||||
? pathOf((ChannelNode) resolved) : null);
|
||||
// The indicator spans the full width, past the row rectangles Swing
|
||||
// repaints on its own when the drop location moves.
|
||||
tree.repaint();
|
||||
return resolved != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void exportDone(JComponent source, Transferable data, int action) {
|
||||
tree.highlightChannel(null);
|
||||
tree.repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean importData(TransferSupport support) {
|
||||
if (!canImport(support)) return false;
|
||||
Object dragged = draggedNode(support);
|
||||
Object resolved = resolve(support);
|
||||
tree.highlightChannel(null);
|
||||
if (dragged instanceof ClientEntry) {
|
||||
actions.moveClientToChannel((ClientEntry) dragged, (ChannelNode) resolved);
|
||||
} else if (dragged instanceof ChannelNode) {
|
||||
int[] place = (int[]) resolved;
|
||||
actions.moveChannel((ChannelNode) dragged, place[0], place[1]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The destination for this drop: a ChannelNode, an {@code {cpid, order}} pair, or null. */
|
||||
private Object resolve(TransferSupport support) {
|
||||
Object dragged = draggedNode(support);
|
||||
if (!(support.getDropLocation() instanceof JTree.DropLocation)) return null;
|
||||
JTree.DropLocation loc = (JTree.DropLocation) support.getDropLocation();
|
||||
if (dragged instanceof ClientEntry) return resolveClientDrop(loc, (ClientEntry) dragged);
|
||||
if (dragged instanceof ChannelNode) return resolveChannelDrop(loc, (ChannelNode) dragged);
|
||||
return null;
|
||||
}
|
||||
|
||||
private Object draggedNode(TransferSupport support) {
|
||||
try {
|
||||
return support.getTransferable().getTransferData(NODE_FLAVOR);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Offers the dragged node locally and its TS3 link BBCode to other applications. */
|
||||
private static final class NodeTransferable implements Transferable {
|
||||
private final Object node;
|
||||
private final String text;
|
||||
|
||||
NodeTransferable(Object node, String text) {
|
||||
this.node = node;
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataFlavor[] getTransferDataFlavors() {
|
||||
return text == null ? new DataFlavor[]{NODE_FLAVOR}
|
||||
: new DataFlavor[]{NODE_FLAVOR, DataFlavor.stringFlavor};
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isDataFlavorSupported(DataFlavor flavor) {
|
||||
return NODE_FLAVOR.equals(flavor) || (text != null && DataFlavor.stringFlavor.equals(flavor));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException {
|
||||
if (NODE_FLAVOR.equals(flavor)) return node;
|
||||
if (text != null && DataFlavor.stringFlavor.equals(flavor)) return text;
|
||||
throw new UnsupportedFlavorException(flavor);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- group icon strip ----
|
||||
|
||||
/** Gap kept between a row's label and the right-aligned icon strip. */
|
||||
private static final int BADGE_GAP = 8;
|
||||
/** Inset of the strip from the visible right edge. */
|
||||
private static final int BADGE_MARGIN = 4;
|
||||
|
||||
/**
|
||||
* Paints the icons of every visible row — a client's group icons, a channel's own
|
||||
* icon — flush with the right edge of the viewport, the way TeamSpeak lines them up.
|
||||
* Drawing them here rather than in the cell renderer keeps the rows' measured widths
|
||||
* (and thus the selection highlight) tied to the label alone.
|
||||
*/
|
||||
private void paintBadges(Graphics g, JTree tree) {
|
||||
Rectangle visible = tree.getVisibleRect();
|
||||
int right = visible.x + visible.width - BADGE_MARGIN;
|
||||
for (int row = 0; row < tree.getRowCount(); row++) {
|
||||
Rectangle bounds = tree.getRowBounds(row);
|
||||
if (bounds == null || bounds.y + bounds.height < visible.y) continue;
|
||||
if (bounds.y > visible.y + visible.height) break;
|
||||
|
||||
TreePath path = tree.getPathForRow(row);
|
||||
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||
List<Icon> icons = badgesOf(obj);
|
||||
if (icons.isEmpty()) continue;
|
||||
|
||||
GroupIcons.Row strip = new GroupIcons.Row(icons);
|
||||
int x = Math.max(bounds.x + bounds.width + BADGE_GAP, right - strip.getIconWidth());
|
||||
strip.paintIcon(tree, g, x, bounds.y + (bounds.height - strip.getIconHeight()) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
/** The icon strip a row shows on its right, empty when it has none (yet). */
|
||||
private List<Icon> badgesOf(Object node) {
|
||||
if (node instanceof ClientEntry) {
|
||||
ClientEntry cl = (ClientEntry) node;
|
||||
return groupIcons.iconsOf(
|
||||
model.serverGroupsOf(cl.serverGroupIds), model.channelGroup(cl.channelGroupId));
|
||||
}
|
||||
if (node instanceof ChannelNode) {
|
||||
ChannelNode ch = (ChannelNode) node;
|
||||
Icon icon = Spacers.isSpacer(ch.name) ? null : groupIcons.icon(ch.iconId);
|
||||
if (icon != null) return List.of(icon);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws where the drop will land: an insertion line between rows, or an outline
|
||||
* around the row that will receive the dragged node.
|
||||
*/
|
||||
private final class DropIndicatorTree extends JTree {
|
||||
/** Set while a drop would move a client into this channel row. */
|
||||
private TreePath highlight;
|
||||
|
||||
DropIndicatorTree(TreeModel model) {
|
||||
super(model);
|
||||
}
|
||||
|
||||
/** Keeps the server row permanently open; collapsing it would hide everything. */
|
||||
@Override
|
||||
public void setExpandedState(TreePath path, boolean state) {
|
||||
if (!state && path.getPathCount() == 1) return;
|
||||
super.setExpandedState(path, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Widens every repaint request to the full visible width. Swing only asks for
|
||||
* the row rectangle, which stops short of the right-aligned icon strip and would
|
||||
* leave it behind when a row's label changes width.
|
||||
*/
|
||||
@Override
|
||||
public void repaint(long tm, int x, int y, int width, int height) {
|
||||
Rectangle visible = getVisibleRect();
|
||||
super.repaint(tm, visible.x, y, visible.width, height);
|
||||
}
|
||||
|
||||
void highlightChannel(TreePath path) {
|
||||
if (path == highlight || (path != null && path.equals(highlight))) return;
|
||||
highlight = path;
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g) {
|
||||
super.paintComponent(g);
|
||||
paintBadges(g, this);
|
||||
JTree.DropLocation loc = getDropLocation();
|
||||
if (loc == null || loc.getPath() == null) return;
|
||||
|
||||
Graphics2D g2 = (Graphics2D) g.create();
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
g2.setColor(Theme.ACCENT);
|
||||
if (highlight != null || loc.getChildIndex() < 0) {
|
||||
Rectangle r = getPathBounds(highlight != null ? highlight : loc.getPath());
|
||||
if (r != null) {
|
||||
g2.setStroke(new BasicStroke(2f));
|
||||
g2.drawRoundRect(r.x, r.y + 1, r.width - 1, r.height - 3, 4, 4);
|
||||
}
|
||||
} else {
|
||||
Rectangle line = insertLine(loc);
|
||||
if (line != null) {
|
||||
g2.fillRect(line.x, line.y - 1, line.width, 2);
|
||||
g2.fillOval(line.x - 3, line.y - 4, 7, 7);
|
||||
}
|
||||
}
|
||||
g2.dispose();
|
||||
}
|
||||
|
||||
/** The 1px-tall strip where the insertion line goes, in tree coordinates. */
|
||||
private Rectangle insertLine(JTree.DropLocation loc) {
|
||||
DefaultMutableTreeNode parent = (DefaultMutableTreeNode) loc.getPath().getLastPathComponent();
|
||||
int index = loc.getChildIndex();
|
||||
if (index < parent.getChildCount()) {
|
||||
Rectangle r = getPathBounds(loc.getPath().pathByAddingChild(parent.getChildAt(index)));
|
||||
return r == null ? null : new Rectangle(r.x, r.y, getWidth() - r.x, 2);
|
||||
}
|
||||
if (parent.getChildCount() == 0) {
|
||||
Rectangle r = getPathBounds(loc.getPath());
|
||||
if (r == null) return null;
|
||||
int x = r.x + getRowHeight();
|
||||
return new Rectangle(x, r.y + r.height, getWidth() - x, 2);
|
||||
}
|
||||
// Past the last child: below that child's whole (expanded) subtree.
|
||||
TreePath lastChild = loc.getPath().pathByAddingChild(parent.getChildAt(parent.getChildCount() - 1));
|
||||
Rectangle head = getPathBounds(lastChild);
|
||||
Rectangle tail = getPathBounds(lastVisibleRow(lastChild));
|
||||
if (head == null || tail == null) return null;
|
||||
return new Rectangle(head.x, tail.y + tail.height, getWidth() - head.x, 2);
|
||||
}
|
||||
|
||||
private TreePath lastVisibleRow(TreePath path) {
|
||||
int row = getRowForPath(path);
|
||||
if (row < 0) return path;
|
||||
for (int i = row + 1; i < getRowCount(); i++) {
|
||||
if (!path.isDescendant(getPathForRow(i))) break;
|
||||
row = i;
|
||||
}
|
||||
return getPathForRow(row);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuilds the tree from the model, preserving full expansion. */
|
||||
public void rebuild() {
|
||||
Object selected = selectedUserObject();
|
||||
rebuilding = true;
|
||||
try {
|
||||
root.setUserObject(model.getServerName());
|
||||
root.removeAllChildren();
|
||||
List<ChannelNode> roots = model.buildTree();
|
||||
@@ -554,6 +317,22 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
for (int i = 0; i < tree.getRowCount(); i++) {
|
||||
tree.expandRow(i);
|
||||
}
|
||||
if (selected != null) {
|
||||
TreePath path = pathOf(selected);
|
||||
if (path != null) tree.setSelectionPath(path);
|
||||
}
|
||||
} finally {
|
||||
rebuilding = false;
|
||||
}
|
||||
// The selection listener was swallowed above; tell the caller only if it actually changed
|
||||
// (e.g. the previously selected channel/client is gone), since it already knows the rest.
|
||||
Object nowSelected = selectedUserObject();
|
||||
if (nowSelected != selected) actions.onSelectionChanged(nowSelected);
|
||||
}
|
||||
|
||||
private Object selectedUserObject() {
|
||||
TreePath path = tree.getSelectionPath();
|
||||
return path == null ? null : ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||
}
|
||||
|
||||
private DefaultMutableTreeNode buildChannel(ChannelNode c) {
|
||||
@@ -593,73 +372,4 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a tree row: the status icon and the label. The group icon strip is painted
|
||||
* separately, right-aligned, by {@link #paintBadges}.
|
||||
*/
|
||||
private final class Renderer extends DefaultTreeCellRenderer {
|
||||
|
||||
@Override
|
||||
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel,
|
||||
boolean expanded, boolean leaf, int row,
|
||||
boolean hasFocus) {
|
||||
super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus);
|
||||
setBackgroundNonSelectionColor(Theme.TREE_BG);
|
||||
setBackgroundSelectionColor(Theme.TREE_SELECTION);
|
||||
setBorderSelectionColor(Theme.TREE_SELECTION);
|
||||
|
||||
Object obj = ((DefaultMutableTreeNode) value).getUserObject();
|
||||
if (obj instanceof ChannelNode) {
|
||||
ChannelNode c = (ChannelNode) obj;
|
||||
Spacers.Spacer spacer = Spacers.parse(c.name);
|
||||
if (spacer != null) {
|
||||
setText(Spacers.render(spacer, 40));
|
||||
setIcon(null);
|
||||
setForeground(Theme.IDLE_CLIENT);
|
||||
setFont(Theme.UI_FONT);
|
||||
} else {
|
||||
setText(c.name);
|
||||
setIcon(iconFor(c));
|
||||
setForeground(Theme.CHANNEL_TEXT);
|
||||
setFont(Theme.UI_BOLD);
|
||||
}
|
||||
} else if (obj instanceof ClientEntry) {
|
||||
ClientEntry cl = (ClientEntry) obj;
|
||||
String label = cl.nickname;
|
||||
if (cl.away && !cl.awayMessage.isEmpty()) label += " [" + cl.awayMessage + "]";
|
||||
setText(label);
|
||||
setIcon(iconFor(cl));
|
||||
setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT);
|
||||
setFont(cl.talking ? Theme.UI_BOLD : Theme.UI_FONT);
|
||||
} else {
|
||||
// root / server
|
||||
setText(String.valueOf(obj));
|
||||
setIcon(Icons.server());
|
||||
setForeground(Theme.SERVER_TEXT);
|
||||
setFont(Theme.UI_BOLD);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
private ImageIcon iconFor(ChannelNode c) {
|
||||
if (c.hasPassword) return Icons.channelLocked(c.subscribed);
|
||||
if (c.maxClients >= 0 && c.clients.size() >= c.maxClients) return Icons.channelFull(c.subscribed);
|
||||
return Icons.channel(c.subscribed);
|
||||
}
|
||||
|
||||
/** The client's state, in the order the official client gives them priority. */
|
||||
private ImageIcon iconFor(ClientEntry cl) {
|
||||
if (cl.isQuery()) return Icons.clientQuery();
|
||||
if (!cl.outputHardware) return Icons.speakerDisabled();
|
||||
if (cl.outputMuted) return Icons.speakerMuted();
|
||||
if (!cl.inputHardware) return Icons.micDisabled();
|
||||
if (cl.inputMuted) return Icons.micMuted();
|
||||
if (cl.away) return Icons.clientAway();
|
||||
if (cl.channelCommander) {
|
||||
return cl.talking ? Icons.clientCommanderTalking() : Icons.clientCommander();
|
||||
}
|
||||
if (cl.talking) return Icons.clientTalking();
|
||||
return Icons.clientIdle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +1,40 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.audio.InputLevel;
|
||||
import com.ts3client.audio.OpusParameters;
|
||||
import com.ts3client.audio.VoiceInput;
|
||||
import com.ts3client.audio.VoiceOutput;
|
||||
import com.ts3client.audio.desktop.AudioDevices;
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.hotkey.Hotkey;
|
||||
import com.ts3client.hotkey.HotkeyAction;
|
||||
import com.ts3client.sound.SoundNotifier;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.ButtonGroup;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JRadioButton;
|
||||
import javax.swing.JSlider;
|
||||
import javax.swing.JTabbedPane;
|
||||
import javax.swing.JToggleButton;
|
||||
import javax.swing.Scrollable;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Frame;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.GridBagLayout;
|
||||
import java.awt.Insets;
|
||||
import java.awt.Rectangle;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Options dialog: audio device selection plus voice-activation / push-to-talk
|
||||
* tuning with a live input meter. Changes are applied to the running audio
|
||||
* subsystem immediately and persisted to {@link Settings} on OK.
|
||||
* Options dialog: a tabbed coordinator over the individual settings pages. Each tab owns
|
||||
* its own controls and reads/writes {@link Settings} on {@link #apply()}; this class wires
|
||||
* the tabs that preview themselves live against the running audio subsystem (the device
|
||||
* and voice-activation tabs, which share a single microphone test) and owns the
|
||||
* OK/Cancel/Apply plumbing.
|
||||
*/
|
||||
public final class SettingsDialog extends JDialog {
|
||||
|
||||
/** Preferred widths of the form's field column; rows shrink with the dialog from there. */
|
||||
private static final int FIELD_WIDTH = 240;
|
||||
private static final int SLIDER_WIDTH = 200;
|
||||
private static final int MIN_FIELD_WIDTH = 60;
|
||||
|
||||
private static final int MIN_BITRATE_KBITS = 8;
|
||||
private static final int MAX_BITRATE_KBITS = 160;
|
||||
|
||||
private final Settings settings;
|
||||
private final VoiceInput liveMic;
|
||||
private final VoiceOutput livePlayback;
|
||||
private final SoundNotifier sounds;
|
||||
private final HotkeyService hotkeys;
|
||||
private final Runnable onApply;
|
||||
|
||||
private NotificationsPanel notificationsPanel;
|
||||
private IconPackPanel iconPackPanel;
|
||||
private ClientVersionPanel clientVersionPanel;
|
||||
private HotkeysPanel hotkeysPanel;
|
||||
|
||||
private JComboBox<AudioDevices.Device> inputCombo;
|
||||
private JComboBox<AudioDevices.Device> outputCombo;
|
||||
private JSlider inputGain;
|
||||
private JSlider outputVol;
|
||||
private JCheckBox denoiseCheck;
|
||||
private JSlider denoiseLevel;
|
||||
private JCheckBox typingCheck;
|
||||
private JCheckBox agcCheck;
|
||||
|
||||
private JRadioButton vadRadio;
|
||||
private JRadioButton pttRadio;
|
||||
private JRadioButton contRadio;
|
||||
private JComboBox<String> vadModeCombo;
|
||||
private JSlider thresholdSlider;
|
||||
private JSlider speechSlider;
|
||||
private JCheckBox vadOverPttCheck;
|
||||
private JLabel thresholdLabel;
|
||||
private JLabel speechLabel;
|
||||
private LevelMeter meter;
|
||||
private JToggleButton testButton;
|
||||
private JCheckBox loopbackCheck;
|
||||
private JLabel talkIndicator;
|
||||
private JButton pttKeyButton;
|
||||
private JSlider bitrateSlider;
|
||||
private JLabel bitrateLabel;
|
||||
private JSlider complexitySlider;
|
||||
private JCheckBox vbrCheck;
|
||||
private JCheckBox fecCheck;
|
||||
private JCheckBox musicCheck;
|
||||
|
||||
private final MicrophoneTest micTest = new MicrophoneTest(this::onTestLevel, this::onTestTalking);
|
||||
private final DevicesPanel devicesPanel;
|
||||
private final VoiceActivationPanel voiceActivationPanel;
|
||||
private final NotificationsPanel notificationsPanel;
|
||||
private final DesignPanel designPanel;
|
||||
private final HotkeysPanel hotkeysPanel;
|
||||
private final ClientVersionPanel clientVersionPanel;
|
||||
|
||||
public SettingsDialog(Frame owner, Settings settings,
|
||||
VoiceInput liveMic, VoiceOutput livePlayback,
|
||||
@@ -101,20 +43,23 @@ public final class SettingsDialog extends JDialog {
|
||||
this.settings = settings;
|
||||
this.liveMic = liveMic;
|
||||
this.livePlayback = livePlayback;
|
||||
this.sounds = sounds;
|
||||
this.onApply = onApply;
|
||||
this.hotkeys = hotkeys;
|
||||
|
||||
notificationsPanel = new NotificationsPanel(settings, sounds);
|
||||
designPanel = new DesignPanel(settings);
|
||||
hotkeysPanel = new HotkeysPanel(hotkeys);
|
||||
clientVersionPanel = new ClientVersionPanel(settings);
|
||||
devicesPanel = new DevicesPanel(settings, livePlayback,
|
||||
this::applyLive, this::restartTest, this::setTestOutputDevice);
|
||||
voiceActivationPanel = new VoiceActivationPanel(settings, liveMic, hotkeys,
|
||||
hotkeysPanel, this::audioSnapshot);
|
||||
|
||||
JTabbedPane tabs = new JTabbedPane();
|
||||
tabs.addTab("Playback / Capture", scrollable(buildDevicesTab()));
|
||||
tabs.addTab("Voice Activation", scrollable(buildVoiceTab()));
|
||||
notificationsPanel = new NotificationsPanel(settings, sounds);
|
||||
tabs.addTab("Playback / Capture", scrollable(devicesPanel));
|
||||
tabs.addTab("Voice Activation", scrollable(voiceActivationPanel));
|
||||
tabs.addTab("Notifications", notificationsPanel);
|
||||
iconPackPanel = new IconPackPanel(settings);
|
||||
tabs.addTab("Design", iconPackPanel);
|
||||
hotkeysPanel = new HotkeysPanel(hotkeys);
|
||||
tabs.addTab("Design", designPanel);
|
||||
tabs.addTab("Hotkeys", hotkeysPanel);
|
||||
clientVersionPanel = new ClientVersionPanel(settings);
|
||||
tabs.addTab("Client Version", scrollable(clientVersionPanel));
|
||||
|
||||
JPanel buttons = new JPanel(new BorderLayout());
|
||||
@@ -139,7 +84,7 @@ public final class SettingsDialog extends JDialog {
|
||||
addWindowListener(new java.awt.event.WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosed(java.awt.event.WindowEvent e) {
|
||||
micTest.stop();
|
||||
voiceActivationPanel.stopTest();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -149,327 +94,6 @@ public final class SettingsDialog extends JDialog {
|
||||
setLocationRelativeTo(owner);
|
||||
}
|
||||
|
||||
private JPanel buildDevicesTab() {
|
||||
JPanel p = formPanel();
|
||||
GridBagConstraints c = gbc();
|
||||
|
||||
List<AudioDevices.Device> ins = AudioDevices.inputDevices();
|
||||
List<AudioDevices.Device> outs = AudioDevices.outputDevices();
|
||||
|
||||
inputCombo = new JComboBox<>(ins.toArray(new AudioDevices.Device[0]));
|
||||
outputCombo = new JComboBox<>(outs.toArray(new AudioDevices.Device[0]));
|
||||
selectOrDefault(inputCombo, settings.inputDevice);
|
||||
selectOrDefault(outputCombo, settings.outputDevice);
|
||||
|
||||
String deviceHint = "<html>Named devices are PipeWire's, and are routed through it "
|
||||
+ "(so per-application volume and rerouting keep working).<br>"
|
||||
+ "<b>ALSA:</b> entries talk to the sound card directly, taking it exclusively.</html>";
|
||||
inputCombo.setToolTipText(deviceHint);
|
||||
outputCombo.setToolTipText(deviceHint);
|
||||
limitWidth(inputCombo, FIELD_WIDTH);
|
||||
limitWidth(outputCombo, FIELD_WIDTH);
|
||||
|
||||
int row = 0;
|
||||
addRow(p, c, row++, new JLabel("Capture device (microphone):"), inputCombo);
|
||||
addRow(p, c, row++, new JLabel("Playback device (speakers):"), outputCombo);
|
||||
|
||||
inputGain = new JSlider(0, 200, (int) Math.round(settings.inputVolume * 100));
|
||||
outputVol = new JSlider(0, 200, (int) Math.round(settings.outputVolume * 100));
|
||||
limitWidth(inputGain, SLIDER_WIDTH);
|
||||
limitWidth(outputVol, SLIDER_WIDTH);
|
||||
addRow(p, c, row++, new JLabel("Microphone gain:"), inputGain);
|
||||
addRow(p, c, row++, new JLabel("Playback volume:"), outputVol);
|
||||
|
||||
outputVol.addChangeListener(e -> {
|
||||
if (livePlayback != null) livePlayback.setMasterVolume(outputVol.getValue() / 100.0);
|
||||
});
|
||||
inputGain.addChangeListener(e ->
|
||||
applyLive(m -> m.setInputGain(inputGain.getValue() / 100.0)));
|
||||
inputCombo.addActionListener(e -> restartTest());
|
||||
outputCombo.addActionListener(e -> micTest.setOutputDevice(comboValue(outputCombo)));
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
c.insets = new Insets(14, 4, 2, 4);
|
||||
p.add(new JLabel("Noise reduction"), c);
|
||||
c.insets = new Insets(4, 4, 4, 4);
|
||||
c.gridwidth = 1;
|
||||
|
||||
denoiseCheck = new JCheckBox("Remove background noise", settings.denoise);
|
||||
denoiseCheck.setToolTipText("Attempt to filter out background noises.");
|
||||
denoiseLevel = new JSlider(0, 100, (int) Math.round(settings.denoiserLevel * 100));
|
||||
limitWidth(denoiseLevel, SLIDER_WIDTH);
|
||||
denoiseLevel.setToolTipText("Higher = more aggressive noise removal.");
|
||||
typingCheck = new JCheckBox("Typing attenuation", settings.typingAttenuation);
|
||||
typingCheck.setToolTipText("<html><b>Typing attenuation</b> tries to detect and "
|
||||
+ "reduce the sounds made by typing.</html>");
|
||||
agcCheck = new JCheckBox("Automatic gain control (AGC)", settings.agc);
|
||||
agcCheck.setToolTipText("<html><b>Automatic gain control</b> normalises your "
|
||||
+ "microphone loudness to a target level, boosting quiet mics and taming "
|
||||
+ "loud ones.</html>");
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
p.add(denoiseCheck, c);
|
||||
c.gridwidth = 1;
|
||||
addRow(p, c, row++, new JLabel("Noise removal level:"), denoiseLevel);
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
p.add(typingCheck, c);
|
||||
c.gridy = row++;
|
||||
p.add(agcCheck, c);
|
||||
c.gridwidth = 1;
|
||||
|
||||
Runnable syncNoise = () -> {
|
||||
denoiseLevel.setEnabled(denoiseCheck.isSelected());
|
||||
applyLive(m -> {
|
||||
m.setNoiseSuppression(denoiseCheck.isSelected());
|
||||
m.setDenoiserLevel(denoiseLevel.getValue() / 100.0);
|
||||
m.setTypingAttenuation(typingCheck.isSelected());
|
||||
m.setAgc(agcCheck.isSelected());
|
||||
});
|
||||
};
|
||||
denoiseCheck.addActionListener(e -> syncNoise.run());
|
||||
typingCheck.addActionListener(e -> syncNoise.run());
|
||||
agcCheck.addActionListener(e -> syncNoise.run());
|
||||
denoiseLevel.addChangeListener(e ->
|
||||
applyLive(m -> m.setDenoiserLevel(denoiseLevel.getValue() / 100.0)));
|
||||
syncNoise.run();
|
||||
|
||||
// filler
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.weighty = 1;
|
||||
p.add(Box.createGlue(), c);
|
||||
return p;
|
||||
}
|
||||
|
||||
private JPanel buildVoiceTab() {
|
||||
JPanel p = formPanel();
|
||||
GridBagConstraints c = gbc();
|
||||
|
||||
vadRadio = new JRadioButton("Voice Activation Detection");
|
||||
pttRadio = new JRadioButton("Push-To-Talk");
|
||||
contRadio = new JRadioButton("Continuous");
|
||||
ButtonGroup group = new ButtonGroup();
|
||||
group.add(vadRadio);
|
||||
group.add(pttRadio);
|
||||
group.add(contRadio);
|
||||
switch (settings.inputMode) {
|
||||
case PUSH_TO_TALK:
|
||||
pttRadio.setSelected(true);
|
||||
break;
|
||||
case CONTINUOUS:
|
||||
contRadio.setSelected(true);
|
||||
break;
|
||||
default:
|
||||
vadRadio.setSelected(true);
|
||||
}
|
||||
|
||||
int row = 0;
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
p.add(vadRadio, c);
|
||||
c.gridy = row++;
|
||||
p.add(pttRadio, c);
|
||||
c.gridy = row++;
|
||||
p.add(contRadio, c);
|
||||
c.gridwidth = 1;
|
||||
|
||||
meter = new LevelMeter();
|
||||
meter.setThreshold(settings.vadThresholdDb);
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.gridwidth = 2;
|
||||
c.insets = new Insets(10, 4, 2, 4);
|
||||
p.add(new JLabel("Input level:"), c);
|
||||
c.gridy = ++row;
|
||||
p.add(meter, c);
|
||||
c.insets = new Insets(4, 4, 4, 4);
|
||||
c.gridy = ++row;
|
||||
p.add(buildTestControls(), c);
|
||||
c.gridwidth = 1;
|
||||
row++;
|
||||
|
||||
vadModeCombo = new JComboBox<>(new String[]{"Automatic", "Volume Gate", "Hybrid"});
|
||||
vadModeCombo.setSelectedIndex(vadModeIndex(settings.vadMode));
|
||||
vadModeCombo.addActionListener(e -> micTest.configure(m -> m.setVadMode(currentVadMode())));
|
||||
vadModeCombo.setToolTipText("<html><b>Automatic</b>: intelligent speech detection.<br>"
|
||||
+ "<b>Volume Gate</b>: transmit when loud enough.<br>"
|
||||
+ "<b>Hybrid</b>: loud enough <i>and</i> detected as speech.</html>");
|
||||
addRow(p, c, row++, new JLabel("Detection:"), vadModeCombo);
|
||||
|
||||
thresholdSlider = new JSlider((int) InputLevel.MIN_DB, (int) InputLevel.MAX_DB,
|
||||
(int) Math.round(settings.vadThresholdDb));
|
||||
thresholdLabel = new JLabel(Math.round(settings.vadThresholdDb) + " dB");
|
||||
thresholdSlider.addChangeListener(e -> {
|
||||
meter.setThreshold(thresholdSlider.getValue());
|
||||
thresholdLabel.setText(thresholdSlider.getValue() + " dB");
|
||||
applyLive(m -> m.setThresholdDb(thresholdSlider.getValue()));
|
||||
});
|
||||
addRow(p, c, row++, new JLabel("Volume gate:"), sliderWithLabel(thresholdSlider, thresholdLabel));
|
||||
|
||||
speechSlider = new JSlider(0, 100, (int) Math.round(settings.speechThreshold * 100));
|
||||
speechLabel = new JLabel(Math.round(settings.speechThreshold * 100) + "%");
|
||||
speechSlider.addChangeListener(e -> {
|
||||
speechLabel.setText(speechSlider.getValue() + "%");
|
||||
applyLive(m -> m.setSpeechThreshold(speechSlider.getValue() / 100.0));
|
||||
});
|
||||
addRow(p, c, row++, new JLabel("Speech threshold:"), sliderWithLabel(speechSlider, speechLabel));
|
||||
|
||||
pttKeyButton = new JButton(pushToTalkHotkeyText());
|
||||
pttKeyButton.setToolTipText("Push-to-talk is a global hotkey; this opens its binding");
|
||||
pttKeyButton.addActionListener(e -> editPushToTalkHotkey());
|
||||
addRow(p, c, row++, new JLabel("Push-to-talk hotkey:"), pttKeyButton);
|
||||
|
||||
vadOverPttCheck = new JCheckBox("Also detect voice while push-to-talk", settings.vadOverPtt);
|
||||
vadOverPttCheck.addActionListener(e ->
|
||||
micTest.configure(m -> m.setVadOverPtt(vadOverPttCheck.isSelected())));
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
p.add(vadOverPttCheck, c);
|
||||
c.gridwidth = 1;
|
||||
|
||||
int kbits = Math.max(MIN_BITRATE_KBITS, Math.min(MAX_BITRATE_KBITS, settings.bitrate / 1000));
|
||||
bitrateSlider = new JSlider(MIN_BITRATE_KBITS, MAX_BITRATE_KBITS, kbits);
|
||||
bitrateLabel = new JLabel(kbits + " kbit/s");
|
||||
bitrateSlider.addChangeListener(e -> {
|
||||
bitrateLabel.setText(bitrateSlider.getValue() + " kbit/s");
|
||||
pushOpusLive();
|
||||
});
|
||||
addRow(p, c, row++, new JLabel("Opus bitrate:"),
|
||||
sliderWithLabel(bitrateSlider, bitrateLabel, 70));
|
||||
|
||||
complexitySlider = new JSlider(0, 10, settings.complexity);
|
||||
limitWidth(complexitySlider, SLIDER_WIDTH);
|
||||
complexitySlider.addChangeListener(e -> pushOpusLive());
|
||||
addRow(p, c, row++, new JLabel("Opus complexity:"), complexitySlider);
|
||||
|
||||
vbrCheck = new JCheckBox("Variable bitrate (VBR)", settings.vbr);
|
||||
fecCheck = new JCheckBox("Forward error correction (FEC)", settings.fec);
|
||||
musicCheck = new JCheckBox("Music codec (stereo, higher fidelity)", settings.music);
|
||||
musicCheck.setToolTipText("<html>Transmits <b>OPUS_MUSIC</b>: stereo when the capture "
|
||||
+ "device has two channels, and without the voice pre-processing "
|
||||
+ "(noise removal, typing attenuation, AGC).<br>"
|
||||
+ "Voice mode (<b>OPUS_VOICE</b>) is mono, as in the official client.</html>");
|
||||
vbrCheck.addActionListener(e -> pushOpusLive());
|
||||
fecCheck.addActionListener(e -> pushOpusLive());
|
||||
musicCheck.addActionListener(e -> pushOpusLive());
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
p.add(vbrCheck, c);
|
||||
c.gridy = row++;
|
||||
p.add(fecCheck, c);
|
||||
c.gridy = row++;
|
||||
p.add(musicCheck, c);
|
||||
c.gridwidth = 1;
|
||||
|
||||
Runnable syncEnabled = () -> {
|
||||
boolean vad = vadRadio.isSelected();
|
||||
boolean ptt = pttRadio.isSelected();
|
||||
boolean vadContext = vad || (ptt && vadOverPttCheck.isSelected());
|
||||
Settings.VadMode vm = currentVadMode();
|
||||
boolean usesGate = vm != Settings.VadMode.AUTOMATIC;
|
||||
boolean usesSpeech = vm != Settings.VadMode.VOLUME_GATE;
|
||||
|
||||
vadModeCombo.setEnabled(vadContext);
|
||||
thresholdSlider.setEnabled(vadContext && usesGate);
|
||||
speechSlider.setEnabled(vadContext && usesSpeech);
|
||||
pttKeyButton.setEnabled(ptt);
|
||||
vadOverPttCheck.setEnabled(ptt);
|
||||
meter.setShowThreshold(vadContext && usesGate);
|
||||
|
||||
if (liveMic != null) {
|
||||
liveMic.setMode(ptt ? Settings.InputMode.PUSH_TO_TALK
|
||||
: vad ? Settings.InputMode.VOICE_ACTIVATION
|
||||
: Settings.InputMode.CONTINUOUS);
|
||||
liveMic.setVadMode(vm);
|
||||
liveMic.setVadOverPtt(vadOverPttCheck.isSelected());
|
||||
}
|
||||
};
|
||||
vadRadio.addActionListener(e -> syncEnabled.run());
|
||||
pttRadio.addActionListener(e -> syncEnabled.run());
|
||||
contRadio.addActionListener(e -> syncEnabled.run());
|
||||
vadModeCombo.addActionListener(e -> syncEnabled.run());
|
||||
vadOverPttCheck.addActionListener(e -> syncEnabled.run());
|
||||
syncEnabled.run();
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.weighty = 1;
|
||||
p.add(Box.createGlue(), c);
|
||||
return p;
|
||||
}
|
||||
|
||||
private static int vadModeIndex(Settings.VadMode m) {
|
||||
switch (m) {
|
||||
case AUTOMATIC:
|
||||
return 0;
|
||||
case VOLUME_GATE:
|
||||
return 1;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private Settings.VadMode currentVadMode() {
|
||||
switch (vadModeCombo.getSelectedIndex()) {
|
||||
case 0:
|
||||
return Settings.VadMode.AUTOMATIC;
|
||||
case 1:
|
||||
return Settings.VadMode.VOLUME_GATE;
|
||||
default:
|
||||
return Settings.VadMode.HYBRID;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The form panel used by both tabs. It follows the scroll pane's width instead of
|
||||
* demanding its own preferred one, so rows stay inside the dialog.
|
||||
*/
|
||||
private static JPanel formPanel() {
|
||||
JPanel p = new FormPanel();
|
||||
p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
|
||||
return p;
|
||||
}
|
||||
|
||||
private static final class FormPanel extends JPanel implements Scrollable {
|
||||
FormPanel() {
|
||||
super(new GridBagLayout());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredScrollableViewportSize() {
|
||||
return getPreferredSize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) {
|
||||
return 16;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) {
|
||||
return visible.height;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportWidth() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportHeight() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static javax.swing.JScrollPane scrollable(JPanel content) {
|
||||
javax.swing.JScrollPane sp = new javax.swing.JScrollPane(content,
|
||||
javax.swing.ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
|
||||
@@ -479,103 +103,15 @@ public final class SettingsDialog extends JDialog {
|
||||
return sp;
|
||||
}
|
||||
|
||||
private static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel) {
|
||||
return sliderWithLabel(slider, valueLabel, 48);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairs a slider with its value readout. The readout gets a fixed width wide enough for
|
||||
* the longest value so the slider does not jump around as it is dragged.
|
||||
*/
|
||||
private static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel, int labelWidth) {
|
||||
JPanel panel = new JPanel(new BorderLayout(6, 0));
|
||||
limitWidth(slider, SLIDER_WIDTH);
|
||||
panel.add(slider, BorderLayout.CENTER);
|
||||
valueLabel.setPreferredSize(new Dimension(labelWidth, valueLabel.getPreferredSize().height));
|
||||
panel.add(valueLabel, BorderLayout.EAST);
|
||||
return panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps a field's preferred width and lets it shrink: long device names and wide sliders
|
||||
* would otherwise force the form past the dialog's edge, where the scroll pane (which
|
||||
* never scrolls horizontally) simply clips them.
|
||||
*/
|
||||
private static void limitWidth(JComponent comp, int preferredWidth) {
|
||||
int height = comp.getPreferredSize().height;
|
||||
comp.setPreferredSize(new Dimension(preferredWidth, height));
|
||||
comp.setMinimumSize(new Dimension(MIN_FIELD_WIDTH, height));
|
||||
}
|
||||
|
||||
private OpusParameters currentOpusParameters() {
|
||||
return new OpusParameters(
|
||||
bitrateSlider.getValue() * 1000,
|
||||
complexitySlider.getValue(),
|
||||
vbrCheck.isSelected(),
|
||||
fecCheck.isSelected(),
|
||||
settings.packetLoss,
|
||||
musicCheck.isSelected());
|
||||
}
|
||||
|
||||
/** Applies the current Opus controls to the running encoder immediately. */
|
||||
private void pushOpusLive() {
|
||||
if (liveMic != null) liveMic.setOpusParameters(currentOpusParameters());
|
||||
}
|
||||
|
||||
private String pushToTalkHotkeyText() {
|
||||
Hotkey ptt = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
|
||||
return ptt == null ? "No hotkey assigned" : hotkeys.display(ptt.combo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push-to-talk is an ordinary hotkey, so this shortcut edits that binding — adding
|
||||
* it when there is none — rather than keeping a key of its own.
|
||||
*/
|
||||
private void editPushToTalkHotkey() {
|
||||
Hotkey existing = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
|
||||
HotkeyDialog dlg = new HotkeyDialog(this, hotkeys,
|
||||
existing == null ? new Hotkey(HotkeyAction.PTT_ACTIVATE, null) : existing);
|
||||
dlg.setVisible(true);
|
||||
if (!dlg.isConfirmed()) return;
|
||||
List<Hotkey> updated = new java.util.ArrayList<>();
|
||||
synchronized (hotkeys.all()) {
|
||||
for (Hotkey h : hotkeys.all()) {
|
||||
if (h != existing) updated.add(h.copy());
|
||||
}
|
||||
}
|
||||
updated.add(dlg.result());
|
||||
hotkeys.replaceAll(updated);
|
||||
pttKeyButton.setText(pushToTalkHotkeyText());
|
||||
hotkeysPanel.reload();
|
||||
}
|
||||
|
||||
/** Copies the audio form into {@code target}, without touching anything else. */
|
||||
/** Copies the audio tabs into {@code target}, without touching anything else. */
|
||||
private void writeAudioSettings(Settings target) {
|
||||
target.inputDevice = comboValue(inputCombo);
|
||||
target.outputDevice = comboValue(outputCombo);
|
||||
target.inputVolume = inputGain.getValue() / 100.0;
|
||||
target.outputVolume = outputVol.getValue() / 100.0;
|
||||
target.denoise = denoiseCheck.isSelected();
|
||||
target.denoiserLevel = denoiseLevel.getValue() / 100.0;
|
||||
target.typingAttenuation = typingCheck.isSelected();
|
||||
target.agc = agcCheck.isSelected();
|
||||
target.inputMode = pttRadio.isSelected() ? Settings.InputMode.PUSH_TO_TALK
|
||||
: contRadio.isSelected() ? Settings.InputMode.CONTINUOUS
|
||||
: Settings.InputMode.VOICE_ACTIVATION;
|
||||
target.vadMode = currentVadMode();
|
||||
target.vadThresholdDb = thresholdSlider.getValue();
|
||||
target.speechThreshold = speechSlider.getValue() / 100.0;
|
||||
target.vadOverPtt = vadOverPttCheck.isSelected();
|
||||
target.bitrate = bitrateSlider.getValue() * 1000;
|
||||
target.complexity = complexitySlider.getValue();
|
||||
target.vbr = vbrCheck.isSelected();
|
||||
target.fec = fecCheck.isSelected();
|
||||
target.music = musicCheck.isSelected();
|
||||
devicesPanel.writeInto(target);
|
||||
voiceActivationPanel.writeInto(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* The audio form as a standalone {@link Settings}, so the test chain runs with the
|
||||
* values currently on screen rather than the ones last saved.
|
||||
* The audio tabs as a standalone {@link Settings}, so the microphone test chain runs
|
||||
* with the values currently on screen rather than the ones last saved.
|
||||
*
|
||||
* <p>The test always runs voice activation: it exists to tune the gate, and push-to-talk
|
||||
* would need the global hotkey, which belongs to the connected microphone.
|
||||
@@ -592,7 +128,7 @@ public final class SettingsDialog extends JDialog {
|
||||
private void apply() {
|
||||
writeAudioSettings(settings);
|
||||
notificationsPanel.apply();
|
||||
iconPackPanel.apply();
|
||||
designPanel.apply();
|
||||
clientVersionPanel.apply();
|
||||
hotkeysPanel.apply();
|
||||
settings.save();
|
||||
@@ -620,134 +156,32 @@ public final class SettingsDialog extends JDialog {
|
||||
/** Leaves without applying, putting back the settings that preview themselves live. */
|
||||
private void cancel() {
|
||||
notificationsPanel.revert();
|
||||
iconPackPanel.revert();
|
||||
designPanel.revert();
|
||||
close();
|
||||
}
|
||||
|
||||
private void close() {
|
||||
micTest.stop();
|
||||
voiceActivationPanel.stopTest();
|
||||
dispose();
|
||||
}
|
||||
|
||||
// ---- microphone test ----
|
||||
|
||||
/**
|
||||
* The test row: a toggle that runs the capture chain, an indicator showing whether the
|
||||
* gate is open, and an optional loopback so you can hear what is being sent.
|
||||
* Applies a live change to the connected microphone and to the microphone test alike.
|
||||
*
|
||||
* <p>Guarded against a null {@code voiceActivationPanel}: the Devices tab applies its
|
||||
* controls once as they are built, which happens before that tab exists.
|
||||
*/
|
||||
private JPanel buildTestControls() {
|
||||
JPanel row = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.LEFT, 8, 0));
|
||||
|
||||
testButton = new JToggleButton("Begin Test");
|
||||
testButton.setToolTipText("Run the capture chain exactly as it runs while connected, "
|
||||
+ "so the bar and the indicator show what would actually be transmitted.");
|
||||
testButton.addActionListener(e -> setTesting(testButton.isSelected()));
|
||||
|
||||
loopbackCheck = new JCheckBox("Hear myself");
|
||||
loopbackCheck.setToolTipText("Play the transmitted audio back through the playback "
|
||||
+ "device. Use headphones to avoid feedback.");
|
||||
loopbackCheck.setEnabled(false);
|
||||
loopbackCheck.addActionListener(e -> micTest.setLoopback(loopbackCheck.isSelected()));
|
||||
|
||||
talkIndicator = new JLabel("Not transmitting", Icons.clientIdle(), JLabel.LEFT);
|
||||
talkIndicator.setToolTipText("Lights up while your microphone is open, exactly as "
|
||||
+ "other users would see you in the channel list.");
|
||||
|
||||
row.add(testButton);
|
||||
row.add(loopbackCheck);
|
||||
row.add(talkIndicator);
|
||||
return row;
|
||||
}
|
||||
|
||||
private void setTesting(boolean on) {
|
||||
if (on && !micTest.start(audioSnapshot())) {
|
||||
testButton.setSelected(false);
|
||||
testButton.setText("Begin Test");
|
||||
loopbackCheck.setEnabled(false);
|
||||
resetTestIndicators();
|
||||
talkIndicator.setText("Capture device unavailable");
|
||||
return;
|
||||
}
|
||||
if (!on) {
|
||||
micTest.stop();
|
||||
loopbackCheck.setSelected(false);
|
||||
resetTestIndicators();
|
||||
}
|
||||
testButton.setText(on ? "Stop Test" : "Begin Test");
|
||||
loopbackCheck.setEnabled(on);
|
||||
}
|
||||
|
||||
private void resetTestIndicators() {
|
||||
onTestTalking(false);
|
||||
onTestLevel(InputLevel.SILENCE_DB);
|
||||
}
|
||||
|
||||
/** Restarts the test chain, if running, so a device change takes effect. */
|
||||
private void restartTest() {
|
||||
if (micTest.isRunning()) {
|
||||
boolean loopback = loopbackCheck.isSelected();
|
||||
if (micTest.start(audioSnapshot())) {
|
||||
micTest.setLoopback(loopback);
|
||||
} else {
|
||||
setTesting(false);
|
||||
testButton.setSelected(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies a live change to the connected microphone and to the test one alike. */
|
||||
private void applyLive(java.util.function.Consumer<VoiceInput> change) {
|
||||
private void applyLive(Consumer<VoiceInput> change) {
|
||||
if (liveMic != null) change.accept(liveMic);
|
||||
micTest.configure(change);
|
||||
if (voiceActivationPanel != null) voiceActivationPanel.configureTest(change);
|
||||
}
|
||||
|
||||
private void onTestLevel(double db) {
|
||||
if (meter != null) meter.setLevel(db);
|
||||
/** Restarts the microphone test, if running, so a device change takes effect. */
|
||||
private void restartTest() {
|
||||
if (voiceActivationPanel != null) voiceActivationPanel.restartTest();
|
||||
}
|
||||
|
||||
private void onTestTalking(boolean talking) {
|
||||
if (meter != null) meter.setTransmitting(talking);
|
||||
if (talkIndicator != null) {
|
||||
talkIndicator.setIcon(talking ? Icons.clientTalking() : Icons.clientIdle());
|
||||
talkIndicator.setText(talking ? "Transmitting" : "Not transmitting");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- small helpers ----
|
||||
|
||||
private static GridBagConstraints gbc() {
|
||||
GridBagConstraints c = new GridBagConstraints();
|
||||
c.insets = new Insets(4, 4, 4, 4);
|
||||
c.anchor = GridBagConstraints.WEST;
|
||||
c.fill = GridBagConstraints.HORIZONTAL;
|
||||
return c;
|
||||
}
|
||||
|
||||
private static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, java.awt.Component field) {
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.weightx = 0;
|
||||
c.gridwidth = 1;
|
||||
p.add(label, c);
|
||||
c.gridx = 1;
|
||||
c.weightx = 1;
|
||||
p.add(field, c);
|
||||
}
|
||||
|
||||
private static void selectOrDefault(JComboBox<AudioDevices.Device> combo, String deviceId) {
|
||||
if (deviceId != null && !deviceId.isEmpty()) {
|
||||
for (int i = 0; i < combo.getItemCount(); i++) {
|
||||
if (deviceId.equals(combo.getItemAt(i).id())) {
|
||||
combo.setSelectedIndex(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
combo.setSelectedIndex(0);
|
||||
}
|
||||
|
||||
private static String comboValue(JComboBox<AudioDevices.Device> combo) {
|
||||
AudioDevices.Device d = (AudioDevices.Device) combo.getSelectedItem();
|
||||
return d == null ? "" : d.id();
|
||||
private void setTestOutputDevice(String deviceId) {
|
||||
if (voiceActivationPanel != null) voiceActivationPanel.setTestOutputDevice(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.net.ChannelNode;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -21,6 +26,9 @@ public final class Spacers {
|
||||
}
|
||||
}
|
||||
|
||||
/** The tag's identifying part, which is what keeps two spacers' names apart. */
|
||||
private static final Pattern TAG = Pattern.compile("^\\[(?:\\*|[lcr])?spacer([^\\]]*)\\]");
|
||||
|
||||
private static final Pattern PATTERN =
|
||||
Pattern.compile("^\\[(\\*|[lcr])?spacer[^\\]]*\\](.*)$");
|
||||
|
||||
@@ -41,6 +49,22 @@ public final class Spacers {
|
||||
return parse(channelName) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A spacer name no channel among {@code siblings} is using: {@code [spacerN]} with the
|
||||
* lowest free N. TeamSpeak needs channel names to be unique, and the number in the tag
|
||||
* is how spacers — which usually all read the same — get away with it.
|
||||
*/
|
||||
public static String freeName(List<ChannelNode> siblings) {
|
||||
Set<String> taken = new HashSet<>();
|
||||
for (ChannelNode sibling : siblings) {
|
||||
Matcher m = TAG.matcher(sibling.name == null ? "" : sibling.name);
|
||||
if (m.find()) taken.add(m.group(1));
|
||||
}
|
||||
for (int n = 0; ; n++) {
|
||||
if (taken.add(Integer.toString(n))) return "[spacer" + n + "]";
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the visible label for a spacer at roughly the given character width. */
|
||||
public static String render(Spacer s, int width) {
|
||||
String caption = s.caption == null ? "" : s.caption;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import java.awt.BorderLayout;
|
||||
|
||||
/** The strip at the bottom of the window: connection status on the left, codec info on the right. */
|
||||
final class StatusBar extends JPanel {
|
||||
|
||||
private final JLabel statusLabel = new JLabel("Not connected");
|
||||
private final JLabel codecLabel = new JLabel();
|
||||
|
||||
StatusBar() {
|
||||
super(new BorderLayout());
|
||||
setBackground(Theme.statusBg());
|
||||
setBorder(BorderFactory.createEmptyBorder(3, 8, 3, 8));
|
||||
statusLabel.setFont(Theme.uiFont());
|
||||
codecLabel.setFont(Theme.uiFont());
|
||||
codecLabel.setForeground(Theme.chatSystem());
|
||||
add(statusLabel, BorderLayout.WEST);
|
||||
add(codecLabel, BorderLayout.EAST);
|
||||
}
|
||||
|
||||
void setStatus(String text) {
|
||||
statusLabel.setText(text);
|
||||
}
|
||||
|
||||
void setCodec(String text) {
|
||||
codecLabel.setText(text);
|
||||
}
|
||||
|
||||
String codecText() {
|
||||
return codecLabel.getText();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JRootPane;
|
||||
import javax.swing.JTabbedPane;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.Timer;
|
||||
import java.awt.AlphaComposite;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Point;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Lets the user reorder a {@link JTabbedPane}'s tabs by dragging one sideways over
|
||||
* another, browser-tab style. Snapshots of every tab are drawn on the window's
|
||||
* glass pane: the dragged one follows the cursor's X only (its Y stays put, and it
|
||||
* can't leave the strip) and stays drawn on top; it swaps with whichever neighbour
|
||||
* it reaches the midpoint of. The real tab model is only updated once, when the
|
||||
* mouse is released, and the overlay eases into its final position on top of it.
|
||||
*
|
||||
* <p>Swing delivers drag events only to whichever component originally got the
|
||||
* press, so every component that should start a drag — the tab strip itself
|
||||
* and each custom tab label — must be {@link #attach}ed individually.
|
||||
*/
|
||||
final class TabDragReorder {
|
||||
|
||||
/** Moves the tab at {@code from} to sit where {@code to} currently is. */
|
||||
interface Reorder {
|
||||
void moveTab(int from, int to);
|
||||
}
|
||||
|
||||
/** Below this many pixels of movement, a press is treated as a click, not a drag. */
|
||||
private static final int THRESHOLD = 5;
|
||||
private static final double EASE = 0.35;
|
||||
private static final double SETTLE_EPSILON = 0.5;
|
||||
private static final int FRAME_MS = 15;
|
||||
|
||||
private final JTabbedPane tabbed;
|
||||
private final Reorder reorder;
|
||||
|
||||
private int pressSlot = -1;
|
||||
private Point pressPoint;
|
||||
private boolean dragging;
|
||||
private boolean releasing;
|
||||
|
||||
private Overlay overlay;
|
||||
private List<Tile> order;
|
||||
private Tile draggedTile;
|
||||
private int dragSlot;
|
||||
private int startSlot;
|
||||
private int stripStartX;
|
||||
private int stripWidth;
|
||||
private int grabDx;
|
||||
private Timer timer;
|
||||
|
||||
TabDragReorder(JTabbedPane tabbed, Reorder reorder) {
|
||||
this.tabbed = tabbed;
|
||||
this.reorder = reorder;
|
||||
}
|
||||
|
||||
void attach(JComponent source) {
|
||||
MouseAdapter listener = new MouseAdapter() {
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e) {
|
||||
Point inTabbed = SwingUtilities.convertPoint(e.getComponent(), e.getPoint(), tabbed);
|
||||
pressSlot = tabbed.indexAtLocation(inTabbed.x, inTabbed.y);
|
||||
pressPoint = inTabbed;
|
||||
dragging = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseDragged(MouseEvent e) {
|
||||
if (pressSlot < 0) return;
|
||||
Point inTabbed = SwingUtilities.convertPoint(e.getComponent(), e.getPoint(), tabbed);
|
||||
if (!dragging) {
|
||||
if (pressPoint.distance(inTabbed) < THRESHOLD) return;
|
||||
dragging = beginDrag(inTabbed);
|
||||
if (!dragging) return;
|
||||
}
|
||||
dragTo(inTabbed.x);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(MouseEvent e) {
|
||||
if (dragging) endDrag();
|
||||
pressSlot = -1;
|
||||
dragging = false;
|
||||
}
|
||||
};
|
||||
source.addMouseListener(listener);
|
||||
source.addMouseMotionListener(listener);
|
||||
}
|
||||
|
||||
/** Snapshots every tab and shows them on the glass pane in place of the real strip. */
|
||||
private boolean beginDrag(Point inTabbed) {
|
||||
JRootPane root = tabbed.getRootPane();
|
||||
int n = tabbed.getTabCount();
|
||||
if (root == null || pressSlot < 0 || pressSlot >= n) return false;
|
||||
|
||||
order = new ArrayList<>(n);
|
||||
for (int i = 0; i < n; i++) {
|
||||
Rectangle b = tabbed.getBoundsAt(i);
|
||||
if (b == null || b.isEmpty()) return false;
|
||||
order.add(new Tile(snapshot(b), b.x, b.y, b.width, b.height));
|
||||
}
|
||||
stripStartX = order.get(0).x;
|
||||
stripWidth = 0;
|
||||
for (Tile t : order) stripWidth += t.width;
|
||||
draggedTile = order.get(pressSlot);
|
||||
dragSlot = pressSlot;
|
||||
startSlot = pressSlot;
|
||||
grabDx = inTabbed.x - draggedTile.x;
|
||||
releasing = false;
|
||||
|
||||
if (!(root.getGlassPane() instanceof Overlay)) {
|
||||
root.setGlassPane(new Overlay());
|
||||
}
|
||||
overlay = (Overlay) root.getGlassPane();
|
||||
overlay.origin = SwingUtilities.convertPoint(tabbed, new Point(0, 0), overlay);
|
||||
overlay.tiles = order;
|
||||
overlay.onTop = draggedTile;
|
||||
overlay.setVisible(true);
|
||||
overlay.repaint();
|
||||
|
||||
timer = new Timer(FRAME_MS, e -> tick());
|
||||
timer.start();
|
||||
return true;
|
||||
}
|
||||
|
||||
private BufferedImage snapshot(Rectangle bounds) {
|
||||
BufferedImage img = new BufferedImage(bounds.width, bounds.height, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
g.setClip(0, 0, bounds.width, bounds.height);
|
||||
g.translate(-bounds.x, -bounds.y);
|
||||
tabbed.paint(g);
|
||||
g.dispose();
|
||||
return img;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the dragged tile with whichever neighbour it has reached the midpoint
|
||||
* of: its trailing edge past the next tile's midpoint when moving right, or its
|
||||
* leading edge past the previous tile's midpoint when moving left. Checked
|
||||
* against the neighbour's target slot rather than its live position, since that
|
||||
* may itself still be mid-animation.
|
||||
*/
|
||||
private void dragTo(int cursorX) {
|
||||
int min = stripStartX;
|
||||
int max = stripStartX + stripWidth - draggedTile.width;
|
||||
draggedTile.currentX = Math.max(min, Math.min(max, cursorX - grabDx));
|
||||
|
||||
if (dragSlot + 1 < order.size()) {
|
||||
Tile next = order.get(dragSlot + 1);
|
||||
double rightEdge = draggedTile.currentX + draggedTile.width;
|
||||
if (rightEdge > next.targetX + next.width / 2.0) {
|
||||
swap(dragSlot, dragSlot + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (dragSlot - 1 >= 0) {
|
||||
Tile prev = order.get(dragSlot - 1);
|
||||
double leftEdge = draggedTile.currentX;
|
||||
if (leftEdge < prev.targetX + prev.width / 2.0) {
|
||||
swap(dragSlot - 1, dragSlot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Swaps the two adjacent slots {@code a} and {@code b} (one of them the dragged tile's) in {@link #order}, not the real tab model. */
|
||||
private void swap(int a, int b) {
|
||||
int newSlot = dragSlot == a ? b : a;
|
||||
order.remove(draggedTile);
|
||||
order.add(newSlot, draggedTile);
|
||||
dragSlot = newSlot;
|
||||
retarget(false);
|
||||
}
|
||||
|
||||
/** Assigns each tile's slot target from the current {@code order}; the dragged tile follows the cursor instead, unless {@code includeDragged}. */
|
||||
private void retarget(boolean includeDragged) {
|
||||
double x = stripStartX;
|
||||
for (Tile t : order) {
|
||||
if (t != draggedTile || includeDragged) t.targetX = x;
|
||||
x += t.width;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits the reorder to the real tab model in one shot. Doing this once here,
|
||||
* rather than per swap while dragging, matters because reordering the real
|
||||
* model can rebuild tab components (new labels, new listeners), which would
|
||||
* otherwise yank the component out from under an in-flight mouse grab.
|
||||
*/
|
||||
private void endDrag() {
|
||||
releasing = true;
|
||||
if (dragSlot != startSlot) reorder.moveTab(startSlot, dragSlot);
|
||||
retarget(true);
|
||||
}
|
||||
|
||||
private void tick() {
|
||||
boolean settled = true;
|
||||
for (Tile t : order) {
|
||||
if (t == draggedTile && !releasing) continue;
|
||||
double diff = t.targetX - t.currentX;
|
||||
if (Math.abs(diff) < SETTLE_EPSILON) {
|
||||
t.currentX = t.targetX;
|
||||
} else {
|
||||
t.currentX += diff * EASE;
|
||||
settled = false;
|
||||
}
|
||||
}
|
||||
overlay.repaint();
|
||||
if (releasing && settled) {
|
||||
timer.stop();
|
||||
overlay.tiles = null;
|
||||
overlay.onTop = null;
|
||||
overlay.setVisible(false);
|
||||
order = null;
|
||||
draggedTile = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** One tab's snapshot, animating from {@link #currentX} toward {@link #targetX}. */
|
||||
private static final class Tile {
|
||||
final BufferedImage image;
|
||||
final int x, y, width, height;
|
||||
double currentX;
|
||||
double targetX;
|
||||
|
||||
Tile(BufferedImage image, int x, int y, int width, int height) {
|
||||
this.image = image;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.currentX = x;
|
||||
this.targetX = x;
|
||||
}
|
||||
}
|
||||
|
||||
/** A transparent overlay on the glass pane that paints every tab's animated snapshot. */
|
||||
private static final class Overlay extends JComponent {
|
||||
List<Tile> tiles;
|
||||
/** Painted last (on top) so it stays above tabs it's currently overlapping while dragged. */
|
||||
Tile onTop;
|
||||
Point origin = new Point();
|
||||
|
||||
Overlay() {
|
||||
setOpaque(false);
|
||||
}
|
||||
|
||||
/** Never claims mouse events, so drags keep reaching the component that was actually pressed. */
|
||||
@Override
|
||||
public boolean contains(int x, int y) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g) {
|
||||
if (tiles == null) return;
|
||||
Graphics2D g2 = (Graphics2D) g.create();
|
||||
g2.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.9f));
|
||||
for (Tile t : tiles) {
|
||||
if (t != onTop) draw(g2, t);
|
||||
}
|
||||
if (onTop != null) draw(g2, onTop);
|
||||
g2.dispose();
|
||||
}
|
||||
|
||||
private void draw(Graphics2D g2, Tile t) {
|
||||
g2.drawImage(t.image, origin.x + (int) Math.round(t.currentX), origin.y + t.y, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,156 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.formdev.flatlaf.ui.FlatUIUtils;
|
||||
|
||||
import javax.swing.UIManager;
|
||||
import java.awt.Color;
|
||||
import java.awt.Font;
|
||||
|
||||
/** Central palette + fonts approximating the light TeamSpeak 3 look. */
|
||||
/**
|
||||
* The palette and fonts the client paints with, on top of the active look-and-feel.
|
||||
*
|
||||
* <p>Surfaces (window, tree, chat background) and fonts come straight from the
|
||||
* look-and-feel, so the client follows whatever FlatLaf theme is installed. The
|
||||
* status colours TeamSpeak gives meaning to — talking, away, muted, channel and
|
||||
* server names — are ours, in a light and a dark variant; {@link #refresh()} picks
|
||||
* the variant matching the installed theme.
|
||||
*/
|
||||
public final class Theme {
|
||||
|
||||
public static final Color WINDOW_BG = new Color(0xF0F0F0);
|
||||
public static final Color TREE_BG = new Color(0xFFFFFF);
|
||||
public static final Color TREE_SELECTION = new Color(0xCFE3FB);
|
||||
public static final Color TREE_TEXT = new Color(0x1E1E1E);
|
||||
public static final Color CHANNEL_TEXT = new Color(0x21486B);
|
||||
public static final Color SERVER_TEXT = new Color(0x123456);
|
||||
|
||||
public static final Color TALKING = new Color(0x33B35A);
|
||||
public static final Color IDLE_CLIENT = new Color(0x6E7B87);
|
||||
public static final Color AWAY = new Color(0xC98A1B);
|
||||
public static final Color MUTED = new Color(0xC0392B);
|
||||
|
||||
public static final Color TOOLBAR_BG = new Color(0xE6E9ED);
|
||||
public static final Color STATUS_BG = new Color(0xE6E9ED);
|
||||
public static final Color ACCENT = new Color(0x2C7BE5);
|
||||
|
||||
public static final Color CHAT_BG = new Color(0xFAFAFA);
|
||||
public static final Color CHAT_SYSTEM = new Color(0x8A8A8A);
|
||||
public static final Color CHAT_NAME = new Color(0x2C7BE5);
|
||||
public static final Color CHAT_TEXT = new Color(0x202020);
|
||||
/** Links that leave the client, distinct from the colour used for identities. */
|
||||
public static final Color LINK = new Color(0x1A5FB4);
|
||||
|
||||
public static final Font UI_FONT = new Font("SansSerif", Font.PLAIN, 12);
|
||||
public static final Font UI_BOLD = new Font("SansSerif", Font.BOLD, 12);
|
||||
private static boolean dark;
|
||||
|
||||
private Theme() {
|
||||
}
|
||||
|
||||
/** Re-reads the installed look-and-feel; call after changing it. */
|
||||
static void refresh() {
|
||||
Color bg = UIManager.getColor("Panel.background");
|
||||
dark = bg != null && luminance(bg) < 0.5;
|
||||
}
|
||||
|
||||
public static boolean isDark() {
|
||||
return dark;
|
||||
}
|
||||
|
||||
// ---- fonts ----
|
||||
|
||||
public static Font uiFont() {
|
||||
Font f = UIManager.getFont("Label.font");
|
||||
return f != null ? f : new Font("SansSerif", Font.PLAIN, 12);
|
||||
}
|
||||
|
||||
public static Font uiBold() {
|
||||
return uiFont().deriveFont(Font.BOLD);
|
||||
}
|
||||
|
||||
// ---- surfaces, from the look-and-feel ----
|
||||
|
||||
public static Color windowBg() {
|
||||
return ui("Panel.background", 0xF0F0F0, 0x282828);
|
||||
}
|
||||
|
||||
public static Color toolbarBg() {
|
||||
return ui("ToolBar.background", 0xE6E9ED, 0x282828);
|
||||
}
|
||||
|
||||
public static Color statusBg() {
|
||||
return windowBg();
|
||||
}
|
||||
|
||||
public static Color treeBg() {
|
||||
return ui("Tree.background", 0xFFFFFF, 0x1E1E1E);
|
||||
}
|
||||
|
||||
public static Color treeText() {
|
||||
return ui("Tree.foreground", 0x1E1E1E, 0xE0E0E0);
|
||||
}
|
||||
|
||||
public static Color treeSelection() {
|
||||
return ui("Tree.selectionBackground", 0xCFE3FB, 0x364B6F);
|
||||
}
|
||||
|
||||
/**
|
||||
* Behind the row the pointer is over: the same wash the menu bar puts behind a hovered
|
||||
* menu. The look-and-feel states it as a shift of whatever surface it sits on, and the
|
||||
* menu bar resolves it against the window, so this does too — a row hover and a menu
|
||||
* hover then carry the very same colour.
|
||||
*/
|
||||
public static Color hover() {
|
||||
return FlatUIUtils.deriveColor(ui("MenuBar.hoverBackground", 0xE6E6E6, 0x3D3D3D), windowBg());
|
||||
}
|
||||
|
||||
public static Color chatBg() {
|
||||
return ui("TextPane.background", 0xFAFAFA, 0x1E1E1E);
|
||||
}
|
||||
|
||||
public static Color border() {
|
||||
return ui("Component.borderColor", 0xD0D0D0, 0x3E3E3E);
|
||||
}
|
||||
|
||||
public static Color chatText() {
|
||||
return ui("TextPane.foreground", 0x202020, 0xE0E0E0);
|
||||
}
|
||||
|
||||
// ---- status colours ----
|
||||
|
||||
public static Color channelText() {
|
||||
return pick(0x21486B, 0xFFFFFF);
|
||||
}
|
||||
|
||||
/** The colour of a chat line about what happened on the server. */
|
||||
public static Color chatEvent() {
|
||||
return pick(0x21486B, 0x8FBCE6);
|
||||
}
|
||||
|
||||
public static Color serverText() {
|
||||
return pick(0x123456, 0xDCE8F5);
|
||||
}
|
||||
|
||||
public static Color talking() {
|
||||
return pick(0x33B35A, 0x4CC479);
|
||||
}
|
||||
|
||||
public static Color idleClient() {
|
||||
return pick(0x6E7B87, 0x9AA6B2);
|
||||
}
|
||||
|
||||
public static Color away() {
|
||||
return pick(0xC98A1B, 0xE0A83C);
|
||||
}
|
||||
|
||||
public static Color muted() {
|
||||
return pick(0xC0392B, 0xE05C4C);
|
||||
}
|
||||
|
||||
public static Color accent() {
|
||||
return pick(0x2C7BE5, 0x5C9BEE);
|
||||
}
|
||||
|
||||
public static Color chatSystem() {
|
||||
return pick(0x8A8A8A, 0x9A9A9A);
|
||||
}
|
||||
|
||||
public static Color chatName() {
|
||||
return accent();
|
||||
}
|
||||
|
||||
/** Links that leave the client, distinct from the colour used for identities. */
|
||||
public static Color link() {
|
||||
return pick(0x1A5FB4, 0x6EA8FF);
|
||||
}
|
||||
|
||||
private static Color pick(int light, int darkRgb) {
|
||||
return new Color(dark ? darkRgb : light);
|
||||
}
|
||||
|
||||
private static Color ui(String key, int lightFallback, int darkFallback) {
|
||||
// Returned as the look-and-feel's own UIResource colour, so components painted with
|
||||
// it are recoloured automatically when the look-and-feel is switched.
|
||||
Color c = UIManager.getColor(key);
|
||||
return c != null ? c : pick(lightFallback, darkFallback);
|
||||
}
|
||||
|
||||
private static double luminance(Color c) {
|
||||
return (0.2126 * c.getRed() + 0.7152 * c.getGreen() + 0.0722 * c.getBlue()) / 255.0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.audio.InputLevel;
|
||||
import com.ts3client.audio.OpusParameters;
|
||||
import com.ts3client.audio.VoiceInput;
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.hotkey.Hotkey;
|
||||
import com.ts3client.hotkey.HotkeyAction;
|
||||
|
||||
import javax.swing.Box;
|
||||
import javax.swing.ButtonGroup;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JRadioButton;
|
||||
import javax.swing.JSlider;
|
||||
import javax.swing.JToggleButton;
|
||||
import java.awt.GridBagConstraints;
|
||||
import java.awt.Insets;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Options dialog's "Voice Activation" tab: input mode (VAD / push-to-talk / continuous),
|
||||
* the detection tuning with a live input meter, and the Opus encoder controls.
|
||||
*
|
||||
* <p>Owns the microphone test (start/stop button, loopback, indicator): it runs a real
|
||||
* capture chain built from the form's current values, plus whatever the "Playback / Capture"
|
||||
* tab currently has on screen, via {@code audioSnapshot}.
|
||||
*/
|
||||
final class VoiceActivationPanel extends FormPanel {
|
||||
|
||||
private static final int MIN_BITRATE_KBITS = 8;
|
||||
private static final int MAX_BITRATE_KBITS = 160;
|
||||
|
||||
private final Settings settings;
|
||||
private final VoiceInput liveMic;
|
||||
private final HotkeyService hotkeys;
|
||||
private final HotkeysPanel hotkeysPanel;
|
||||
private final Supplier<Settings> audioSnapshot;
|
||||
private final MicrophoneTest micTest = new MicrophoneTest(this::onTestLevel, this::onTestTalking);
|
||||
|
||||
private final JRadioButton vadRadio;
|
||||
private final JRadioButton pttRadio;
|
||||
private final JRadioButton contRadio;
|
||||
private final JComboBox<String> vadModeCombo;
|
||||
private final JSlider thresholdSlider;
|
||||
private final JSlider speechSlider;
|
||||
private final JCheckBox vadOverPttCheck;
|
||||
private final LevelMeter meter;
|
||||
private JToggleButton testButton;
|
||||
private JCheckBox loopbackCheck;
|
||||
private JLabel talkIndicator;
|
||||
private final JButton pttKeyButton;
|
||||
private final JSlider bitrateSlider;
|
||||
private final JSlider complexitySlider;
|
||||
private final JCheckBox vbrCheck;
|
||||
private final JCheckBox fecCheck;
|
||||
private final JCheckBox musicCheck;
|
||||
|
||||
VoiceActivationPanel(Settings settings, VoiceInput liveMic, HotkeyService hotkeys,
|
||||
HotkeysPanel hotkeysPanel, Supplier<Settings> audioSnapshot) {
|
||||
this.settings = settings;
|
||||
this.liveMic = liveMic;
|
||||
this.hotkeys = hotkeys;
|
||||
this.hotkeysPanel = hotkeysPanel;
|
||||
this.audioSnapshot = audioSnapshot;
|
||||
|
||||
GridBagConstraints c = gbc();
|
||||
|
||||
vadRadio = new JRadioButton("Voice Activation Detection");
|
||||
pttRadio = new JRadioButton("Push-To-Talk");
|
||||
contRadio = new JRadioButton("Continuous");
|
||||
ButtonGroup group = new ButtonGroup();
|
||||
group.add(vadRadio);
|
||||
group.add(pttRadio);
|
||||
group.add(contRadio);
|
||||
switch (settings.inputMode) {
|
||||
case PUSH_TO_TALK:
|
||||
pttRadio.setSelected(true);
|
||||
break;
|
||||
case CONTINUOUS:
|
||||
contRadio.setSelected(true);
|
||||
break;
|
||||
default:
|
||||
vadRadio.setSelected(true);
|
||||
}
|
||||
|
||||
int row = 0;
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
add(vadRadio, c);
|
||||
c.gridy = row++;
|
||||
add(pttRadio, c);
|
||||
c.gridy = row++;
|
||||
add(contRadio, c);
|
||||
c.gridwidth = 1;
|
||||
|
||||
meter = new LevelMeter();
|
||||
meter.setThreshold(settings.vadThresholdDb);
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.gridwidth = 2;
|
||||
c.insets = new Insets(10, 4, 2, 4);
|
||||
add(new JLabel("Input level:"), c);
|
||||
c.gridy = ++row;
|
||||
add(meter, c);
|
||||
c.insets = new Insets(4, 4, 4, 4);
|
||||
c.gridy = ++row;
|
||||
add(buildTestControls(), c);
|
||||
c.gridwidth = 1;
|
||||
row++;
|
||||
|
||||
vadModeCombo = new JComboBox<>(new String[]{"Automatic", "Volume Gate", "Hybrid"});
|
||||
vadModeCombo.setSelectedIndex(vadModeIndex(settings.vadMode));
|
||||
vadModeCombo.addActionListener(e -> micTest.configure(m -> m.setVadMode(currentVadMode())));
|
||||
vadModeCombo.setToolTipText("<html><b>Automatic</b>: intelligent speech detection.<br>"
|
||||
+ "<b>Volume Gate</b>: transmit when loud enough.<br>"
|
||||
+ "<b>Hybrid</b>: loud enough <i>and</i> detected as speech.</html>");
|
||||
addRow(this, c, row++, new JLabel("Detection:"), vadModeCombo);
|
||||
|
||||
thresholdSlider = new JSlider((int) InputLevel.MIN_DB, (int) InputLevel.MAX_DB,
|
||||
(int) Math.round(settings.vadThresholdDb));
|
||||
JLabel thresholdLabel = new JLabel(Math.round(settings.vadThresholdDb) + " dB");
|
||||
thresholdSlider.addChangeListener(e -> {
|
||||
meter.setThreshold(thresholdSlider.getValue());
|
||||
thresholdLabel.setText(thresholdSlider.getValue() + " dB");
|
||||
applyLive(m -> m.setThresholdDb(thresholdSlider.getValue()));
|
||||
});
|
||||
addRow(this, c, row++, new JLabel("Volume gate:"), sliderWithLabel(thresholdSlider, thresholdLabel));
|
||||
|
||||
speechSlider = new JSlider(0, 100, (int) Math.round(settings.speechThreshold * 100));
|
||||
JLabel speechLabel = new JLabel(Math.round(settings.speechThreshold * 100) + "%");
|
||||
speechSlider.addChangeListener(e -> {
|
||||
speechLabel.setText(speechSlider.getValue() + "%");
|
||||
applyLive(m -> m.setSpeechThreshold(speechSlider.getValue() / 100.0));
|
||||
});
|
||||
addRow(this, c, row++, new JLabel("Speech threshold:"), sliderWithLabel(speechSlider, speechLabel));
|
||||
|
||||
pttKeyButton = new JButton(pushToTalkHotkeyText());
|
||||
pttKeyButton.setToolTipText("Push-to-talk is a global hotkey; this opens its binding");
|
||||
pttKeyButton.addActionListener(e -> editPushToTalkHotkey());
|
||||
addRow(this, c, row++, new JLabel("Push-to-talk hotkey:"), pttKeyButton);
|
||||
|
||||
vadOverPttCheck = new JCheckBox("Also detect voice while push-to-talk", settings.vadOverPtt);
|
||||
vadOverPttCheck.addActionListener(e ->
|
||||
micTest.configure(m -> m.setVadOverPtt(vadOverPttCheck.isSelected())));
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
add(vadOverPttCheck, c);
|
||||
c.gridwidth = 1;
|
||||
|
||||
int kbits = Math.max(MIN_BITRATE_KBITS, Math.min(MAX_BITRATE_KBITS, settings.bitrate / 1000));
|
||||
bitrateSlider = new JSlider(MIN_BITRATE_KBITS, MAX_BITRATE_KBITS, kbits);
|
||||
JLabel bitrateLabel = new JLabel(kbits + " kbit/s");
|
||||
bitrateSlider.addChangeListener(e -> {
|
||||
bitrateLabel.setText(bitrateSlider.getValue() + " kbit/s");
|
||||
pushOpusLive();
|
||||
});
|
||||
addRow(this, c, row++, new JLabel("Opus bitrate:"),
|
||||
sliderWithLabel(bitrateSlider, bitrateLabel, 70));
|
||||
|
||||
complexitySlider = new JSlider(0, 10, settings.complexity);
|
||||
limitWidth(complexitySlider, SLIDER_WIDTH);
|
||||
complexitySlider.addChangeListener(e -> pushOpusLive());
|
||||
addRow(this, c, row++, new JLabel("Opus complexity:"), complexitySlider);
|
||||
|
||||
vbrCheck = new JCheckBox("Variable bitrate (VBR)", settings.vbr);
|
||||
fecCheck = new JCheckBox("Forward error correction (FEC)", settings.fec);
|
||||
musicCheck = new JCheckBox("Music codec (stereo, higher fidelity)", settings.music);
|
||||
musicCheck.setToolTipText("<html>Transmits <b>OPUS_MUSIC</b>: stereo when the capture "
|
||||
+ "device has two channels, and without the voice pre-processing "
|
||||
+ "(noise removal, typing attenuation, AGC).<br>"
|
||||
+ "Voice mode (<b>OPUS_VOICE</b>) is mono, as in the official client.</html>");
|
||||
vbrCheck.addActionListener(e -> pushOpusLive());
|
||||
fecCheck.addActionListener(e -> pushOpusLive());
|
||||
musicCheck.addActionListener(e -> pushOpusLive());
|
||||
c.gridx = 0;
|
||||
c.gridy = row++;
|
||||
c.gridwidth = 2;
|
||||
add(vbrCheck, c);
|
||||
c.gridy = row++;
|
||||
add(fecCheck, c);
|
||||
c.gridy = row++;
|
||||
add(musicCheck, c);
|
||||
c.gridwidth = 1;
|
||||
|
||||
Runnable syncEnabled = () -> {
|
||||
boolean vad = vadRadio.isSelected();
|
||||
boolean ptt = pttRadio.isSelected();
|
||||
boolean vadContext = vad || (ptt && vadOverPttCheck.isSelected());
|
||||
Settings.VadMode vm = currentVadMode();
|
||||
boolean usesGate = vm != Settings.VadMode.AUTOMATIC;
|
||||
boolean usesSpeech = vm != Settings.VadMode.VOLUME_GATE;
|
||||
|
||||
vadModeCombo.setEnabled(vadContext);
|
||||
thresholdSlider.setEnabled(vadContext && usesGate);
|
||||
speechSlider.setEnabled(vadContext && usesSpeech);
|
||||
pttKeyButton.setEnabled(ptt);
|
||||
vadOverPttCheck.setEnabled(ptt);
|
||||
meter.setShowThreshold(vadContext && usesGate);
|
||||
|
||||
if (liveMic != null) {
|
||||
liveMic.setMode(ptt ? Settings.InputMode.PUSH_TO_TALK
|
||||
: vad ? Settings.InputMode.VOICE_ACTIVATION
|
||||
: Settings.InputMode.CONTINUOUS);
|
||||
liveMic.setVadMode(vm);
|
||||
liveMic.setVadOverPtt(vadOverPttCheck.isSelected());
|
||||
}
|
||||
};
|
||||
vadRadio.addActionListener(e -> syncEnabled.run());
|
||||
pttRadio.addActionListener(e -> syncEnabled.run());
|
||||
contRadio.addActionListener(e -> syncEnabled.run());
|
||||
vadModeCombo.addActionListener(e -> syncEnabled.run());
|
||||
vadOverPttCheck.addActionListener(e -> syncEnabled.run());
|
||||
syncEnabled.run();
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = row;
|
||||
c.weighty = 1;
|
||||
add(Box.createGlue(), c);
|
||||
}
|
||||
|
||||
/** Copies the form into {@code target}, without touching anything else. */
|
||||
void writeInto(Settings target) {
|
||||
target.inputMode = pttRadio.isSelected() ? Settings.InputMode.PUSH_TO_TALK
|
||||
: contRadio.isSelected() ? Settings.InputMode.CONTINUOUS
|
||||
: Settings.InputMode.VOICE_ACTIVATION;
|
||||
target.vadMode = currentVadMode();
|
||||
target.vadThresholdDb = thresholdSlider.getValue();
|
||||
target.speechThreshold = speechSlider.getValue() / 100.0;
|
||||
target.vadOverPtt = vadOverPttCheck.isSelected();
|
||||
target.bitrate = bitrateSlider.getValue() * 1000;
|
||||
target.complexity = complexitySlider.getValue();
|
||||
target.vbr = vbrCheck.isSelected();
|
||||
target.fec = fecCheck.isSelected();
|
||||
target.music = musicCheck.isSelected();
|
||||
}
|
||||
|
||||
/** Applies a change to the microphone test, if it is running; used by the Devices tab too. */
|
||||
void configureTest(Consumer<VoiceInput> change) {
|
||||
micTest.configure(change);
|
||||
}
|
||||
|
||||
/** The playback device to loop the test through; used by the Devices tab's output picker. */
|
||||
void setTestOutputDevice(String deviceId) {
|
||||
micTest.setOutputDevice(deviceId);
|
||||
}
|
||||
|
||||
/** Restarts the test chain, if running, so a device change on the Devices tab takes effect. */
|
||||
void restartTest() {
|
||||
if (micTest.isRunning()) {
|
||||
boolean loopback = loopbackCheck.isSelected();
|
||||
if (micTest.start(audioSnapshot.get())) {
|
||||
micTest.setLoopback(loopback);
|
||||
} else {
|
||||
setTesting(false);
|
||||
testButton.setSelected(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void stopTest() {
|
||||
micTest.stop();
|
||||
}
|
||||
|
||||
private static int vadModeIndex(Settings.VadMode m) {
|
||||
switch (m) {
|
||||
case AUTOMATIC:
|
||||
return 0;
|
||||
case VOLUME_GATE:
|
||||
return 1;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private Settings.VadMode currentVadMode() {
|
||||
switch (vadModeCombo.getSelectedIndex()) {
|
||||
case 0:
|
||||
return Settings.VadMode.AUTOMATIC;
|
||||
case 1:
|
||||
return Settings.VadMode.VOLUME_GATE;
|
||||
default:
|
||||
return Settings.VadMode.HYBRID;
|
||||
}
|
||||
}
|
||||
|
||||
private OpusParameters currentOpusParameters() {
|
||||
return new OpusParameters(
|
||||
bitrateSlider.getValue() * 1000,
|
||||
complexitySlider.getValue(),
|
||||
vbrCheck.isSelected(),
|
||||
fecCheck.isSelected(),
|
||||
settings.packetLoss,
|
||||
musicCheck.isSelected());
|
||||
}
|
||||
|
||||
/** Applies the current Opus controls to the running encoder immediately. */
|
||||
private void pushOpusLive() {
|
||||
if (liveMic != null) liveMic.setOpusParameters(currentOpusParameters());
|
||||
}
|
||||
|
||||
private String pushToTalkHotkeyText() {
|
||||
Hotkey ptt = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
|
||||
return ptt == null ? "No hotkey assigned" : hotkeys.display(ptt.combo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push-to-talk is an ordinary hotkey, so this shortcut edits that binding — adding
|
||||
* it when there is none — rather than keeping a key of its own.
|
||||
*/
|
||||
private void editPushToTalkHotkey() {
|
||||
Hotkey existing = hotkeys.find(HotkeyAction.PTT_ACTIVATE);
|
||||
HotkeyDialog dlg = new HotkeyDialog(javax.swing.SwingUtilities.getWindowAncestor(this),
|
||||
hotkeys, existing == null ? new Hotkey(HotkeyAction.PTT_ACTIVATE, null) : existing);
|
||||
dlg.setVisible(true);
|
||||
if (!dlg.isConfirmed()) return;
|
||||
List<Hotkey> updated = new java.util.ArrayList<>();
|
||||
synchronized (hotkeys.all()) {
|
||||
for (Hotkey h : hotkeys.all()) {
|
||||
if (h != existing) updated.add(h.copy());
|
||||
}
|
||||
}
|
||||
updated.add(dlg.result());
|
||||
hotkeys.replaceAll(updated);
|
||||
pttKeyButton.setText(pushToTalkHotkeyText());
|
||||
hotkeysPanel.reload();
|
||||
}
|
||||
|
||||
/** Applies a live change to the connected microphone and to the test one alike. */
|
||||
private void applyLive(Consumer<VoiceInput> change) {
|
||||
if (liveMic != null) change.accept(liveMic);
|
||||
micTest.configure(change);
|
||||
}
|
||||
|
||||
// ---- microphone test ----
|
||||
|
||||
/**
|
||||
* The test row: a toggle that runs the capture chain, an indicator showing whether the
|
||||
* gate is open, and an optional loopback so you can hear what is being sent.
|
||||
*/
|
||||
private JPanel buildTestControls() {
|
||||
JPanel row = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.LEFT, 8, 0));
|
||||
|
||||
testButton = new JToggleButton("Begin Test");
|
||||
testButton.setToolTipText("Run the capture chain exactly as it runs while connected, "
|
||||
+ "so the bar and the indicator show what would actually be transmitted.");
|
||||
testButton.addActionListener(e -> setTesting(testButton.isSelected()));
|
||||
|
||||
loopbackCheck = new JCheckBox("Hear myself");
|
||||
loopbackCheck.setToolTipText("Play the transmitted audio back through the playback "
|
||||
+ "device. Use headphones to avoid feedback.");
|
||||
loopbackCheck.setEnabled(false);
|
||||
loopbackCheck.addActionListener(e -> micTest.setLoopback(loopbackCheck.isSelected()));
|
||||
|
||||
talkIndicator = new JLabel("Not transmitting", Icons.clientIdle(), JLabel.LEFT);
|
||||
talkIndicator.setToolTipText("Lights up while your microphone is open, exactly as "
|
||||
+ "other users would see you in the channel list.");
|
||||
|
||||
row.add(testButton);
|
||||
row.add(loopbackCheck);
|
||||
row.add(talkIndicator);
|
||||
return row;
|
||||
}
|
||||
|
||||
private void setTesting(boolean on) {
|
||||
if (on && !micTest.start(audioSnapshot.get())) {
|
||||
testButton.setSelected(false);
|
||||
testButton.setText("Begin Test");
|
||||
loopbackCheck.setEnabled(false);
|
||||
resetTestIndicators();
|
||||
talkIndicator.setText("Capture device unavailable");
|
||||
return;
|
||||
}
|
||||
if (!on) {
|
||||
micTest.stop();
|
||||
loopbackCheck.setSelected(false);
|
||||
resetTestIndicators();
|
||||
}
|
||||
testButton.setText(on ? "Stop Test" : "Begin Test");
|
||||
loopbackCheck.setEnabled(on);
|
||||
}
|
||||
|
||||
private void resetTestIndicators() {
|
||||
onTestTalking(false);
|
||||
onTestLevel(InputLevel.SILENCE_DB);
|
||||
}
|
||||
|
||||
private void onTestLevel(double db) {
|
||||
if (meter != null) meter.setLevel(db);
|
||||
}
|
||||
|
||||
private void onTestTalking(boolean talking) {
|
||||
if (meter != null) meter.setTransmitting(talking);
|
||||
if (talkIndicator != null) {
|
||||
talkIndicator.setIcon(talking ? Icons.clientTalking() : Icons.clientIdle());
|
||||
talkIndicator.setText(talking ? "Transmitting" : "Not transmitting");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#
|
||||
# A darker dark theme than FlatLaf's own, keyed to the toolbar's #282828.
|
||||
#
|
||||
# Three levels of surface: the window chrome (toolbar, panels, status bar) at
|
||||
# @background, the tab strips one step below it, and the content the client shows
|
||||
# (channel tree, chat log, text fields, lists) one step below those.
|
||||
#
|
||||
|
||||
@background = #282828
|
||||
@foreground = #e0e0e0
|
||||
@componentBackground = #1e1e1e
|
||||
@menuBackground = #232323
|
||||
|
||||
# Selected channel tree row, and every other selection with it.
|
||||
@selectionBackground = #364b6f
|
||||
|
||||
TabbedPane.background = #232323
|
||||
@@ -0,0 +1,14 @@
|
||||
#
|
||||
# Look-and-feel defaults for both themes. Loaded by FlatLaf itself, on top of the
|
||||
# theme's own properties; see LookAndFeelManager.
|
||||
#
|
||||
|
||||
# Softer corners than FlatLaf's default, and a tab strip with room to breathe.
|
||||
Component.arc = 6
|
||||
Button.arc = 6
|
||||
Component.focusWidth = 1
|
||||
TabbedPane.tabHeight = 26
|
||||
|
||||
# A read-only editor pane is a chat log or an information view here, not a disabled
|
||||
# input, so it keeps the content background instead of the window's.
|
||||
EditorPane.inactiveBackground = @componentBackground
|
||||
2
ts3j
2
ts3j
Submodule ts3j updated: 0e5724b877...49f2ce874d
Reference in New Issue
Block a user