Set and delete our avatar from the Self menu and the avatar's menu

As in TeamSpeak 3, Self offers "Set avatar…", which picks an image and
uploads it, and "Delete avatar" while we have one; the old avatar
dialog is gone. Setting (and stealing) is offered only while the server
grants i_client_max_avatar_filesize. Right-clicking our own avatar in
the info panel offers the same two beside saving it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 16:01:16 +00:00
parent e7d338ce28
commit b1890cbf79
8 changed files with 182 additions and 241 deletions

View File

@@ -383,6 +383,20 @@ public final class ServerSession {
return !model.hasPermissionNames() || model.selfPermissionValue("b_client_ban_list") > 0; return !model.hasPermissionNames() || model.selfPermissionValue("b_client_ban_list") > 0;
} }
/** Whether the server lets us upload an avatar ({@code i_client_max_avatar_filesize}); offered while unknown. */
public boolean canSetAvatar() {
if (!conn.isConnected()) return false;
ServerModel model = conn.getModel();
return !model.hasPermissionNames() || model.selfPermissionValue("i_client_max_avatar_filesize") > 0;
}
/** Whether we publish an avatar on this server. */
public boolean hasAvatar() {
if (!conn.isConnected()) return false;
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
return self != null && !self.avatarFlag.isEmpty();
}
/** /**
* Whether we have any {@code i_client_move_power}; whether it beats a client's needed * Whether we have any {@code i_client_move_power}; whether it beats a client's needed
* move power only the server knows, so it is left to refuse. * move power only the server knows, so it is left to refuse.

View File

@@ -0,0 +1,69 @@
package com.ts3client.ui;
import com.ts3client.net.TeamspeakConnection;
import com.ts3client.net.avatar.Avatar;
import javax.swing.JFileChooser;
import javax.swing.JOptionPane;
import javax.swing.SwingUtilities;
import javax.swing.filechooser.FileNameExtensionFilter;
import java.awt.Component;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
/** Sets, takes and deletes our avatar on one server, reporting failures in a dialog. */
final class AvatarActions {
/** Where the last avatar was picked from, for every server alike. */
private static File lastDirectory;
private final Component owner;
private final TeamspeakConnection conn;
AvatarActions(Component owner, TeamspeakConnection conn) {
this.owner = owner;
this.conn = conn;
}
/** Asks for an image and uploads it as our avatar. */
void choose() {
if (!conn.isConnected()) return;
JFileChooser chooser = new JFileChooser(lastDirectory);
chooser.setDialogTitle("Select your avatar");
chooser.setFileFilter(new FileNameExtensionFilter("Images", "png", "jpg", "jpeg", "gif", "bmp"));
if (chooser.showOpenDialog(owner) != JFileChooser.APPROVE_OPTION) return;
File file = chooser.getSelectedFile();
lastDirectory = file.getParentFile();
conn.setAvatar(file, error -> fail("Error uploading avatar: ", error));
}
/** Uploads someone else's avatar as ours. */
void steal(Avatar avatar) {
if (!conn.isConnected()) return;
File file;
try {
file = Files.createTempFile("avatar", "." + avatar.format().extension).toFile();
Files.write(file.toPath(), avatar.data());
} catch (IOException e) {
fail("Error uploading avatar: ", e.getMessage());
return;
}
conn.setAvatar(file, error -> {
file.delete();
fail("Error uploading avatar: ", error);
});
}
void delete() {
if (!conn.isConnected()) return;
conn.removeAvatar(error -> fail("Error deleting avatar: ", error));
}
/** Shows {@code error}, if there is one; callable from any thread. */
private void fail(String what, String error) {
if (error == null) return;
SwingUtilities.invokeLater(() ->
JOptionPane.showMessageDialog(owner, what + error, "Error", JOptionPane.ERROR_MESSAGE));
}
}

View File

@@ -1,132 +0,0 @@
package com.ts3client.ui;
import com.formdev.flatlaf.util.ScaledEmptyBorder;
import com.formdev.flatlaf.util.UIScale;
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(
new ScaledEmptyBorder(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(new ScaledEmptyBorder(0, 8, 0, 8));
status.setFont(Theme.uiSmall());
status.setBorder(new ScaledEmptyBorder(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(new ScaledEmptyBorder(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(UIScale.scale(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);
}
}

View File

@@ -1,67 +0,0 @@
package com.ts3client.ui;
import com.formdev.flatlaf.util.UIScale;
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(UIScale.scale(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(UIScale.getUserScaleFactor(), 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;
}
}

View File

@@ -44,7 +44,6 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.util.List; import java.util.List;
import java.util.function.Consumer;
/** /**
* Read-only detail view for the server, channel or client selected, mirroring the * Read-only detail view for the server, channel or client selected, mirroring the
@@ -64,6 +63,20 @@ public final class InfoPanel extends JPanel {
void setPanelHidden(boolean hidden); void setPanelHidden(boolean hidden);
} }
/** What the avatar's menu does. */
public interface AvatarHandler {
/** Takes someone else's avatar as ours. */
void steal(Avatar avatar);
/** Whether we may upload an avatar. */
boolean canSet();
/** Asks for an image to upload as our avatar. */
void set();
void delete();
}
private static final int AVATAR_MIN_SIZE = 64; private static final int AVATAR_MIN_SIZE = 64;
private static final int AVATAR_BOX_STEP = 16; private static final int AVATAR_BOX_STEP = 16;
/** Pictures in a chat tab have no panel to fit, so they get this much. */ /** Pictures in a chat tab have no panel to fit, so they get this much. */
@@ -75,8 +88,7 @@ public final class InfoPanel extends JPanel {
/** 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. */
private final JLabel toggle = new JLabel("Show in chat tab"); private final JLabel toggle = new JLabel("Show in chat tab");
private DescriptionHandler descriptionHandler; private DescriptionHandler descriptionHandler;
/** Takes someone else's avatar as ours; null where that cannot be done. */ private AvatarHandler avatarHandler;
private Consumer<Avatar> avatarStealer;
private ChannelNode shownChannel; private ChannelNode shownChannel;
private ClientEntry shownClient; private ClientEntry shownClient;
/** The server's address while the server itself is shown. */ /** The server's address while the server itself is shown. */
@@ -219,8 +231,8 @@ public final class InfoPanel extends JPanel {
this.descriptionHandler = handler; this.descriptionHandler = handler;
} }
public void setAvatarStealer(Consumer<Avatar> stealer) { public void setAvatarHandler(AvatarHandler handler) {
this.avatarStealer = stealer; this.avatarHandler = handler;
} }
/** Runs when the user closes the info chat tab; shows details in the panel again. */ /** Runs when the user closes the info chat tab; shows details in the panel again. */
@@ -298,17 +310,23 @@ public final class InfoPanel extends JPanel {
Avatar avatar = shownClient == null ? null : shownAvatar; Avatar avatar = shownClient == null ? null : shownAvatar;
if (avatar == null || !overAvatar(avatar, e.getPoint())) return; if (avatar == null || !overAvatar(avatar, e.getPoint())) return;
JPopupMenu menu = new JPopupMenu(); JPopupMenu menu = new JPopupMenu();
JMenuItem save = new JMenuItem("Save…"); String name = "avatar_" + shownClient.nickname;
save.addActionListener(a -> saveAvatar(avatar, "avatar_" + shownClient.nickname)); menu.add(item("Save…", null, () -> saveAvatar(avatar, name)));
menu.add(save); if (avatarHandler != null && !shownClient.self) {
if (!shownClient.self && avatarStealer != null) { if (avatarHandler.canSet()) menu.add(item("Steal for avatar", null, () -> avatarHandler.steal(avatar)));
JMenuItem steal = new JMenuItem("Steal for avatar"); } else if (avatarHandler != null) {
steal.addActionListener(a -> avatarStealer.accept(avatar)); if (avatarHandler.canSet()) menu.add(item("Set avatar…", "UPLOAD_AVATAR", avatarHandler::set));
menu.add(steal); menu.add(item("Delete avatar", "DELETE_AVATAR", avatarHandler::delete));
} }
menu.show(pane, e.getX(), e.getY()); menu.show(pane, e.getX(), e.getY());
} }
private static JMenuItem item(String text, String icon, Runnable action) {
JMenuItem item = new JMenuItem(text, icon == null ? null : Icons.of(icon));
item.addActionListener(e -> action.run());
return item;
}
/** Whether {@code point} falls on the avatar's {@code <img>}. */ /** Whether {@code point} falls on the avatar's {@code <img>}. */
private boolean overAvatar(Avatar avatar, Point point) { private boolean overAvatar(Avatar avatar, Point point) {
if (avatar.file() == null) return false; if (avatar.file() == null) return false;

View File

@@ -232,9 +232,24 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
changeNickname(); changeNickname();
} }
@Override
public boolean canSetAvatar() {
return selected != null && selected.session().canSetAvatar();
}
@Override @Override
public void onSetAvatar() { public void onSetAvatar() {
if (selected != null && selected.session().isConnected()) selected.showAvatarDialog(); if (selected != null) selected.avatar().choose();
}
@Override
public boolean hasAvatar() {
return selected != null && selected.session().hasAvatar();
}
@Override
public void onDeleteAvatar() {
if (selected != null) selected.avatar().delete();
} }
@Override @Override

View File

@@ -46,8 +46,16 @@ final class MainMenuBar extends JMenuBar {
void onChangeNickname(); void onChangeNickname();
/** Whether the current server lets us upload an avatar; asked as the Self menu opens. */
boolean canSetAvatar();
void onSetAvatar(); void onSetAvatar();
/** Whether we have an avatar on the current server to delete; asked as the Self menu opens. */
boolean hasAvatar();
void onDeleteAvatar();
void onShowIdentities(); void onShowIdentities();
void onShowContacts(); void onShowContacts();
@@ -77,6 +85,7 @@ final class MainMenuBar extends JMenuBar {
private JMenuItem awayStatusItem; private JMenuItem awayStatusItem;
private JCheckBoxMenuItem commanderItem; private JCheckBoxMenuItem commanderItem;
private JMenuItem avatarItem; private JMenuItem avatarItem;
private JMenuItem deleteAvatarItem;
private JMenuItem banListItem; private JMenuItem banListItem;
MainMenuBar(Bookmarks bookmarks, Listener listener) { MainMenuBar(Bookmarks bookmarks, Listener listener) {
@@ -118,6 +127,8 @@ final class MainMenuBar extends JMenuBar {
nick.addActionListener(e -> listener.onChangeNickname()); nick.addActionListener(e -> listener.onChangeNickname());
avatarItem = new JMenuItem("Set avatar…", Icons.of("UPLOAD_AVATAR")); avatarItem = new JMenuItem("Set avatar…", Icons.of("UPLOAD_AVATAR"));
avatarItem.addActionListener(e -> listener.onSetAvatar()); avatarItem.addActionListener(e -> listener.onSetAvatar());
deleteAvatarItem = new JMenuItem("Delete avatar", Icons.of("DELETE_AVATAR"));
deleteAvatarItem.addActionListener(e -> listener.onDeleteAvatar());
self.add(mute); self.add(mute);
self.add(deaf); self.add(deaf);
self.addSeparator(); self.addSeparator();
@@ -127,6 +138,22 @@ final class MainMenuBar extends JMenuBar {
self.addSeparator(); self.addSeparator();
self.add(nick); self.add(nick);
self.add(avatarItem); self.add(avatarItem);
self.add(deleteAvatarItem);
self.addMenuListener(new MenuListener() {
@Override
public void menuSelected(MenuEvent e) {
avatarItem.setVisible(!avatarItem.isEnabled() || listener.canSetAvatar());
deleteAvatarItem.setVisible(listener.hasAvatar());
}
@Override
public void menuDeselected(MenuEvent e) {
}
@Override
public void menuCanceled(MenuEvent e) {
}
});
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"));
@@ -181,6 +208,7 @@ final class MainMenuBar extends JMenuBar {
awayStatusItem.setEnabled(connected); awayStatusItem.setEnabled(connected);
commanderItem.setEnabled(connected); commanderItem.setEnabled(connected);
avatarItem.setEnabled(connected); avatarItem.setEnabled(connected);
deleteAvatarItem.setEnabled(connected);
banListItem.setEnabled(connected); banListItem.setEnabled(connected);
awayItem.setSelected(away); awayItem.setSelected(away);
commanderItem.setSelected(commander); commanderItem.setSelected(commander);

View File

@@ -15,15 +15,10 @@ import com.ts3client.sound.SoundNotifier;
import com.ts3client.text.TsLink; import com.ts3client.text.TsLink;
import javax.swing.JComponent; import javax.swing.JComponent;
import javax.swing.JOptionPane;
import javax.swing.JSplitPane; import javax.swing.JSplitPane;
import javax.swing.SwingUtilities;
import java.awt.Component; import java.awt.Component;
import java.awt.event.ComponentAdapter; import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent; import java.awt.event.ComponentEvent;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
/** /**
* One server connection and the views bound to it (tree, info and chat). The * One server connection and the views bound to it (tree, info and chat). The
@@ -46,6 +41,7 @@ final class ServerTab {
private final ServerTreePanel treePanel; private final ServerTreePanel treePanel;
private final ChatPanel chatPanel; private final ChatPanel chatPanel;
private final InfoPanel infoPanel; private final InfoPanel infoPanel;
private final AvatarActions avatar;
private final JComponent component; private final JComponent component;
private JSplitPane leftColumn; private JSplitPane leftColumn;
private int normalDividerSize; private int normalDividerSize;
@@ -107,7 +103,28 @@ final class ServerTab {
} }
}); });
infoPanel.setAvatarStealer(this::stealAvatar); this.avatar = new AvatarActions(host, conn);
infoPanel.setAvatarHandler(new InfoPanel.AvatarHandler() {
@Override
public void steal(Avatar other) {
avatar.steal(other);
}
@Override
public boolean canSet() {
return session.canSetAvatar();
}
@Override
public void set() {
avatar.choose();
}
@Override
public void delete() {
avatar.delete();
}
});
leftColumn = new JSplitPane(JSplitPane.VERTICAL_SPLIT, treePanel, infoPanel); leftColumn = new JSplitPane(JSplitPane.VERTICAL_SPLIT, treePanel, infoPanel);
leftColumn.setResizeWeight(0.68); leftColumn.setResizeWeight(0.68);
@@ -175,29 +192,8 @@ final class ServerTab {
// ---- dialogs ---- // ---- dialogs ----
void showAvatarDialog() { AvatarActions avatar() {
if (conn.isConnected()) new AvatarDialog(host, conn).setVisible(true); return avatar;
}
/** Uploads someone else's avatar as ours. */
private void stealAvatar(Avatar avatar) {
if (!conn.isConnected()) return;
File file;
try {
file = Files.createTempFile("avatar", "." + avatar.format().extension).toFile();
Files.write(file.toPath(), avatar.data());
} catch (IOException e) {
showAvatarError(e.getMessage());
return;
}
conn.setAvatar(file, error -> {
file.delete();
if (error != null) SwingUtilities.invokeLater(() -> showAvatarError(error));
});
}
private void showAvatarError(String error) {
JOptionPane.showMessageDialog(host, "Error uploading avatar: " + error, "Error", JOptionPane.ERROR_MESSAGE);
} }
// ---- chat ---- // ---- chat ----