diff --git a/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java b/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java index 0fca445..0338709 100644 --- a/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java +++ b/ts3-client/core/src/main/java/com/ts3client/session/ServerSession.java @@ -383,6 +383,20 @@ public final class ServerSession { 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 * move power only the server knows, so it is left to refuse. diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/AvatarActions.java b/ts3-client/swing/src/main/java/com/ts3client/ui/AvatarActions.java new file mode 100644 index 0000000..e54935d --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/AvatarActions.java @@ -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)); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/AvatarDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/AvatarDialog.java deleted file mode 100644 index c588e23..0000000 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/AvatarDialog.java +++ /dev/null @@ -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("Images up to " + (conn.maxAvatarBytes() / 1024) - + " KiB may be uploaded to this server. Animated GIFs stay animated."); - 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); - } -} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/AvatarView.java b/ts3-client/swing/src/main/java/com/ts3client/ui/AvatarView.java deleted file mode 100644 index dcd4960..0000000 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/AvatarView.java +++ /dev/null @@ -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; - } -} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java index 55d038e..a14f38f 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java @@ -44,7 +44,6 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.util.List; -import java.util.function.Consumer; /** * 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); } + /** 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_BOX_STEP = 16; /** 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. */ private final JLabel toggle = new JLabel("Show in chat tab"); private DescriptionHandler descriptionHandler; - /** Takes someone else's avatar as ours; null where that cannot be done. */ - private Consumer avatarStealer; + private AvatarHandler avatarHandler; private ChannelNode shownChannel; private ClientEntry shownClient; /** The server's address while the server itself is shown. */ @@ -219,8 +231,8 @@ public final class InfoPanel extends JPanel { this.descriptionHandler = handler; } - public void setAvatarStealer(Consumer stealer) { - this.avatarStealer = stealer; + public void setAvatarHandler(AvatarHandler handler) { + this.avatarHandler = handler; } /** 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; if (avatar == null || !overAvatar(avatar, e.getPoint())) return; JPopupMenu menu = new JPopupMenu(); - JMenuItem save = new JMenuItem("Save…"); - save.addActionListener(a -> saveAvatar(avatar, "avatar_" + shownClient.nickname)); - menu.add(save); - if (!shownClient.self && avatarStealer != null) { - JMenuItem steal = new JMenuItem("Steal for avatar"); - steal.addActionListener(a -> avatarStealer.accept(avatar)); - menu.add(steal); + String name = "avatar_" + shownClient.nickname; + menu.add(item("Save…", null, () -> saveAvatar(avatar, name))); + if (avatarHandler != null && !shownClient.self) { + if (avatarHandler.canSet()) menu.add(item("Steal for avatar", null, () -> avatarHandler.steal(avatar))); + } else if (avatarHandler != null) { + if (avatarHandler.canSet()) menu.add(item("Set avatar…", "UPLOAD_AVATAR", avatarHandler::set)); + menu.add(item("Delete avatar", "DELETE_AVATAR", avatarHandler::delete)); } 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 }. */ private boolean overAvatar(Avatar avatar, Point point) { if (avatar.file() == null) return false; diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index 5ea9d9a..94a81b2 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -232,9 +232,24 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { changeNickname(); } + @Override + public boolean canSetAvatar() { + return selected != null && selected.session().canSetAvatar(); + } + @Override 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 diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java index 787fe27..181e65e 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainMenuBar.java @@ -46,8 +46,16 @@ final class MainMenuBar extends JMenuBar { void onChangeNickname(); + /** Whether the current server lets us upload an avatar; asked as the Self menu opens. */ + boolean canSetAvatar(); + 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 onShowContacts(); @@ -77,6 +85,7 @@ final class MainMenuBar extends JMenuBar { private JMenuItem awayStatusItem; private JCheckBoxMenuItem commanderItem; private JMenuItem avatarItem; + private JMenuItem deleteAvatarItem; private JMenuItem banListItem; MainMenuBar(Bookmarks bookmarks, Listener listener) { @@ -118,6 +127,8 @@ final class MainMenuBar extends JMenuBar { nick.addActionListener(e -> listener.onChangeNickname()); avatarItem = new JMenuItem("Set avatar…", Icons.of("UPLOAD_AVATAR")); avatarItem.addActionListener(e -> listener.onSetAvatar()); + deleteAvatarItem = new JMenuItem("Delete avatar", Icons.of("DELETE_AVATAR")); + deleteAvatarItem.addActionListener(e -> listener.onDeleteAvatar()); self.add(mute); self.add(deaf); self.addSeparator(); @@ -127,6 +138,22 @@ final class MainMenuBar extends JMenuBar { self.addSeparator(); self.add(nick); 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"); JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER")); @@ -181,6 +208,7 @@ final class MainMenuBar extends JMenuBar { awayStatusItem.setEnabled(connected); commanderItem.setEnabled(connected); avatarItem.setEnabled(connected); + deleteAvatarItem.setEnabled(connected); banListItem.setEnabled(connected); awayItem.setSelected(away); commanderItem.setSelected(commander); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java index 6e2e141..ca57c46 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java @@ -15,15 +15,10 @@ import com.ts3client.sound.SoundNotifier; import com.ts3client.text.TsLink; import javax.swing.JComponent; -import javax.swing.JOptionPane; import javax.swing.JSplitPane; -import javax.swing.SwingUtilities; import java.awt.Component; import java.awt.event.ComponentAdapter; 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 @@ -46,6 +41,7 @@ final class ServerTab { private final ServerTreePanel treePanel; private final ChatPanel chatPanel; private final InfoPanel infoPanel; + private final AvatarActions avatar; private final JComponent component; private JSplitPane leftColumn; 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.setResizeWeight(0.68); @@ -175,29 +192,8 @@ final class ServerTab { // ---- dialogs ---- - void showAvatarDialog() { - if (conn.isConnected()) new AvatarDialog(host, conn).setVisible(true); - } - - /** 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); + AvatarActions avatar() { + return avatar; } // ---- chat ----