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:
@@ -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.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>" : "")));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user