Show servers' icons on bookmarks, and their host button in the toolbar's corner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,9 @@ import com.ts3client.teamspeak.TeamSpeakSettingsDb;
|
||||
|
||||
import javax.swing.Box;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.DefaultListModel;
|
||||
import javax.swing.ImageIcon;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JDialog;
|
||||
@@ -25,6 +27,7 @@ import javax.swing.JSplitPane;
|
||||
import javax.swing.JTextField;
|
||||
import javax.swing.ListSelectionModel;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Frame;
|
||||
import java.awt.GridBagConstraints;
|
||||
@@ -75,6 +78,16 @@ public final class BookmarksDialog extends JDialog {
|
||||
|
||||
list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||
list.setVisibleRowCount(12);
|
||||
list.setCellRenderer(new DefaultListCellRenderer() {
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<?> l, Object value, int index,
|
||||
boolean selected, boolean focused) {
|
||||
super.getListCellRendererComponent(l, value, index, selected, focused);
|
||||
ImageIcon icon = GroupIcons.cached(((Bookmark) value).iconId);
|
||||
setIcon(icon != null ? icon : Icons.of("SERVER_GREEN"));
|
||||
return this;
|
||||
}
|
||||
});
|
||||
list.addListSelectionListener(e -> {
|
||||
if (!e.getValueIsAdjusting()) onSelectionChanged();
|
||||
});
|
||||
|
||||
@@ -287,9 +287,7 @@ final class DropIndicatorTree extends JTree {
|
||||
if (bounds == null || bounds.y + bounds.height < visible.y) continue;
|
||||
if (bounds.y > visible.y + visible.height) break;
|
||||
|
||||
TreePath path = getPathForRow(row);
|
||||
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||
List<Icon> icons = badgesOf(obj);
|
||||
List<Icon> icons = badgesOf((DefaultMutableTreeNode) getPathForRow(row).getLastPathComponent());
|
||||
if (icons.isEmpty()) continue;
|
||||
|
||||
GroupIcons.Row strip = new GroupIcons.Row(icons);
|
||||
@@ -299,7 +297,12 @@ final class DropIndicatorTree extends JTree {
|
||||
}
|
||||
|
||||
/** The icon strip a row shows on its right, empty when it has none (yet). */
|
||||
private List<Icon> badgesOf(Object node) {
|
||||
private List<Icon> badgesOf(DefaultMutableTreeNode treeNode) {
|
||||
Object node = treeNode.getUserObject();
|
||||
if (treeNode.isRoot()) {
|
||||
Icon icon = groupIcons.icon(model.serverIconId());
|
||||
return icon == null ? List.of() : List.of(icon);
|
||||
}
|
||||
if (node instanceof ClientEntry) {
|
||||
ClientEntry cl = (ClientEntry) node;
|
||||
List<Icon> icons = groupIcons.iconsOf(
|
||||
|
||||
@@ -24,6 +24,8 @@ public final class GroupIcons {
|
||||
/** Tree rows are 16×16 like TeamSpeak's own icon set. */
|
||||
private static final int SIZE = 16;
|
||||
|
||||
private static final Map<Long, ImageIcon> DISK_CACHED = new ConcurrentHashMap<>();
|
||||
|
||||
private final IconRepository repository;
|
||||
private final Map<Long, ImageIcon> decoded = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -39,27 +41,52 @@ public final class GroupIcons {
|
||||
|
||||
public ImageIcon icon(long iconId) {
|
||||
if (iconId == 0) return null;
|
||||
// TeamSpeak's default group icons belong to the icon pack, which draws them in
|
||||
// its own style; only server-uploaded icons come off the wire. The theme keeps
|
||||
// its own cache, so those are not cached again here.
|
||||
if (iconId > 0 && iconId <= IconRepository.MAX_BUNDLED_ID) {
|
||||
ImageIcon themed = IconTheme.icon("GROUP_" + iconId, UIScale.scale(SIZE));
|
||||
if (themed != null) return themed;
|
||||
}
|
||||
ImageIcon themed = themed(iconId);
|
||||
if (themed != null) return themed;
|
||||
|
||||
ImageIcon cached = decoded.get(iconId);
|
||||
if (cached != null) return cached;
|
||||
|
||||
byte[] data = repository.get(iconId);
|
||||
if (data == null) return null;
|
||||
ImageIcon icon = decode(repository.get(iconId));
|
||||
if (icon != null) decoded.put(iconId, icon);
|
||||
return icon;
|
||||
}
|
||||
|
||||
/**
|
||||
* An icon from the client's own cache, without a server to fetch it from, as
|
||||
* bookmarks show their server's.
|
||||
*
|
||||
* @return the icon, or {@code null} when it has never been downloaded
|
||||
*/
|
||||
public static ImageIcon cached(long iconId) {
|
||||
if (iconId == 0) return null;
|
||||
ImageIcon themed = themed(iconId);
|
||||
if (themed != null) return themed;
|
||||
ImageIcon icon = DISK_CACHED.get(iconId);
|
||||
if (icon != null) return icon;
|
||||
icon = decode(IconRepository.cached(iconId));
|
||||
if (icon != null) DISK_CACHED.put(iconId, icon);
|
||||
return icon;
|
||||
}
|
||||
|
||||
/**
|
||||
* TeamSpeak's default group icons belong to the icon pack, which draws them in its
|
||||
* own style; only server-uploaded icons come off the wire. The theme keeps its own
|
||||
* cache, so those are not cached again here.
|
||||
*/
|
||||
private static ImageIcon themed(long iconId) {
|
||||
if (iconId <= 0 || iconId > IconRepository.MAX_BUNDLED_ID) return null;
|
||||
return IconTheme.icon("GROUP_" + iconId, UIScale.scale(SIZE));
|
||||
}
|
||||
|
||||
private static ImageIcon decode(byte[] data) {
|
||||
if (data == null) return null;
|
||||
ImageIcon icon = new ImageIcon(data);
|
||||
if (icon.getIconWidth() <= 0) return null;
|
||||
int size = UIScale.scale(SIZE);
|
||||
if (icon.getIconWidth() != size || icon.getIconHeight() != size) {
|
||||
icon = new ImageIcon(icon.getImage().getScaledInstance(size, size, Image.SCALE_SMOOTH));
|
||||
}
|
||||
decoded.put(iconId, icon);
|
||||
return icon;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.formdev.flatlaf.util.UIScale;
|
||||
import com.ts3client.net.HostButton;
|
||||
import com.ts3client.net.PictureDownloads;
|
||||
|
||||
import javax.swing.ImageIcon;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.SwingUtilities;
|
||||
import java.awt.Image;
|
||||
import java.io.File;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* The server's host button in the toolbar's corner: its own picture, fitted to the
|
||||
* toolbar's height, or TeamSpeak's host button icon while there is none (yet).
|
||||
*/
|
||||
final class HostButtonView extends JButton {
|
||||
|
||||
/** Keeps a banner-shaped picture from crowding out the rest of the toolbar. */
|
||||
private static final int MAX_ASPECT = 6;
|
||||
|
||||
private final PictureDownloads pictures;
|
||||
private final Runnable onPictureLoaded = () -> SwingUtilities.invokeLater(this::refreshIcon);
|
||||
private HostButton shown;
|
||||
|
||||
HostButtonView(PictureDownloads pictures) {
|
||||
this.pictures = pictures;
|
||||
setVisible(false);
|
||||
addActionListener(e -> {
|
||||
if (shown != null && !shown.url().isEmpty()) Links.open(shown.url(), this);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param button the selected server's, or {@code null} to hide it */
|
||||
void show(HostButton button) {
|
||||
if (Objects.equals(button, shown)) return;
|
||||
shown = button;
|
||||
setVisible(button != null);
|
||||
if (button == null) return;
|
||||
setToolTipText(!button.tooltip().isEmpty() ? button.tooltip() : button.url().isEmpty() ? null : button.url());
|
||||
refreshIcon();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addNotify() {
|
||||
super.addNotify();
|
||||
pictures.addListener(onPictureLoaded);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeNotify() {
|
||||
pictures.removeListener(onPictureLoaded);
|
||||
super.removeNotify();
|
||||
}
|
||||
|
||||
private void refreshIcon() {
|
||||
if (shown == null) return;
|
||||
ImageIcon picture = shown.gfxUrl().isEmpty() ? null : picture(pictures.get(shown.gfxUrl()));
|
||||
setIcon(picture != null ? picture : Icons.of("HOSTER_BUTTON", UIScale.scale(IconTheme.TOOLBAR_SIZE)));
|
||||
}
|
||||
|
||||
private static ImageIcon picture(File file) {
|
||||
if (file == null) return null;
|
||||
ImageIcon icon = new ImageIcon(file.getPath());
|
||||
int w = icon.getIconWidth();
|
||||
int h = icon.getIconHeight();
|
||||
if (w <= 0 || h <= 0) return null;
|
||||
int height = UIScale.scale(IconTheme.TOOLBAR_SIZE);
|
||||
int width = Math.min(Math.max(1, Math.round((float) w * height / h)), height * MAX_ASPECT);
|
||||
if (w == width && h == height) return icon;
|
||||
return new ImageIcon(icon.getImage().getScaledInstance(width, height, Image.SCALE_SMOOTH));
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import com.ts3client.myts.MyTeamSpeakLogin;
|
||||
import com.ts3client.teamspeak.TeamSpeakImporter;
|
||||
import com.ts3client.contacts.ContactStore;
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.HostButton;
|
||||
import com.ts3client.net.PictureDownloads;
|
||||
import com.ts3client.session.SelfState;
|
||||
import com.ts3client.session.ServerSession;
|
||||
@@ -368,7 +369,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
});
|
||||
}, pictures);
|
||||
}
|
||||
|
||||
/** Rebuilds the icon-bearing chrome after the active icon pack changed. */
|
||||
@@ -460,6 +461,18 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
updateTray();
|
||||
}
|
||||
|
||||
/** The server on {@code tab} may have changed its host button. */
|
||||
void refreshHostButton(ServerTab tab) {
|
||||
if (tab == selected) toolbar.showHostButton(hostButtonOf(tab));
|
||||
}
|
||||
|
||||
/** Bookmarks leading to the server on {@code tab} take on its icon. */
|
||||
void serverIconChanged(ServerTab tab, long iconId) {
|
||||
if (!bookmarks.rememberServerIcon(tab.session().address(), tab.session().port(), iconId)) return;
|
||||
bookmarks.save();
|
||||
menuBar.rebuildBookmarks();
|
||||
}
|
||||
|
||||
/** The local client on {@code tab} started or stopped talking, muted itself, … */
|
||||
void selfStateChanged(ServerTab tab) {
|
||||
if (tabs.contains(tab)) updateTray();
|
||||
@@ -909,10 +922,15 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
||||
boolean away = connected && selected.session().isAway();
|
||||
boolean commander = connected && selected.session().isCommander();
|
||||
toolbar.refresh(connected, anyConnected, micMuted, deaf, active, away);
|
||||
toolbar.showHostButton(hostButtonOf(selected));
|
||||
menuBar.refresh(connected, away, commander);
|
||||
updateTray();
|
||||
}
|
||||
|
||||
private static HostButton hostButtonOf(ServerTab tab) {
|
||||
return tab != null && tab.session().isConnected() ? tab.connection().getModel().hostButton() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server the tray icon speaks for: the one holding the microphone, or the
|
||||
* visible one when nobody is capturing.
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.ts3client.ui;
|
||||
import com.ts3client.config.Bookmark;
|
||||
import com.ts3client.config.Bookmarks;
|
||||
|
||||
import javax.swing.ImageIcon;
|
||||
import javax.swing.JCheckBoxMenuItem;
|
||||
import javax.swing.JMenu;
|
||||
import javax.swing.JMenuBar;
|
||||
@@ -111,6 +112,21 @@ final class MainMenuBar extends JMenuBar {
|
||||
|
||||
bookmarksMenu = new JMenu("Bookmarks");
|
||||
rebuildBookmarks();
|
||||
// A server's icon may have finished downloading since the menu was built.
|
||||
bookmarksMenu.addMenuListener(new MenuListener() {
|
||||
@Override
|
||||
public void menuSelected(MenuEvent e) {
|
||||
rebuildBookmarks();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void menuDeselected(MenuEvent e) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void menuCanceled(MenuEvent e) {
|
||||
}
|
||||
});
|
||||
|
||||
JMenu self = new JMenu("Self");
|
||||
JMenuItem mute = new JMenuItem("Toggle microphone", Icons.of("CAPTURE"));
|
||||
@@ -218,7 +234,8 @@ final class MainMenuBar extends JMenuBar {
|
||||
void rebuildBookmarks() {
|
||||
bookmarksMenu.removeAll();
|
||||
for (Bookmark b : bookmarks.all()) {
|
||||
JMenuItem item = new JMenuItem(b.displayName(), Icons.of("SERVER_GREEN"));
|
||||
ImageIcon serverIcon = GroupIcons.cached(b.iconId);
|
||||
JMenuItem item = new JMenuItem(b.displayName(), serverIcon != null ? serverIcon : Icons.of("SERVER_GREEN"));
|
||||
item.setToolTipText("Connect here (middle-click to open a new tab)");
|
||||
item.addActionListener(e -> listener.onConnectBookmark(b, false));
|
||||
item.addMouseListener(new MouseAdapter() {
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.ts3client.ui;
|
||||
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
||||
import com.formdev.flatlaf.util.UIScale;
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.net.HostButton;
|
||||
import com.ts3client.net.PictureDownloads;
|
||||
|
||||
import javax.swing.Box;
|
||||
import javax.swing.JButton;
|
||||
@@ -22,10 +24,10 @@ import java.awt.FlowLayout;
|
||||
|
||||
/**
|
||||
* The main window's toolbar: connect/disconnect, the per-tab mic/speaker/away
|
||||
* controls, and the Master Volume slider on the right. A right-click anywhere on
|
||||
* the bar opens a customization menu for the window's optional chrome (this
|
||||
* toolbar has no say over the status bar's own visibility beyond reporting the
|
||||
* toggle).
|
||||
* controls, and the Master Volume slider and the server's host button on the
|
||||
* right. A right-click anywhere on the bar opens a customization menu for the
|
||||
* window's optional chrome (this toolbar has no say over the status bar's own
|
||||
* visibility beyond reporting the toggle).
|
||||
*
|
||||
* <p>Talks to {@link MainFrame} only through {@link Listener}, so it owns no
|
||||
* connection/tab state itself — {@link #refresh} is handed everything it
|
||||
@@ -68,8 +70,9 @@ final class MainToolbar extends JToolBar {
|
||||
private final JToggleButton speakerButton;
|
||||
private final DropDownToggleButton awayButton;
|
||||
private final JComponent masterVolumePanel;
|
||||
private final HostButtonView hostButton;
|
||||
|
||||
MainToolbar(Settings settings, Listener listener) {
|
||||
MainToolbar(Settings settings, Listener listener, PictureDownloads pictures) {
|
||||
this.settings = settings;
|
||||
this.listener = listener;
|
||||
|
||||
@@ -120,6 +123,13 @@ final class MainToolbar extends JToolBar {
|
||||
masterVolumePanel = buildMasterVolumeControl();
|
||||
masterVolumePanel.setVisible(settings.showMasterVolumeSlider);
|
||||
add(masterVolumePanel);
|
||||
hostButton = new HostButtonView(pictures);
|
||||
add(hostButton);
|
||||
}
|
||||
|
||||
/** Shows the selected server's host button; {@code null} hides it. */
|
||||
void showHostButton(HostButton button) {
|
||||
hostButton.show(button);
|
||||
}
|
||||
|
||||
/** Reflects the current tab's state on the buttons. */
|
||||
|
||||
@@ -111,6 +111,7 @@ final class ServerTabConnectionEvents implements ServerSession.Listener {
|
||||
treePanel.rebuild();
|
||||
treeActions.renderInfo();
|
||||
treeActions.refreshGroupsDialog();
|
||||
host.refreshHostButton(tab);
|
||||
if (session.isConnected()) {
|
||||
ChannelNode channel = session.currentChannel();
|
||||
chatPanel.setServerName(conn.getModel().getServerName());
|
||||
@@ -119,6 +120,11 @@ final class ServerTabConnectionEvents implements ServerSession.Listener {
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerIconChanged(long iconId) {
|
||||
SwingUtilities.invokeLater(() -> host.serverIconChanged(tab, iconId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInfoUpdated() {
|
||||
SwingUtilities.invokeLater(treeActions::renderInfo);
|
||||
|
||||
Reference in New Issue
Block a user