Show the local client's state in the system tray

The icon carries the badge the tree draws next to our own nickname on the
active server — idle, talking, away, commander, microphone or speakers
muted — and clicking it brings the window back to the front. Right click
offers "Show TS3J" and "Quit".

AWT's tray icon cannot be transparent on X11: the toolkit embeds a window
of the screen's default, opaque visual and fills it with a background
colour before drawing the image, so every icon sits in a box. Panels that
can show transparent icons advertise an ARGB visual instead, so the icon
is docked by hand over the system tray protocol, in that visual, with the
image put on the window premultiplied. AWT's own icon stays as the
fallback for everything else.

Two details a panel will not forgive: an icon that publishes no size
hints is allocated a one-pixel sliver, and docking is a request, so an
icon that is never adopted has to hand over to the fallback rather than
sit invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 22:45:32 +00:00
parent ef95b6305d
commit 32e5ffb576
10 changed files with 1314 additions and 17 deletions

View File

@@ -0,0 +1,159 @@
package com.ts3client.ui;
import com.ts3client.ui.tray.AwtTray;
import com.ts3client.ui.tray.TrayBackend;
import com.ts3client.ui.tray.X11Tray;
import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
import javax.swing.JWindow;
import javax.swing.SwingUtilities;
import javax.swing.event.PopupMenuEvent;
import javax.swing.event.PopupMenuListener;
import java.awt.Frame;
import java.awt.Image;
import java.awt.MenuItem;
import java.awt.PopupMenu;
import java.util.function.IntFunction;
/**
* The system tray icon. It shows the local client's state on the active server —
* the same badge the tree draws next to a nickname, so it turns green while
* speaking and into the muted microphone or speaker when muted — and brings the
* window back to the front when clicked.
*
* <p>The icon is docked by {@link X11Tray} where the panel can show a transparent
* one, and by {@link AwtTray} everywhere else. On a desktop with no tray at all
* nothing is installed and every call here is a no-op.
*/
final class TrayController {
private final MainFrame frame;
private final Runnable onQuit;
private final TrayBackend tray;
/** What is on screen now, so repeated updates don't touch the tray. */
private SelfState state;
private String tooltip = "";
TrayController(MainFrame frame, Runnable onQuit) {
this.frame = frame;
this.onQuit = onQuit;
this.tray = install();
}
/** What happened when the icon was installed, for the diagnostics in the chat log. */
String status() {
return tray == null
? "System tray: no icon could be installed (run with -Dts3j.tray.debug=true for why)."
: "System tray: " + tray.description() + ".";
}
/** @return the backend that took the icon, or {@code null} when none could */
private TrayBackend install() {
TrayBackend.Listener listener = new TrayBackend.Listener() {
@Override
public void activated() {
SwingUtilities.invokeLater(TrayController.this::show);
}
@Override
public void menuRequested(int x, int y) {
SwingUtilities.invokeLater(() -> showMenu(x, y));
}
};
// -Dts3j.tray=awt|x11 pins the backend; by default the transparent one is
// tried first and AWT's picks up whatever it leaves.
String choice = System.getProperty("ts3j.tray", "auto");
IntFunction<Image> initial = size -> SelfState.DISCONNECTED.icon(size).getImage();
TrayBackend backend = choice.equals("awt") ? null : X11Tray.create(listener, initial);
if (backend == null && !choice.equals("x11")) {
backend = AwtTray.create(listener, buildAwtMenu(), initial);
}
return backend;
}
/** Shows the state of the given server, named by {@code serverName} in the tooltip. */
void update(SelfState newState, String serverName) {
if (tray == null) return;
String newTooltip = "TS3J — " + (serverName == null || serverName.isBlank()
? newState.label()
: serverName + ": " + newState.label());
if (newState == state && newTooltip.equals(tooltip)) return;
state = newState;
tooltip = newTooltip;
tray.setIcon(size -> newState.icon(size).getImage());
tray.setTooltip(newTooltip);
}
/** Redraws after the icon pack changed. */
void refreshIcon() {
if (tray == null || state == null) return;
SelfState current = state;
tray.setIcon(size -> current.icon(size).getImage());
}
void dispose() {
if (tray != null) tray.dispose();
}
// ---- menus ----
/** The menu for AWT's icon, which shows one of its own. */
private PopupMenu buildAwtMenu() {
PopupMenu menu = new PopupMenu();
MenuItem showItem = new MenuItem("Show TS3J");
showItem.addActionListener(e -> SwingUtilities.invokeLater(this::show));
MenuItem quitItem = new MenuItem("Quit");
quitItem.addActionListener(e -> SwingUtilities.invokeLater(onQuit));
menu.add(showItem);
menu.addSeparator();
menu.add(quitItem);
return menu;
}
/**
* The same menu for backends that have none — a Swing popup needs a component to
* hang off, so it is given an empty window at the pointer.
*/
private void showMenu(int x, int y) {
JWindow anchor = new JWindow(frame);
anchor.setLocation(x, y);
anchor.setSize(1, 1);
anchor.setVisible(true);
JPopupMenu menu = new JPopupMenu();
JMenuItem showItem = new JMenuItem("Show TS3J", Icons.app());
showItem.addActionListener(e -> show());
JMenuItem quitItem = new JMenuItem("Quit", Icons.of("QUIT"));
quitItem.addActionListener(e -> onQuit.run());
menu.add(showItem);
menu.addSeparator();
menu.add(quitItem);
menu.addPopupMenuListener(new PopupMenuListener() {
@Override
public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
}
@Override
public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
anchor.dispose();
}
@Override
public void popupMenuCanceled(PopupMenuEvent e) {
anchor.dispose();
}
});
menu.show(anchor, 0, 0);
}
/** Brings the window back from the taskbar or an iconified state. */
private void show() {
frame.setVisible(true);
frame.setExtendedState(frame.getExtendedState() & ~Frame.ICONIFIED);
frame.toFront();
frame.requestFocus();
}
}