From 2a73900dcca998be5572cbe347928f78ad59e3e7 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Wed, 23 Sep 2026 19:03:00 +0000 Subject: [PATCH] Show client avatars and let the user set their own Avatars are fetched from the server's file repository as /avatar_, verified against client_flag_avatar and cached on disk; animated GIFs play in the info panel. Self > Set avatar... uploads or removes our own picture within the server's size limit. Co-Authored-By: Claude Opus 5.5 --- ts3-client/README.md | 5 + .../java/com/ts3client/net/AvatarAdmin.java | 103 ++++++++++++ .../java/com/ts3client/net/ClientEntry.java | 2 + .../ts3client/net/ConnectionEventHandler.java | 2 + .../ts3client/net/TeamspeakConnection.java | 58 +++++++ .../java/com/ts3client/net/avatar/Avatar.java | 17 ++ .../ts3client/net/avatar/AvatarNaming.java | 67 ++++++++ .../net/avatar/AvatarRepository.java | 148 ++++++++++++++++++ .../com/ts3client/net/avatar/ImageFormat.java | 22 +++ .../net/avatar/AvatarNamingTest.java | 39 +++++ .../net/avatar/AvatarRepositoryTest.java | 83 ++++++++++ .../ts3client/net/avatar/ImageFormatTest.java | 20 +++ .../java/com/ts3client/ui/AvatarDialog.java | 130 +++++++++++++++ .../java/com/ts3client/ui/AvatarHtml.java | 58 +++++++ .../java/com/ts3client/ui/AvatarView.java | 65 ++++++++ .../main/java/com/ts3client/ui/InfoPanel.java | 51 +++++- .../main/java/com/ts3client/ui/MainFrame.java | 5 + .../java/com/ts3client/ui/MainMenuBar.java | 7 + .../main/java/com/ts3client/ui/ServerTab.java | 4 + .../ts3client/ui/ServerTabTreeActions.java | 5 +- 20 files changed, 886 insertions(+), 5 deletions(-) create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/AvatarAdmin.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/avatar/Avatar.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/avatar/AvatarNaming.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/avatar/AvatarRepository.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/avatar/ImageFormat.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/net/avatar/AvatarNamingTest.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/net/avatar/AvatarRepositoryTest.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/net/avatar/ImageFormatTest.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/AvatarDialog.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/AvatarHtml.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/AvatarView.java diff --git a/ts3-client/README.md b/ts3-client/README.md index 8dd6209..fba03c7 100644 --- a/ts3-client/README.md +++ b/ts3-client/README.md @@ -71,6 +71,11 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged. **private chat**, or **locally mute** them. - **Chat** to the current channel or the whole server; receive channel/server/private messages and **pokes**. +- **Avatars** — a selected client's avatar is shown in the info panel, fetched from the + server's file repository (`/avatar_` in channel 0, verified against the client's + `client_flag_avatar` MD5) and cached on disk; **animated GIFs play**. Self → Set + avatar… uploads your own picture (PNG/JPEG/GIF/BMP, within the server's + `i_client_max_avatar_filesize`) or removes it. - **Server bookmarks** — quick-connect menu with add/edit/remove management, each optionally pinned to a specific identity. - **Identity management** (Tools → Identities) — keep several identities, mark one diff --git a/ts3-client/core/src/main/java/com/ts3client/net/AvatarAdmin.java b/ts3-client/core/src/main/java/com/ts3client/net/AvatarAdmin.java new file mode 100644 index 0000000..d060e6b --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/AvatarAdmin.java @@ -0,0 +1,103 @@ +package com.ts3client.net; + +import com.github.manevolent.ts3j.command.SingleCommand; +import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter; +import com.github.manevolent.ts3j.protocol.ProtocolRole; +import com.ts3client.net.avatar.AvatarNaming; +import com.ts3client.net.avatar.ImageFormat; +import com.ts3client.net.filetransfer.FileTransfer; +import com.ts3client.net.filetransfer.FileTransferManager; + +import java.io.File; +import java.nio.file.Files; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Publishes and withdraws the local client's avatar, the way the official client does + * it: the picture goes into channel 0's file repository as {@code /avatar_} and + * {@code client_flag_avatar} is then set to its MD5, which is what other clients key + * their downloads on. + */ +final class AvatarAdmin { + + /** The server's own default for {@code i_client_max_avatar_filesize}. */ + static final int DEFAULT_MAX_AVATAR_BYTES = 200_000; + /** The largest avatar we are willing to download, whatever a server allows. */ + static final int MAX_AVATAR_DOWNLOAD_BYTES = 8 * 1024 * 1024; + + private static final long UPLOAD_TIMEOUT_MS = 60_000; + + private final TeamspeakConnection conn; + + AvatarAdmin(TeamspeakConnection conn) { + this.conn = conn; + } + + /** + * How large an avatar this server lets us upload, from + * {@code i_client_max_avatar_filesize}, or the server default while unknown. + */ + int maxUploadBytes() { + int limit = conn.getModel().selfPermissionValue("i_client_max_avatar_filesize"); + return limit > 0 ? limit : DEFAULT_MAX_AVATAR_BYTES; + } + + /** Uploads {@code source} as our avatar and announces it; returns its flag. */ + String upload(File source) throws Exception { + byte[] data = Files.readAllBytes(source.toPath()); + if (data.length == 0) throw new IllegalArgumentException("That file is empty"); + if (ImageFormat.detect(data) == null) { + throw new IllegalArgumentException("Avatars must be PNG, JPEG, GIF or BMP images"); + } + int limit = maxUploadBytes(); + if (data.length > limit) { + throw new IllegalArgumentException("This server limits avatars to " + (limit / 1024) + " KiB"); + } + String name = ownFileName(); + + CountDownLatch done = new CountDownLatch(1); + FileTransfer transfer = fileTransfers().upload(0, "", "/" + name, source, true, t -> { + if (t.isDone()) done.countDown(); + }); + if (!done.await(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()); + } + String flag = AvatarNaming.flag(data); + setFlag(flag); + conn.getAvatars().put(data); + return flag; + } + + /** Clears our avatar flag and removes the file from the server. */ + void remove() throws Exception { + setFlag(""); + try { + fileTransfers().delete(0, "", "/" + ownFileName()); + } catch (Exception ignored) { + // The flag is what other clients go by; a leftover file only wastes space. + } + } + + private void setFlag(String flag) throws Exception { + SingleCommand cmd = new SingleCommand("clientupdate", ProtocolRole.CLIENT, + new CommandSingleParameter("client_flag_avatar", flag)); + conn.socket().executeCommand(cmd).complete(); + } + + private String ownFileName() { + String name = AvatarNaming.fileName(conn.getSelfUniqueId()); + if (name == null) throw new IllegalStateException("Our identity has no usable unique id"); + return name; + } + + private FileTransferManager fileTransfers() { + FileTransferManager ft = conn.fileTransfers(); + if (ft == null) throw new IllegalStateException("Not connected"); + return ft; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java b/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java index 40540e5..b6a368b 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ClientEntry.java @@ -18,6 +18,8 @@ public final class ClientEntry { public String version = ""; public long idleTimeMs; public String description = ""; + /** MD5 of the client's avatar ({@code client_flag_avatar}); empty when they have none. */ + public String avatarFlag = ""; public boolean talking; public boolean inputMuted; // microphone muted (client_input_muted) diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java index a81b66e..10e0106 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionEventHandler.java @@ -45,6 +45,7 @@ final class ConnectionEventHandler implements TS3Listener { c.away = e.isClientAway(); c.awayMessage = TeamspeakConnection.orEmpty(e.get("client_away_message")); c.uniqueId = TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier()); + c.avatarFlag = TeamspeakConnection.orEmpty(e.get("client_flag_avatar")); c.databaseId = e.getClientDatabaseId(); c.serverGroupIds = parseIntList(e.getClientServerGroups()); c.channelGroupId = e.getClientChannelGroupId(); @@ -321,6 +322,7 @@ final class ConnectionEventHandler implements TS3Listener { } if (has(e, "client_is_channel_commander")) c.channelCommander = e.getBoolean("client_is_channel_commander"); + if (has(e, "client_flag_avatar")) c.avatarFlag = TeamspeakConnection.orEmpty(e.get("client_flag_avatar")); announceClientUpdate(e, c, renamed, oldName); conn.ui.onModelChanged(); } diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index 0320983..83be9ae 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -19,6 +19,9 @@ import com.ts3client.chatlog.ChatLogStore; import com.ts3client.config.Settings; import com.ts3client.contacts.Contact; import com.ts3client.contacts.ContactStore; +import com.ts3client.net.avatar.Avatar; +import com.ts3client.net.avatar.AvatarNaming; +import com.ts3client.net.avatar.AvatarRepository; import com.ts3client.net.filetransfer.FileTransfer; import com.ts3client.net.filetransfer.FileTransferManager; import com.ts3client.net.ban.BanEntry; @@ -66,6 +69,7 @@ public final class TeamspeakConnection implements TS3Listener { /** Package-private: read directly by {@link ConnectionEventHandler}. */ final ConnectionListener ui; private final IconRepository icons; + private final AvatarRepository avatars; /** Package-private: read directly by {@link ConnectionEventHandler}. */ final SoundNotifier sounds; /** Friends, blocked clients and their per-client filters; also read by {@link ConnectionEventHandler}. */ @@ -78,6 +82,7 @@ public final class TeamspeakConnection implements TS3Listener { final ChannelAdmin channels = new ChannelAdmin(this); /** The server's ban list; also package-private for {@link ConnectionEventHandler}. */ final BanAdmin bans = new BanAdmin(this); + private final AvatarAdmin avatarAdmin = new AvatarAdmin(this); /** Package-private: read directly by {@link ConnectionStatsCollector}. */ LocalTeamspeakClientSocket client; @@ -123,6 +128,7 @@ public final class TeamspeakConnection implements TS3Listener { this.sounds = sounds; this.contacts = contacts; this.icons = new IconRepository(this::fetchIcon, ui::onIconsUpdated); + this.avatars = new AvatarRepository(this::fetchAvatar, ui::onInfoUpdated); } public ServerModel getModel() { @@ -299,6 +305,7 @@ public final class TeamspeakConnection implements TS3Listener { fileTransfers = new FileTransferManager(client, () -> serverHost); client.setMicrophone(microphone); icons.retryFailed(); + avatars.retryFailed(); connected = true; connectedAtMs = System.currentTimeMillis(); leaving = false; @@ -472,6 +479,7 @@ public final class TeamspeakConnection implements TS3Listener { e.away = cl.isAway(); e.awayMessage = orEmpty(cl.get("client_away_message")); e.uniqueId = cl.getUniqueIdentifier(); + e.avatarFlag = orEmpty(cl.get("client_flag_avatar")); e.serverGroupIds = cl.getServerGroups(); e.channelGroupId = cl.getChannelGroupId(); e.self = (cl.getId() == selfClientId); @@ -912,6 +920,7 @@ public final class TeamspeakConnection implements TS3Listener { e.version = orEmpty(c.getVersion()); e.idleTimeMs = c.getIdleTime(); e.description = orEmpty(c.get("client_description")); + e.avatarFlag = orEmpty(c.get("client_flag_avatar")); int[] groups = c.getServerGroups(); if (groups != null && groups.length > 0) e.serverGroupIds = groups; e.channelGroupId = c.getChannelGroupId(); @@ -1232,6 +1241,55 @@ public final class TeamspeakConnection implements TS3Listener { return ft.downloadToMemory(0, "", "/icon_" + iconId, MAX_ICON_BYTES); } + // ---- avatars ---- + + /** Client avatars for this server, downloaded on demand. */ + public AvatarRepository getAvatars() { + return avatars; + } + + /** The avatar a client currently shows, or {@code null} if none or not yet available. */ + public Avatar avatarOf(ClientEntry client) { + return client == null ? null : avatars.get(client.uniqueId, client.avatarFlag); + } + + /** How large an avatar this server lets us upload, in bytes. */ + public int maxAvatarBytes() { + return avatarAdmin.maxUploadBytes(); + } + + /** + * Uploads an image as our avatar and publishes it to the other clients. + * + * @param callback given {@code null} on success, or the failure message + */ + public void setAvatar(File source, Consumer callback) { + run("ts3j-avatar-upload", (flag, error) -> { + if (error == null) updateSelf(self -> self.avatarFlag = flag); + callback.accept(error); + }, () -> avatarAdmin.upload(source)); + } + + /** Withdraws our avatar; {@code callback} is given {@code null} on success. */ + public void removeAvatar(Consumer callback) { + run("ts3j-avatar-remove", (ignored, error) -> { + if (error == null) updateSelf(self -> self.avatarFlag = ""); + callback.accept(error); + }, () -> { + avatarAdmin.remove(); + return null; + }); + } + + /** Reads a client's avatar out of the virtual server's file repository (channel 0). */ + private byte[] fetchAvatar(String uniqueId) throws Exception { + FileTransferManager ft = fileTransfers; + if (ft == null) throw new IllegalStateException("Not connected"); + String name = AvatarNaming.fileName(uniqueId); + if (name == null) throw new IllegalArgumentException("Not a client unique id: " + uniqueId); + return ft.downloadToMemory(0, "", "/" + name, AvatarAdmin.MAX_AVATAR_DOWNLOAD_BYTES); + } + // ---- file transfers ---- /** Whether a live connection capable of file transfers is available. */ diff --git a/ts3-client/core/src/main/java/com/ts3client/net/avatar/Avatar.java b/ts3-client/core/src/main/java/com/ts3client/net/avatar/Avatar.java new file mode 100644 index 0000000..a290f5c --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/avatar/Avatar.java @@ -0,0 +1,17 @@ +package com.ts3client.net.avatar; + +import java.io.File; + +/** + * A downloaded avatar: its bytes, container format and the on-disk copy for views that + * can only reference images by URL. + * + * @param flag the MD5 the server advertises for it ({@code client_flag_avatar}) + * @param file the cached copy, or {@code null} if the disk cache is unavailable + */ +public record Avatar(String flag, byte[] data, ImageFormat format, File file) { + + public boolean isAnimated() { + return format == ImageFormat.GIF; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/avatar/AvatarNaming.java b/ts3-client/core/src/main/java/com/ts3client/net/avatar/AvatarNaming.java new file mode 100644 index 0000000..e8875e9 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/avatar/AvatarNaming.java @@ -0,0 +1,67 @@ +package com.ts3client.net.avatar; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +/** + * How TeamSpeak names and identifies avatars. + * + *

An avatar lives in the virtual server's file repository (channel 0) as + * {@code /avatar_}, where the name is the client's unique identifier — the + * base64 of a 20-byte SHA-1 — decoded and re-encoded as forty letters {@code a}–{@code p} + * (one per nibble, high nibble first). This is what the client library calls + * {@code identityStringToFilename}, recovered from the official client. + * + *

The {@code client_flag_avatar} property carries the MD5 of the file's bytes, so a + * changed avatar is detectable without re-downloading. + */ +public final class AvatarNaming { + + private static final char[] NIBBLES = "abcdefghijklmnop".toCharArray(); + + private AvatarNaming() { + } + + /** + * @return the repository file name ({@code avatar_…}) of a client's avatar, without + * the leading slash, or {@code null} if the unique id is not a valid one + */ + public static String fileName(String uniqueId) { + byte[] hash; + try { + hash = Base64.getDecoder().decode(uniqueId.trim()); + } catch (IllegalArgumentException | NullPointerException e) { + return null; + } + if (hash.length != 20) return null; + StringBuilder sb = new StringBuilder("avatar_"); + for (byte b : hash) { + sb.append(NIBBLES[(b >> 4) & 0xF]).append(NIBBLES[b & 0xF]); + } + return sb.toString(); + } + + /** The lowercase hex MD5 that TeamSpeak publishes as {@code client_flag_avatar}. */ + public static String flag(byte[] avatar) { + try { + byte[] digest = MessageDigest.getInstance("MD5").digest(avatar); + StringBuilder sb = new StringBuilder(32); + for (byte b : digest) sb.append(String.format("%02x", b)); + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + /** Whether the flag looks like an MD5 (the server also passes junk through unchanged). */ + public static boolean isFlag(String flag) { + if (flag == null || flag.length() != 32) return false; + for (byte c : flag.getBytes(StandardCharsets.US_ASCII)) { + boolean hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!hex) return false; + } + return true; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/avatar/AvatarRepository.java b/ts3-client/core/src/main/java/com/ts3client/net/avatar/AvatarRepository.java new file mode 100644 index 0000000..af0e252 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/avatar/AvatarRepository.java @@ -0,0 +1,148 @@ +package com.ts3client.net.avatar; + +import com.ts3client.config.Settings; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Supplies client avatars, downloading each from the server's file repository once + * and caching it in memory and on disk. + * + *

Avatars are identified by their {@code client_flag_avatar} MD5 rather than by + * client, so a client changing their picture simply asks for a new flag, and two + * clients with the same picture share one download. A file whose MD5 does not match + * the advertised flag is discarded, as the official client does. + * + *

Lookups never block: {@link #get} returns {@code null} while an avatar is still + * being fetched and the listener is notified once it becomes available. + */ +public final class AvatarRepository { + + /** Downloads {@code /avatar_} of the given client from channel 0. */ + public interface Fetcher { + byte[] fetchAvatar(String uniqueId) throws Exception; + } + + private final Fetcher fetcher; + private final Runnable onAvatarLoaded; + private final File cacheDir; + private final Map avatars = new ConcurrentHashMap<>(); + private final Set pending = ConcurrentHashMap.newKeySet(); + private final Set failed = ConcurrentHashMap.newKeySet(); + private final ExecutorService downloads = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "ts3j-avatars"); + t.setDaemon(true); + return t; + }); + + /** + * @param onAvatarLoaded run (off the UI thread) whenever a previously missing + * avatar has arrived, so the view can repaint + */ + public AvatarRepository(Fetcher fetcher, Runnable onAvatarLoaded) { + this(fetcher, onAvatarLoaded, new File(Settings.configDir(), "avatars")); + } + + /** @param cacheDir where downloaded avatars are kept between sessions */ + public AvatarRepository(Fetcher fetcher, Runnable onAvatarLoaded, File cacheDir) { + this.fetcher = fetcher; + this.onAvatarLoaded = onAvatarLoaded; + this.cacheDir = cacheDir; + } + + /** + * @param uniqueId the client whose avatar is wanted + * @param flag the MD5 the client currently advertises; empty means no avatar + * @return the avatar, or {@code null} if the client has none, it is still loading + * or it is unavailable. An avatar that is not cached yet starts downloading. + */ + public Avatar get(String uniqueId, String flag) { + if (!AvatarNaming.isFlag(flag)) return null; + String key = flag.toLowerCase(); + + Avatar cached = avatars.get(key); + if (cached != null) return cached; + + Avatar fromDisk = readDiskCache(key); + if (fromDisk != null) { + avatars.put(key, fromDisk); + return fromDisk; + } + if (uniqueId != null && !uniqueId.isEmpty() && !failed.contains(key) && pending.add(key)) { + downloads.execute(() -> download(uniqueId, key)); + } + return null; + } + + /** Records an avatar we just uploaded ourselves, so it shows without a round trip. */ + public Avatar put(byte[] data) { + String flag = AvatarNaming.flag(data); + Avatar avatar = new Avatar(flag, data, ImageFormat.detect(data), writeDiskCache(flag, data)); + avatars.put(flag, avatar); + failed.remove(flag); + return avatar; + } + + /** Forgets download failures so avatars can be retried on the next reconnect. */ + public void retryFailed() { + failed.clear(); + } + + public void shutdown() { + downloads.shutdownNow(); + } + + private void download(String uniqueId, String flag) { + try { + byte[] data = fetcher.fetchAvatar(uniqueId); + if (data == null || data.length == 0 || !AvatarNaming.flag(data).equals(flag)) { + failed.add(flag); + return; + } + avatars.put(flag, new Avatar(flag, data, ImageFormat.detect(data), writeDiskCache(flag, data))); + if (onAvatarLoaded != null) onAvatarLoaded.run(); + } catch (IllegalStateException e) { + // Not connected (yet); leave it retryable. + } catch (Exception e) { + // Missing or permission-denied avatars are common; don't ask again. + failed.add(flag); + } finally { + pending.remove(flag); + } + } + + private Avatar readDiskCache(String flag) { + for (ImageFormat format : ImageFormat.values()) { + File file = new File(cacheDir, flag + "." + format.extension); + if (!file.isFile()) continue; + try { + byte[] data = Files.readAllBytes(file.toPath()); + if (AvatarNaming.flag(data).equals(flag)) return new Avatar(flag, data, format, file); + } catch (IOException ignored) { + // fall through and re-download + } + } + return null; + } + + private File writeDiskCache(String flag, byte[] data) { + ImageFormat format = ImageFormat.detect(data); + if (format == null) return null; // nothing we could display later anyway + File file = new File(cacheDir, flag + "." + format.extension); + try { + //noinspection ResultOfMethodCallIgnored + cacheDir.mkdirs(); + Files.write(file.toPath(), data); + return file; + } catch (IOException e) { + return null; // the in-memory cache still serves this session + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/avatar/ImageFormat.java b/ts3-client/core/src/main/java/com/ts3client/net/avatar/ImageFormat.java new file mode 100644 index 0000000..77b4aed --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/avatar/ImageFormat.java @@ -0,0 +1,22 @@ +package com.ts3client.net.avatar; + +/** The image container formats TeamSpeak accepts for avatars, told apart by their magic bytes. */ +public enum ImageFormat { + PNG("png"), JPEG("jpg"), GIF("gif"), BMP("bmp"); + + public final String extension; + + ImageFormat(String extension) { + this.extension = extension; + } + + /** @return the format of {@code data}, or {@code null} if it is none of the supported ones */ + public static ImageFormat detect(byte[] data) { + if (data == null || data.length < 6) return null; + if ((data[0] & 0xFF) == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G') return PNG; + if ((data[0] & 0xFF) == 0xFF && (data[1] & 0xFF) == 0xD8) return JPEG; + if (data[0] == 'G' && data[1] == 'I' && data[2] == 'F' && data[3] == '8') return GIF; + if (data[0] == 'B' && data[1] == 'M') return BMP; + return null; + } +} diff --git a/ts3-client/core/src/test/java/com/ts3client/net/avatar/AvatarNamingTest.java b/ts3-client/core/src/test/java/com/ts3client/net/avatar/AvatarNamingTest.java new file mode 100644 index 0000000..650d168 --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/net/avatar/AvatarNamingTest.java @@ -0,0 +1,39 @@ +package com.ts3client.net.avatar; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AvatarNamingTest { + + @Test + void fileNameEncodesDecodedUidNibbleWise() { + // base64 of 0x00 0x01 ... 0x13 + String uid = "AAECAwQFBgcICQoLDA0ODxAREhM="; + assertEquals("avatar_aaabacadaeafagahaiajakalamanaoapbabbbcbd", AvatarNaming.fileName(uid)); + assertEquals("avatar_" + "p".repeat(40), + AvatarNaming.fileName("//////////////////////////8=")); + } + + @Test + void fileNameRejectsMalformedIds() { + assertNull(AvatarNaming.fileName("")); + assertNull(AvatarNaming.fileName("not base64!")); + assertNull(AvatarNaming.fileName("AAEC")); // decodes, but not to 20 bytes + assertNull(AvatarNaming.fileName(null)); + } + + @Test + void flagIsLowercaseMd5() { + assertEquals("9e107d9d372bb6826bd81d3542a419d6", + AvatarNaming.flag("The quick brown fox jumps over the lazy dog".getBytes(StandardCharsets.US_ASCII))); + assertTrue(AvatarNaming.isFlag("9e107d9d372bb6826bd81d3542a419d6")); + assertFalse(AvatarNaming.isFlag("")); + assertFalse(AvatarNaming.isFlag("zz107d9d372bb6826bd81d3542a419d6")); + } +} diff --git a/ts3-client/core/src/test/java/com/ts3client/net/avatar/AvatarRepositoryTest.java b/ts3-client/core/src/test/java/com/ts3client/net/avatar/AvatarRepositoryTest.java new file mode 100644 index 0000000..73dd09e --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/net/avatar/AvatarRepositoryTest.java @@ -0,0 +1,83 @@ +package com.ts3client.net.avatar; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AvatarRepositoryTest { + + private static final byte[] GIF = "GIF89a-not-really-an-image".getBytes(); + private static final String UID = "AAECAwQFBgcICQoLDA0ODxAREhM="; + + @TempDir + File cache; + + @Test + void downloadsOnceThenServesFromMemoryAndDisk() throws Exception { + AtomicInteger fetches = new AtomicInteger(); + CountDownLatch loaded = new CountDownLatch(1); + AvatarRepository repo = new AvatarRepository(uid -> { + fetches.incrementAndGet(); + return GIF; + }, loaded::countDown, cache); + String flag = AvatarNaming.flag(GIF); + + assertNull(repo.get(UID, flag)); + assertTrue(loaded.await(5, TimeUnit.SECONDS)); + Avatar avatar = repo.get(UID, flag); + assertNotNull(avatar); + assertEquals(ImageFormat.GIF, avatar.format()); + assertTrue(avatar.isAnimated()); + assertEquals(new File(cache, flag + ".gif"), avatar.file()); + assertEquals(1, fetches.get()); + + // A fresh repository over the same directory needs no download. + AvatarRepository again = new AvatarRepository(uid -> { + throw new AssertionError("should not fetch"); + }, null, cache); + assertNotNull(again.get(UID, flag)); + } + + @Test + void rejectsFilesWhoseMd5DoesNotMatchTheFlag() throws Exception { + AtomicInteger fetches = new AtomicInteger(); + AvatarRepository repo = new AvatarRepository(uid -> { + fetches.incrementAndGet(); + return GIF; + }, null, cache); + String wrongFlag = "0123456789abcdef0123456789abcdef"; + + assertNull(repo.get(UID, wrongFlag)); + Thread.sleep(300); + assertNull(repo.get(UID, wrongFlag)); + Thread.sleep(300); + assertEquals(1, fetches.get(), "a mismatch is not retried"); + assertEquals(0, cache.list().length); + } + + @Test + void ignoresClientsWithoutAnAvatar() { + AvatarRepository repo = new AvatarRepository(uid -> { + throw new AssertionError("should not fetch"); + }, null, cache); + assertNull(repo.get(UID, "")); + assertNull(repo.get(UID, null)); + } + + @Test + void putMakesAnUploadedAvatarAvailableImmediately() { + AvatarRepository repo = new AvatarRepository(uid -> null, null, cache); + Avatar avatar = repo.put(GIF); + assertEquals(AvatarNaming.flag(GIF), avatar.flag()); + assertEquals(avatar, repo.get(UID, avatar.flag())); + } +} diff --git a/ts3-client/core/src/test/java/com/ts3client/net/avatar/ImageFormatTest.java b/ts3-client/core/src/test/java/com/ts3client/net/avatar/ImageFormatTest.java new file mode 100644 index 0000000..a809b62 --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/net/avatar/ImageFormatTest.java @@ -0,0 +1,20 @@ +package com.ts3client.net.avatar; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class ImageFormatTest { + + @Test + void detectsByMagicBytes() { + assertEquals(ImageFormat.PNG, ImageFormat.detect(new byte[]{(byte) 0x89, 'P', 'N', 'G', 13, 10, 26, 10})); + assertEquals(ImageFormat.JPEG, ImageFormat.detect(new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0, 0, 16})); + assertEquals(ImageFormat.GIF, ImageFormat.detect("GIF89a".getBytes())); + assertEquals(ImageFormat.BMP, ImageFormat.detect("BM\0\0\0\0".getBytes())); + assertNull(ImageFormat.detect("".getBytes())); + assertNull(ImageFormat.detect(new byte[3])); + assertNull(ImageFormat.detect(null)); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/AvatarDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/AvatarDialog.java new file mode 100644 index 0000000..a1301cc --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/AvatarDialog.java @@ -0,0 +1,130 @@ +package com.ts3client.ui; + +import com.ts3client.net.ClientEntry; +import com.ts3client.net.TeamspeakConnection; +import com.ts3client.net.avatar.Avatar; + +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.SwingUtilities; +import javax.swing.filechooser.FileNameExtensionFilter; +import java.awt.BorderLayout; +import java.awt.Frame; +import java.io.File; + +/** + * Self → Set avatar: shows the avatar we currently publish on this server and lets the + * user replace it with an image file (PNG, JPEG, BMP or GIF — animated GIFs are kept + * as they are, so they animate for everyone) or withdraw it. + */ +final class AvatarDialog extends JDialog { + + private final TeamspeakConnection conn; + private final AvatarView preview = new AvatarView(200); + private final JLabel status = new JLabel(" "); + private final JButton remove = new JButton("Remove avatar", Icons.of("DELETE_AVATAR")); + private final JButton choose = new JButton("Select image…", Icons.of("UPLOAD_AVATAR")); + private File lastDirectory; + + AvatarDialog(Frame owner, TeamspeakConnection conn) { + super(owner, "Set Avatar", true); + this.conn = conn; + + JPanel picture = new JPanel(new BorderLayout()); + picture.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(8, 8, 4, 8), + BorderFactory.createLineBorder(Theme.border()))); + picture.add(preview, BorderLayout.CENTER); + + JLabel hint = new JLabel("Images up to " + (conn.maxAvatarBytes() / 1024) + + " KiB may be uploaded to this server. Animated GIFs stay animated."); + hint.setFont(Theme.uiSmall()); + hint.setBorder(BorderFactory.createEmptyBorder(0, 8, 0, 8)); + status.setFont(Theme.uiSmall()); + status.setBorder(BorderFactory.createEmptyBorder(2, 8, 0, 8)); + + JPanel text = new JPanel(); + text.setLayout(new BoxLayout(text, BoxLayout.Y_AXIS)); + text.add(hint); + text.add(status); + + JPanel buttons = new JPanel(); + buttons.setLayout(new BoxLayout(buttons, BoxLayout.X_AXIS)); + buttons.setBorder(BorderFactory.createEmptyBorder(6, 8, 8, 8)); + choose.addActionListener(e -> chooseFile()); + remove.addActionListener(e -> removeAvatar()); + JButton close = new JButton("Close"); + close.addActionListener(e -> dispose()); + buttons.add(choose); + buttons.add(Box.createHorizontalStrut(4)); + buttons.add(remove); + buttons.add(Box.createHorizontalGlue()); + buttons.add(close); + + JPanel south = new JPanel(new BorderLayout()); + south.add(text, BorderLayout.NORTH); + south.add(buttons, BorderLayout.SOUTH); + + getContentPane().setLayout(new BorderLayout()); + getContentPane().add(picture, BorderLayout.CENTER); + getContentPane().add(south, BorderLayout.SOUTH); + + showCurrent(); + Dialogs.closeOnEscape(this); + setDefaultCloseOperation(DISPOSE_ON_CLOSE); + pack(); + setResizable(false); + setLocationRelativeTo(owner); + } + + private void showCurrent() { + ClientEntry self = conn.getModel().getClient(conn.getSelfClientId()); + Avatar avatar = conn.avatarOf(self); + preview.setImage(avatar == null ? null : avatar.data()); + remove.setEnabled(avatar != null || (self != null && !self.avatarFlag.isEmpty())); + status.setText(avatar == null ? "You have no avatar on this server." : " "); + } + + private void chooseFile() { + JFileChooser chooser = new JFileChooser(lastDirectory); + chooser.setDialogTitle("Select your avatar"); + chooser.setFileFilter(new FileNameExtensionFilter("Images", "png", "jpg", "jpeg", "gif", "bmp")); + if (chooser.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) return; + File file = chooser.getSelectedFile(); + lastDirectory = file.getParentFile(); + setBusy(true, "Uploading " + file.getName() + "…"); + conn.setAvatar(file, error -> SwingUtilities.invokeLater(() -> { + setBusy(false, " "); + if (error != null) { + JOptionPane.showMessageDialog(this, "Error uploading avatar: " + error, + "Error", JOptionPane.ERROR_MESSAGE); + } + showCurrent(); + })); + } + + private void removeAvatar() { + setBusy(true, "Removing avatar…"); + conn.removeAvatar(error -> SwingUtilities.invokeLater(() -> { + setBusy(false, " "); + if (error != null) { + JOptionPane.showMessageDialog(this, "Error removing avatar: " + error, + "Error", JOptionPane.ERROR_MESSAGE); + } + showCurrent(); + })); + } + + private void setBusy(boolean busy, String message) { + choose.setEnabled(!busy); + remove.setEnabled(!busy); + status.setText(message); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/AvatarHtml.java b/ts3-client/swing/src/main/java/com/ts3client/ui/AvatarHtml.java new file mode 100644 index 0000000..9210f74 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/AvatarHtml.java @@ -0,0 +1,58 @@ +package com.ts3client.ui; + +import com.ts3client.net.avatar.Avatar; + +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import java.awt.Dimension; +import java.io.ByteArrayInputStream; +import java.util.Iterator; + +/** + * Renders an avatar as an HTML {@code } for the info views. Swing's HTML renderer + * loads images by URL and plays animated GIFs on its own, so the tag simply points at + * the repository's on-disk copy, scaled to fit a box while keeping its aspect ratio. + */ +final class AvatarHtml { + + /** The largest box an avatar is drawn in, as in the official client's info frame. */ + static final int MAX_SIZE = 200; + + private AvatarHtml() { + } + + /** @return the tag, or an empty string if the avatar cannot be shown */ + static String imgTag(Avatar avatar, int maxSize) { + if (avatar == null || avatar.file() == null) return ""; + Dimension size = fit(dimensions(avatar.data()), maxSize); + String dims = size == null ? "" : " width='" + size.width + "' height='" + size.height + "'"; + return ""; + } + + /** Reads only the image header, which is cheap even for a large animation. */ + static Dimension dimensions(byte[] data) { + try (ImageInputStream in = ImageIO.createImageInputStream(new ByteArrayInputStream(data))) { + Iterator readers = ImageIO.getImageReaders(in); + if (!readers.hasNext()) return null; + ImageReader reader = readers.next(); + try { + reader.setInput(in); + return new Dimension(reader.getWidth(0), reader.getHeight(0)); + } finally { + reader.dispose(); + } + } catch (Exception e) { + return null; + } + } + + /** Scales {@code size} down (never up) to fit a square box of {@code max}. */ + static Dimension fit(Dimension size, int max) { + if (size == null || size.width <= 0 || size.height <= 0) return null; + if (size.width <= max && size.height <= max) return size; + double scale = Math.min((double) max / size.width, (double) max / size.height); + return new Dimension(Math.max(1, (int) Math.round(size.width * scale)), + Math.max(1, (int) Math.round(size.height * scale))); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/AvatarView.java b/ts3-client/swing/src/main/java/com/ts3client/ui/AvatarView.java new file mode 100644 index 0000000..cbf4903 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/AvatarView.java @@ -0,0 +1,65 @@ +package com.ts3client.ui; + +import javax.swing.JComponent; +import java.awt.Dimension; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.RenderingHints; +import java.awt.Toolkit; + +/** + * Paints an avatar scaled to fit, keeping its aspect ratio. The image is a Toolkit + * image drawn with this component as its observer, so an animated GIF keeps playing: + * each decoded frame triggers a repaint. + */ +final class AvatarView extends JComponent { + + private final int boxSize; + private Image image; + private int imageWidth = -1; + private int imageHeight = -1; + + AvatarView(int boxSize) { + this.boxSize = boxSize; + setPreferredSize(new Dimension(boxSize, boxSize)); + setMinimumSize(getPreferredSize()); + } + + /** @param data the encoded image, or {@code null} to show nothing */ + void setImage(byte[] data) { + if (image != null) image.flush(); + image = data == null ? null : Toolkit.getDefaultToolkit().createImage(data); + imageWidth = imageHeight = -1; + repaint(); + } + + boolean hasImage() { + return image != null; + } + + @Override + protected void paintComponent(Graphics g) { + if (image == null) return; + if (imageWidth < 0 || imageHeight < 0) { + // Unknown until the header is decoded; the observer repaints once it is. + imageWidth = image.getWidth(this); + imageHeight = image.getHeight(this); + if (imageWidth < 0 || imageHeight < 0) return; + } + double scale = Math.min(1.0, Math.min((double) getWidth() / imageWidth, (double) getHeight() / imageHeight)); + int w = Math.max(1, (int) Math.round(imageWidth * scale)); + int h = Math.max(1, (int) Math.round(imageHeight * scale)); + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g2.drawImage(image, (getWidth() - w) / 2, (getHeight() - h) / 2, w, h, this); + } finally { + g2.dispose(); + } + } + + int boxSize() { + return boxSize; + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java index a959cbf..08c2f13 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java @@ -6,6 +6,7 @@ import com.ts3client.net.ClientEntry; import com.ts3client.net.Group; import com.ts3client.net.IconRepository; import com.ts3client.net.ServerModel; +import com.ts3client.net.avatar.Avatar; import com.ts3client.text.BBCode; import javax.swing.BorderFactory; @@ -18,6 +19,8 @@ import java.awt.BorderLayout; import java.awt.Cursor; import java.awt.Dimension; import java.awt.Rectangle; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.io.File; @@ -42,6 +45,9 @@ public final class InfoPanel extends JPanel { void setPanelHidden(boolean hidden); } + private static final int AVATAR_MIN_SIZE = 64; + private static final int AVATAR_BOX_STEP = 16; + private final JEditorPane pane = HtmlStyles.pane("font-family:sans-serif; font-size:11px;"); private final JScrollPane scroll = new JScrollPane(pane); /** Floats over the bottom-right corner of the details, out of the text's way. */ @@ -52,7 +58,10 @@ public final class InfoPanel extends JPanel { private ServerModel shownModel; private IconRepository shownIcons; private Contact shownContact; + private Avatar shownAvatar; private boolean inChatTab; + /** The avatar box the current HTML was built for; a resize that changes it re-renders. */ + private int renderedAvatarBox; public InfoPanel() { super(new BorderLayout()); @@ -62,6 +71,12 @@ public final class InfoPanel extends JPanel { Links.open(e.getDescription(), this); }); HtmlStyles.fillViewport(pane); + scroll.getViewport().addComponentListener(new ComponentAdapter() { + @Override + public void componentResized(ComponentEvent e) { + if (shownAvatar != null && !inChatTab && avatarBox() != renderedAvatarBox) render(); + } + }); toggle.setFont(Theme.uiSmall()); toggle.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); @@ -146,12 +161,15 @@ public final class InfoPanel extends JPanel { render(); } - public void showClient(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) { + /** @param avatar the client's avatar, or {@code null} while there is none to show */ + public void showClient(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact, + Avatar avatar) { shownClient = cl; shownChannel = null; shownModel = model; shownIcons = icons; shownContact = contact; + shownAvatar = avatar; render(); } @@ -169,10 +187,25 @@ public final class InfoPanel extends JPanel { private String body() { if (shownChannel != null) return channelBody(shownChannel); - if (shownClient != null) return clientBody(shownClient, shownModel, shownIcons, shownContact); + if (shownClient != null) { + renderedAvatarBox = inChatTab ? AvatarHtml.MAX_SIZE : avatarBox(); + return clientBody(shownClient, shownModel, shownIcons, shownContact, shownAvatar, renderedAvatarBox); + } return "Select a channel or client to see details."; } + /** + * How large an avatar may be drawn beside the details: a share of the panel's + * width, so a narrow panel keeps room for the text. Rounded to steps so that + * dragging a divider does not rebuild the HTML on every pixel. + */ + private int avatarBox() { + int width = scroll.getViewport().getWidth(); + if (width <= 0) return AvatarHtml.MAX_SIZE; + int box = width * 2 / 5 / AVATAR_BOX_STEP * AVATAR_BOX_STEP; + return Math.max(AVATAR_MIN_SIZE, Math.min(AvatarHtml.MAX_SIZE, box)); + } + private static String channelBody(ChannelNode ch) { StringBuilder sb = new StringBuilder(); sb.append(heading(esc(ch.name))); @@ -190,7 +223,19 @@ public final class InfoPanel extends JPanel { return sb.toString(); } - private static String clientBody(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) { + private static String clientBody(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact, + Avatar avatar, int avatarBox) { + String details = clientDetails(cl, model, icons, contact); + String picture = AvatarHtml.imgTag(avatar, avatarBox); + if (picture.isEmpty()) return details; + // The avatar sits top-right beside the details, like the official client's info frame. + return "" + + "" + + "" + + "
" + details + "" + picture + "
"; + } + + private static String clientDetails(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) { StringBuilder sb = new StringBuilder(); String name = contact == null ? cl.nickname : contact.displayName(cl.nickname); sb.append(heading(esc(name) + (cl.self ? " (you)" : ""))); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index 0fe6066..6b4117c 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -194,6 +194,11 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { changeNickname(); } + @Override + public void onSetAvatar() { + if (selected != null && selected.isConnected()) selected.showAvatarDialog(); + } + @Override public void onShowIdentities() { showIdentities(); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java index b48a883..787fe27 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java @@ -46,6 +46,8 @@ final class MainMenuBar extends JMenuBar { void onChangeNickname(); + void onSetAvatar(); + void onShowIdentities(); void onShowContacts(); @@ -74,6 +76,7 @@ final class MainMenuBar extends JMenuBar { private JCheckBoxMenuItem awayItem; private JMenuItem awayStatusItem; private JCheckBoxMenuItem commanderItem; + private JMenuItem avatarItem; private JMenuItem banListItem; MainMenuBar(Bookmarks bookmarks, Listener listener) { @@ -113,6 +116,8 @@ final class MainMenuBar extends JMenuBar { commanderItem.addActionListener(e -> listener.onCommanderToggle(commanderItem.isSelected())); JMenuItem nick = new JMenuItem("Change nickname…", Icons.of("CHANGE_NICKNAME")); nick.addActionListener(e -> listener.onChangeNickname()); + avatarItem = new JMenuItem("Set avatar…", Icons.of("UPLOAD_AVATAR")); + avatarItem.addActionListener(e -> listener.onSetAvatar()); self.add(mute); self.add(deaf); self.addSeparator(); @@ -121,6 +126,7 @@ final class MainMenuBar extends JMenuBar { self.add(commanderItem); self.addSeparator(); self.add(nick); + self.add(avatarItem); JMenu tools = new JMenu("Tools"); JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER")); @@ -174,6 +180,7 @@ final class MainMenuBar extends JMenuBar { awayItem.setEnabled(connected); awayStatusItem.setEnabled(connected); commanderItem.setEnabled(connected); + avatarItem.setEnabled(connected); banListItem.setEnabled(connected); awayItem.setSelected(away); commanderItem.setSelected(commander); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java index f7e700f..a69f7fd 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java @@ -369,6 +369,10 @@ final class ServerTab implements ServerTabConnectionEvents.Listener { if (conn.isConnected()) conn.setNickname(nickname); } + void showAvatarDialog() { + if (conn.isConnected()) new AvatarDialog(host, conn).setVisible(true); + } + // ---- chat ---- private void onSendChat(ChatPanel.Target target, int clientId, String text) { diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java index 2c6e466..8ef519d 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java @@ -60,8 +60,9 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions { if (sel instanceof ChannelNode) { infoPanel.showChannel((ChannelNode) sel); } else if (sel instanceof ClientEntry) { - infoPanel.showClient((ClientEntry) sel, conn.getModel(), conn.getIcons(), - conn.getContacts().find(((ClientEntry) sel).uniqueId)); + ClientEntry client = (ClientEntry) sel; + infoPanel.showClient(client, conn.getModel(), conn.getIcons(), + conn.getContacts().find(client.uniqueId), conn.avatarOf(client)); } else { infoPanel.clear(); }