Show client avatars and let the user set their own
Avatars are fetched from the server's file repository as /avatar_<id>, 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 <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,11 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged.
|
|||||||
**private chat**, or **locally mute** them.
|
**private chat**, or **locally mute** them.
|
||||||
- **Chat** to the current channel or the whole server; receive channel/server/private
|
- **Chat** to the current channel or the whole server; receive channel/server/private
|
||||||
messages and **pokes**.
|
messages and **pokes**.
|
||||||
|
- **Avatars** — a selected client's avatar is shown in the info panel, fetched from the
|
||||||
|
server's file repository (`/avatar_<id>` 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
|
- **Server bookmarks** — quick-connect menu with add/edit/remove management, each
|
||||||
optionally pinned to a specific identity.
|
optionally pinned to a specific identity.
|
||||||
- **Identity management** (Tools → Identities) — keep several identities, mark one
|
- **Identity management** (Tools → Identities) — keep several identities, mark one
|
||||||
|
|||||||
103
ts3-client/core/src/main/java/com/ts3client/net/AvatarAdmin.java
Normal file
103
ts3-client/core/src/main/java/com/ts3client/net/AvatarAdmin.java
Normal file
@@ -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_<name>} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ public final class ClientEntry {
|
|||||||
public String version = "";
|
public String version = "";
|
||||||
public long idleTimeMs;
|
public long idleTimeMs;
|
||||||
public String description = "";
|
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 talking;
|
||||||
public boolean inputMuted; // microphone muted (client_input_muted)
|
public boolean inputMuted; // microphone muted (client_input_muted)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
|||||||
c.away = e.isClientAway();
|
c.away = e.isClientAway();
|
||||||
c.awayMessage = TeamspeakConnection.orEmpty(e.get("client_away_message"));
|
c.awayMessage = TeamspeakConnection.orEmpty(e.get("client_away_message"));
|
||||||
c.uniqueId = TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier());
|
c.uniqueId = TeamspeakConnection.orEmpty(e.getUniqueClientIdentifier());
|
||||||
|
c.avatarFlag = TeamspeakConnection.orEmpty(e.get("client_flag_avatar"));
|
||||||
c.databaseId = e.getClientDatabaseId();
|
c.databaseId = e.getClientDatabaseId();
|
||||||
c.serverGroupIds = parseIntList(e.getClientServerGroups());
|
c.serverGroupIds = parseIntList(e.getClientServerGroups());
|
||||||
c.channelGroupId = e.getClientChannelGroupId();
|
c.channelGroupId = e.getClientChannelGroupId();
|
||||||
@@ -321,6 +322,7 @@ final class ConnectionEventHandler implements TS3Listener {
|
|||||||
}
|
}
|
||||||
if (has(e, "client_is_channel_commander"))
|
if (has(e, "client_is_channel_commander"))
|
||||||
c.channelCommander = e.getBoolean("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);
|
announceClientUpdate(e, c, renamed, oldName);
|
||||||
conn.ui.onModelChanged();
|
conn.ui.onModelChanged();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import com.ts3client.chatlog.ChatLogStore;
|
|||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
import com.ts3client.contacts.Contact;
|
import com.ts3client.contacts.Contact;
|
||||||
import com.ts3client.contacts.ContactStore;
|
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.FileTransfer;
|
||||||
import com.ts3client.net.filetransfer.FileTransferManager;
|
import com.ts3client.net.filetransfer.FileTransferManager;
|
||||||
import com.ts3client.net.ban.BanEntry;
|
import com.ts3client.net.ban.BanEntry;
|
||||||
@@ -66,6 +69,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
/** Package-private: read directly by {@link ConnectionEventHandler}. */
|
/** Package-private: read directly by {@link ConnectionEventHandler}. */
|
||||||
final ConnectionListener ui;
|
final ConnectionListener ui;
|
||||||
private final IconRepository icons;
|
private final IconRepository icons;
|
||||||
|
private final AvatarRepository avatars;
|
||||||
/** Package-private: read directly by {@link ConnectionEventHandler}. */
|
/** Package-private: read directly by {@link ConnectionEventHandler}. */
|
||||||
final SoundNotifier sounds;
|
final SoundNotifier sounds;
|
||||||
/** Friends, blocked clients and their per-client filters; also read by {@link ConnectionEventHandler}. */
|
/** 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);
|
final ChannelAdmin channels = new ChannelAdmin(this);
|
||||||
/** The server's ban list; also package-private for {@link ConnectionEventHandler}. */
|
/** The server's ban list; also package-private for {@link ConnectionEventHandler}. */
|
||||||
final BanAdmin bans = new BanAdmin(this);
|
final BanAdmin bans = new BanAdmin(this);
|
||||||
|
private final AvatarAdmin avatarAdmin = new AvatarAdmin(this);
|
||||||
|
|
||||||
/** Package-private: read directly by {@link ConnectionStatsCollector}. */
|
/** Package-private: read directly by {@link ConnectionStatsCollector}. */
|
||||||
LocalTeamspeakClientSocket client;
|
LocalTeamspeakClientSocket client;
|
||||||
@@ -123,6 +128,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
this.sounds = sounds;
|
this.sounds = sounds;
|
||||||
this.contacts = contacts;
|
this.contacts = contacts;
|
||||||
this.icons = new IconRepository(this::fetchIcon, ui::onIconsUpdated);
|
this.icons = new IconRepository(this::fetchIcon, ui::onIconsUpdated);
|
||||||
|
this.avatars = new AvatarRepository(this::fetchAvatar, ui::onInfoUpdated);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ServerModel getModel() {
|
public ServerModel getModel() {
|
||||||
@@ -299,6 +305,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
fileTransfers = new FileTransferManager(client, () -> serverHost);
|
fileTransfers = new FileTransferManager(client, () -> serverHost);
|
||||||
client.setMicrophone(microphone);
|
client.setMicrophone(microphone);
|
||||||
icons.retryFailed();
|
icons.retryFailed();
|
||||||
|
avatars.retryFailed();
|
||||||
connected = true;
|
connected = true;
|
||||||
connectedAtMs = System.currentTimeMillis();
|
connectedAtMs = System.currentTimeMillis();
|
||||||
leaving = false;
|
leaving = false;
|
||||||
@@ -472,6 +479,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
e.away = cl.isAway();
|
e.away = cl.isAway();
|
||||||
e.awayMessage = orEmpty(cl.get("client_away_message"));
|
e.awayMessage = orEmpty(cl.get("client_away_message"));
|
||||||
e.uniqueId = cl.getUniqueIdentifier();
|
e.uniqueId = cl.getUniqueIdentifier();
|
||||||
|
e.avatarFlag = orEmpty(cl.get("client_flag_avatar"));
|
||||||
e.serverGroupIds = cl.getServerGroups();
|
e.serverGroupIds = cl.getServerGroups();
|
||||||
e.channelGroupId = cl.getChannelGroupId();
|
e.channelGroupId = cl.getChannelGroupId();
|
||||||
e.self = (cl.getId() == selfClientId);
|
e.self = (cl.getId() == selfClientId);
|
||||||
@@ -912,6 +920,7 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
e.version = orEmpty(c.getVersion());
|
e.version = orEmpty(c.getVersion());
|
||||||
e.idleTimeMs = c.getIdleTime();
|
e.idleTimeMs = c.getIdleTime();
|
||||||
e.description = orEmpty(c.get("client_description"));
|
e.description = orEmpty(c.get("client_description"));
|
||||||
|
e.avatarFlag = orEmpty(c.get("client_flag_avatar"));
|
||||||
int[] groups = c.getServerGroups();
|
int[] groups = c.getServerGroups();
|
||||||
if (groups != null && groups.length > 0) e.serverGroupIds = groups;
|
if (groups != null && groups.length > 0) e.serverGroupIds = groups;
|
||||||
e.channelGroupId = c.getChannelGroupId();
|
e.channelGroupId = c.getChannelGroupId();
|
||||||
@@ -1232,6 +1241,55 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
return ft.downloadToMemory(0, "", "/icon_" + iconId, MAX_ICON_BYTES);
|
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<String> 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<String> 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 ----
|
// ---- file transfers ----
|
||||||
|
|
||||||
/** Whether a live connection capable of file transfers is available. */
|
/** Whether a live connection capable of file transfers is available. */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>An avatar lives in the virtual server's file repository (channel 0) as
|
||||||
|
* {@code /avatar_<name>}, 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.
|
||||||
|
*
|
||||||
|
* <p>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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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_<name>} 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<String, Avatar> avatars = new ConcurrentHashMap<>();
|
||||||
|
private final Set<String> pending = ConcurrentHashMap.newKeySet();
|
||||||
|
private final Set<String> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("<svg/>".getBytes()));
|
||||||
|
assertNull(ImageFormat.detect(new byte[3]));
|
||||||
|
assertNull(ImageFormat.detect(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("<html>Images up to " + (conn.maxAvatarBytes() / 1024)
|
||||||
|
+ " KiB may be uploaded to this server. Animated GIFs stay animated.</html>");
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <img>} 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 "<img src='" + avatar.file().toURI() + "'" + dims + ">";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<ImageReader> 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import com.ts3client.net.ClientEntry;
|
|||||||
import com.ts3client.net.Group;
|
import com.ts3client.net.Group;
|
||||||
import com.ts3client.net.IconRepository;
|
import com.ts3client.net.IconRepository;
|
||||||
import com.ts3client.net.ServerModel;
|
import com.ts3client.net.ServerModel;
|
||||||
|
import com.ts3client.net.avatar.Avatar;
|
||||||
import com.ts3client.text.BBCode;
|
import com.ts3client.text.BBCode;
|
||||||
|
|
||||||
import javax.swing.BorderFactory;
|
import javax.swing.BorderFactory;
|
||||||
@@ -18,6 +19,8 @@ import java.awt.BorderLayout;
|
|||||||
import java.awt.Cursor;
|
import java.awt.Cursor;
|
||||||
import java.awt.Dimension;
|
import java.awt.Dimension;
|
||||||
import java.awt.Rectangle;
|
import java.awt.Rectangle;
|
||||||
|
import java.awt.event.ComponentAdapter;
|
||||||
|
import java.awt.event.ComponentEvent;
|
||||||
import java.awt.event.MouseAdapter;
|
import java.awt.event.MouseAdapter;
|
||||||
import java.awt.event.MouseEvent;
|
import java.awt.event.MouseEvent;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
@@ -42,6 +45,9 @@ public final class InfoPanel extends JPanel {
|
|||||||
void setPanelHidden(boolean hidden);
|
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 JEditorPane pane = HtmlStyles.pane("font-family:sans-serif; font-size:11px;");
|
||||||
private final JScrollPane scroll = new JScrollPane(pane);
|
private final JScrollPane scroll = new JScrollPane(pane);
|
||||||
/** Floats over the bottom-right corner of the details, out of the text's way. */
|
/** 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 ServerModel shownModel;
|
||||||
private IconRepository shownIcons;
|
private IconRepository shownIcons;
|
||||||
private Contact shownContact;
|
private Contact shownContact;
|
||||||
|
private Avatar shownAvatar;
|
||||||
private boolean inChatTab;
|
private boolean inChatTab;
|
||||||
|
/** The avatar box the current HTML was built for; a resize that changes it re-renders. */
|
||||||
|
private int renderedAvatarBox;
|
||||||
|
|
||||||
public InfoPanel() {
|
public InfoPanel() {
|
||||||
super(new BorderLayout());
|
super(new BorderLayout());
|
||||||
@@ -62,6 +71,12 @@ public final class InfoPanel extends JPanel {
|
|||||||
Links.open(e.getDescription(), this);
|
Links.open(e.getDescription(), this);
|
||||||
});
|
});
|
||||||
HtmlStyles.fillViewport(pane);
|
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.setFont(Theme.uiSmall());
|
||||||
toggle.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
toggle.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||||
@@ -146,12 +161,15 @@ public final class InfoPanel extends JPanel {
|
|||||||
render();
|
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;
|
shownClient = cl;
|
||||||
shownChannel = null;
|
shownChannel = null;
|
||||||
shownModel = model;
|
shownModel = model;
|
||||||
shownIcons = icons;
|
shownIcons = icons;
|
||||||
shownContact = contact;
|
shownContact = contact;
|
||||||
|
shownAvatar = avatar;
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,10 +187,25 @@ public final class InfoPanel extends JPanel {
|
|||||||
|
|
||||||
private String body() {
|
private String body() {
|
||||||
if (shownChannel != null) return channelBody(shownChannel);
|
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 "<i class='muted'>Select a channel or client to see details.</i>";
|
return "<i class='muted'>Select a channel or client to see details.</i>";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
private static String channelBody(ChannelNode ch) {
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append(heading(esc(ch.name)));
|
sb.append(heading(esc(ch.name)));
|
||||||
@@ -190,7 +223,19 @@ public final class InfoPanel extends JPanel {
|
|||||||
return sb.toString();
|
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 "<table width='100%' cellpadding='0' cellspacing='0'><tr>"
|
||||||
|
+ "<td valign='top'>" + details + "</td>"
|
||||||
|
+ "<td valign='top' align='right' style='padding-left:8px'>" + picture + "</td>"
|
||||||
|
+ "</tr></table>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String clientDetails(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) {
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
String name = contact == null ? cl.nickname : contact.displayName(cl.nickname);
|
String name = contact == null ? cl.nickname : contact.displayName(cl.nickname);
|
||||||
sb.append(heading(esc(name) + (cl.self ? " <span class='muted'>(you)</span>" : "")));
|
sb.append(heading(esc(name) + (cl.self ? " <span class='muted'>(you)</span>" : "")));
|
||||||
|
|||||||
@@ -194,6 +194,11 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|||||||
changeNickname();
|
changeNickname();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSetAvatar() {
|
||||||
|
if (selected != null && selected.isConnected()) selected.showAvatarDialog();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onShowIdentities() {
|
public void onShowIdentities() {
|
||||||
showIdentities();
|
showIdentities();
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ final class MainMenuBar extends JMenuBar {
|
|||||||
|
|
||||||
void onChangeNickname();
|
void onChangeNickname();
|
||||||
|
|
||||||
|
void onSetAvatar();
|
||||||
|
|
||||||
void onShowIdentities();
|
void onShowIdentities();
|
||||||
|
|
||||||
void onShowContacts();
|
void onShowContacts();
|
||||||
@@ -74,6 +76,7 @@ final class MainMenuBar extends JMenuBar {
|
|||||||
private JCheckBoxMenuItem awayItem;
|
private JCheckBoxMenuItem awayItem;
|
||||||
private JMenuItem awayStatusItem;
|
private JMenuItem awayStatusItem;
|
||||||
private JCheckBoxMenuItem commanderItem;
|
private JCheckBoxMenuItem commanderItem;
|
||||||
|
private JMenuItem avatarItem;
|
||||||
private JMenuItem banListItem;
|
private JMenuItem banListItem;
|
||||||
|
|
||||||
MainMenuBar(Bookmarks bookmarks, Listener listener) {
|
MainMenuBar(Bookmarks bookmarks, Listener listener) {
|
||||||
@@ -113,6 +116,8 @@ final class MainMenuBar extends JMenuBar {
|
|||||||
commanderItem.addActionListener(e -> listener.onCommanderToggle(commanderItem.isSelected()));
|
commanderItem.addActionListener(e -> listener.onCommanderToggle(commanderItem.isSelected()));
|
||||||
JMenuItem nick = new JMenuItem("Change nickname…", Icons.of("CHANGE_NICKNAME"));
|
JMenuItem nick = new JMenuItem("Change nickname…", Icons.of("CHANGE_NICKNAME"));
|
||||||
nick.addActionListener(e -> listener.onChangeNickname());
|
nick.addActionListener(e -> listener.onChangeNickname());
|
||||||
|
avatarItem = new JMenuItem("Set avatar…", Icons.of("UPLOAD_AVATAR"));
|
||||||
|
avatarItem.addActionListener(e -> listener.onSetAvatar());
|
||||||
self.add(mute);
|
self.add(mute);
|
||||||
self.add(deaf);
|
self.add(deaf);
|
||||||
self.addSeparator();
|
self.addSeparator();
|
||||||
@@ -121,6 +126,7 @@ final class MainMenuBar extends JMenuBar {
|
|||||||
self.add(commanderItem);
|
self.add(commanderItem);
|
||||||
self.addSeparator();
|
self.addSeparator();
|
||||||
self.add(nick);
|
self.add(nick);
|
||||||
|
self.add(avatarItem);
|
||||||
|
|
||||||
JMenu tools = new JMenu("Tools");
|
JMenu tools = new JMenu("Tools");
|
||||||
JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER"));
|
JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER"));
|
||||||
@@ -174,6 +180,7 @@ final class MainMenuBar extends JMenuBar {
|
|||||||
awayItem.setEnabled(connected);
|
awayItem.setEnabled(connected);
|
||||||
awayStatusItem.setEnabled(connected);
|
awayStatusItem.setEnabled(connected);
|
||||||
commanderItem.setEnabled(connected);
|
commanderItem.setEnabled(connected);
|
||||||
|
avatarItem.setEnabled(connected);
|
||||||
banListItem.setEnabled(connected);
|
banListItem.setEnabled(connected);
|
||||||
awayItem.setSelected(away);
|
awayItem.setSelected(away);
|
||||||
commanderItem.setSelected(commander);
|
commanderItem.setSelected(commander);
|
||||||
|
|||||||
@@ -369,6 +369,10 @@ final class ServerTab implements ServerTabConnectionEvents.Listener {
|
|||||||
if (conn.isConnected()) conn.setNickname(nickname);
|
if (conn.isConnected()) conn.setNickname(nickname);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void showAvatarDialog() {
|
||||||
|
if (conn.isConnected()) new AvatarDialog(host, conn).setVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- chat ----
|
// ---- chat ----
|
||||||
|
|
||||||
private void onSendChat(ChatPanel.Target target, int clientId, String text) {
|
private void onSendChat(ChatPanel.Target target, int clientId, String text) {
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions {
|
|||||||
if (sel instanceof ChannelNode) {
|
if (sel instanceof ChannelNode) {
|
||||||
infoPanel.showChannel((ChannelNode) sel);
|
infoPanel.showChannel((ChannelNode) sel);
|
||||||
} else if (sel instanceof ClientEntry) {
|
} else if (sel instanceof ClientEntry) {
|
||||||
infoPanel.showClient((ClientEntry) sel, conn.getModel(), conn.getIcons(),
|
ClientEntry client = (ClientEntry) sel;
|
||||||
conn.getContacts().find(((ClientEntry) sel).uniqueId));
|
infoPanel.showClient(client, conn.getModel(), conn.getIcons(),
|
||||||
|
conn.getContacts().find(client.uniqueId), conn.avatarOf(client));
|
||||||
} else {
|
} else {
|
||||||
infoPanel.clear();
|
infoPanel.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user