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:
2026-09-23 19:03:00 +00:00
parent dbb425403a
commit 2a73900dcc
20 changed files with 886 additions and 5 deletions

View File

@@ -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 "<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) {
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 "<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();
String name = contact == null ? cl.nickname : contact.displayName(cl.nickname);
sb.append(heading(esc(name) + (cl.self ? " <span class='muted'>(you)</span>" : "")));