diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java b/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java index f0d84e6..5b97760 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java @@ -55,7 +55,7 @@ public final class Icons { private static ImageIcon themed(String key, int size, Painter fallback) { ImageIcon icon = IconTheme.icon(key, size); - return icon != null ? icon : make(fallback); + return icon != null ? icon : make(size, fallback); } /** @@ -71,10 +71,16 @@ public final class Icons { } private static ImageIcon make(Painter p) { - BufferedImage img = new BufferedImage(SZ, SZ, BufferedImage.TYPE_INT_ARGB); + return make(SZ, p); + } + + /** Runs a painter (which draws in a 16×16 box) scaled to {@code size}. */ + private static ImageIcon make(int size, Painter p) { + BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); Graphics2D g = img.createGraphics(); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); + g.scale(size / (double) SZ, size / (double) SZ); p.paint(g); g.dispose(); return new ImageIcon(img); @@ -111,25 +117,37 @@ public final class Icons { // ---- client status icons ---- public static ImageIcon clientIdle() { - return themed("PLAYER_OFF", g -> paintPerson(g, Theme.IDLE_CLIENT)); + return clientIdle(SZ); + } + + public static ImageIcon clientIdle(int size) { + return themed("PLAYER_OFF", size, g -> paintPerson(g, Theme.IDLE_CLIENT)); } public static ImageIcon clientTalking() { - return themed("PLAYER_ON", g -> paintPerson(g, Theme.TALKING)); + return clientTalking(SZ); + } + + public static ImageIcon clientTalking(int size) { + return themed("PLAYER_ON", size, g -> paintPerson(g, Theme.TALKING)); } public static ImageIcon clientAway() { - return themed("AWAY", g -> paintPerson(g, Theme.AWAY)); + return clientAway(SZ); + } + + public static ImageIcon clientAway(int size) { + return themed("AWAY", size, g -> paintPerson(g, Theme.AWAY)); } /** A channel commander that is not talking. */ public static ImageIcon clientCommander() { - return themed("PLAYER_COMMANDER_OFF", g -> paintPerson(g, Theme.IDLE_CLIENT)); + return clientCommander(SZ); } /** A channel commander that is talking. */ public static ImageIcon clientCommanderTalking() { - return themed("PLAYER_COMMANDER_ON", g -> paintPerson(g, Theme.TALKING)); + return clientCommanderTalking(SZ); } public static ImageIcon clientQuery() { @@ -137,11 +155,29 @@ public final class Icons { } public static ImageIcon micMuted() { - return themed("INPUT_MUTED", Icons::paintMicMuted); + return micMuted(SZ); + } + + public static ImageIcon micMuted(int size) { + return themed("INPUT_MUTED", size, Icons::paintMicMuted); } public static ImageIcon speakerMuted() { - return themed("OUTPUT_MUTED", Icons::paintSpeakerMuted); + return speakerMuted(SZ); + } + + public static ImageIcon speakerMuted(int size) { + return themed("OUTPUT_MUTED", size, Icons::paintSpeakerMuted); + } + + /** A channel commander that is not talking. */ + public static ImageIcon clientCommander(int size) { + return themed("PLAYER_COMMANDER_OFF", size, g -> paintPerson(g, Theme.IDLE_CLIENT)); + } + + /** A channel commander that is talking. */ + public static ImageIcon clientCommanderTalking(int size) { + return themed("PLAYER_COMMANDER_ON", size, g -> paintPerson(g, Theme.TALKING)); } // ---- toolbar / action icons ---- @@ -190,7 +226,11 @@ public final class Icons { } public static ImageIcon app() { - return make(Icons::paintApp); + return app(SZ); + } + + public static ImageIcon app(int size) { + return make(size, Icons::paintApp); } // ---- built-in painters ---- 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 95ec9a5..5bca4c1 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 @@ -76,6 +76,9 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { private final JLabel statusLabel = new JLabel("Not connected"); private final JLabel codecLabel = new JLabel(); + /** Mirrors the active server's own client state next to the clock. */ + private TrayController tray; + private JToolBar toolbar; private JButton connectButton; private JButton disconnectButton; @@ -104,14 +107,14 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { addWindowListener(new WindowAdapter() { @Override public void windowClosing(WindowEvent e) { - shutdown(); - System.exit(0); + quit(); } }); // Catches Ctrl+C / SIGTERM so we still leave the servers cleanly. Runtime.getRuntime().addShutdownHook(shutdownHook); setMinimumSize(new Dimension(720, 480)); + tray = new TrayController(this, this::quit); setJMenuBar(buildMenuBar()); toolbar = buildToolbar(); @@ -127,6 +130,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { selectTab(first); first.chat().appendSystem("Welcome to the TS3J Swing client."); first.chat().appendSystem("Use Connections → Connect to join a server."); + first.chat().appendSystem(tray.status()); installPushToTalk(); statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus()); @@ -158,10 +162,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { closeTab.setAccelerator(KeyStroke.getKeyStroke("control W")); closeTab.addActionListener(e -> closeTab(selected)); JMenuItem quit = new JMenuItem("Quit", Icons.of("QUIT")); - quit.addActionListener(e -> { - shutdown(); - System.exit(0); - }); + quit.addActionListener(e -> quit()); connections.add(connect); connections.add(disconnect); connections.add(closeTab); @@ -307,6 +308,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { add(toolbar, BorderLayout.NORTH); updateToolbar(); refreshTabs(); + if (tray != null) tray.refreshIcon(); revalidate(); repaint(); } @@ -376,6 +378,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { ServerTab previous = micTab; micTab = tab; refreshTabs(); + updateTray(); // Closing and reopening the capture line can block briefly; keep it off the EDT. new Thread(() -> { if (previous != null) previous.setMicrophoneActive(false); @@ -411,6 +414,12 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { updateToolbar(); updateStatusLabel(); } + updateTray(); + } + + /** The local client on {@code tab} started or stopped talking, muted itself, … */ + void selfStateChanged(ServerTab tab) { + if (tabs.contains(tab)) updateTray(); } void tabConnected(ServerTab tab) { @@ -618,6 +627,13 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { tab.shutdown(); } soundPlayer.shutdown(); + if (tray != null) tray.dispose(); + } + + /** Leaves every server and ends the process. */ + private void quit() { + shutdown(); + System.exit(0); } private void showIdentities() { @@ -690,6 +706,26 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { awayItem.setSelected(away); awayButton.setSelected(away); commanderItem.setSelected(connected && selected.isCommander()); + updateTray(); + } + + /** + * The server the tray icon speaks for: the one holding the microphone, or the + * visible one when nobody is capturing. + */ + private ServerTab trayTab() { + if (micTab != null && micTab.isConnected()) return micTab; + return selected != null && selected.isConnected() ? selected : null; + } + + private void updateTray() { + if (tray == null) return; + ServerTab tab = trayTab(); + if (tab == null) { + tray.update(SelfState.DISCONNECTED, null); + } else { + tray.update(tab.selfState(), tab.title()); + } } private void updateStatusLabel() { diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/SelfState.java b/ts3-client/swing/src/main/java/com/ts3client/ui/SelfState.java new file mode 100644 index 0000000..e09c852 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/SelfState.java @@ -0,0 +1,52 @@ +package com.ts3client.ui; + +import javax.swing.ImageIcon; + +/** + * The state of the local client on one server, as the tree shows it for any other + * client — the states are listed in the order the official client gives them + * priority, the strongest one first. + */ +enum SelfState { + + DISCONNECTED("Not connected"), + DEAFENED("Speakers muted"), + MIC_MUTED("Microphone muted"), + AWAY("Away"), + COMMANDER_TALKING("Talking (channel commander)"), + COMMANDER("Channel commander"), + TALKING("Talking"), + IDLE("Connected"); + + private final String label; + + SelfState(String label) { + this.label = label; + } + + String label() { + return label; + } + + /** The icon this state is drawn with, from the active icon pack. */ + ImageIcon icon(int size) { + switch (this) { + case DISCONNECTED: + return Icons.app(size); + case DEAFENED: + return Icons.speakerMuted(size); + case MIC_MUTED: + return Icons.micMuted(size); + case AWAY: + return Icons.clientAway(size); + case COMMANDER_TALKING: + return Icons.clientCommanderTalking(size); + case COMMANDER: + return Icons.clientCommander(size); + case TALKING: + return Icons.clientTalking(size); + default: + return Icons.clientIdle(size); + } + } +} 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 3b702dc..026464a 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 @@ -153,6 +153,18 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions { return commander; } + /** What the local client looks like on this server, for the tray icon. */ + SelfState selfState() { + if (!conn.isConnected()) return SelfState.DISCONNECTED; + if (deafened) return SelfState.DEAFENED; + if (micMuted) return SelfState.MIC_MUTED; + if (away) return SelfState.AWAY; + ClientEntry self = conn.getModel().getClient(conn.getSelfClientId()); + boolean talking = self != null && self.talking; + if (commander) return talking ? SelfState.COMMANDER_TALKING : SelfState.COMMANDER; + return talking ? SelfState.TALKING : SelfState.IDLE; + } + /** Path of the channel we are in, or empty when not connected. */ String currentChannelPath() { if (!conn.isConnected()) return ""; @@ -511,7 +523,10 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions { @Override public void onTalkStateChanged(int clientId, boolean talking) { - SwingUtilities.invokeLater(treePanel::refreshVisual); + SwingUtilities.invokeLater(() -> { + treePanel.refreshVisual(); + if (clientId == conn.getSelfClientId()) host.selfStateChanged(this); + }); } @Override diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/TrayController.java b/ts3-client/swing/src/main/java/com/ts3client/ui/TrayController.java new file mode 100644 index 0000000..8aa6cfc --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/TrayController.java @@ -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. + * + *
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 On X11 the toolkit draws the icon onto an opaque background, which is the
+ * price of this fallback; the menu, on the other hand, is the desktop's own.
+ */
+public final class AwtTray implements TrayBackend {
+
+ private final TrayIcon icon;
+ private final int size;
+
+ /**
+ * @param menu the icon's context menu, or {@code null} to have right clicks
+ * reported to {@code listener} instead
+ * @param initial the image to dock with, rendered at the tray's icon size
+ * @return the icon, or {@code null} when this desktop has no tray at all
+ */
+ public static AwtTray create(Listener listener, PopupMenu menu, IntFunction Which backend ends up with the icon depends on what the panel offers, and a
+ * panel that quietly ignores a docking request leaves nothing to see, so it is
+ * worth being able to ask.
+ */
+final class TrayLog {
+
+ private static final boolean ENABLED = Boolean.getBoolean("ts3j.tray.debug");
+
+ private TrayLog() {
+ }
+
+ static void debug(String message) {
+ if (ENABLED) System.err.println("tray: " + message);
+ }
+}
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/tray/X11.java b/ts3-client/swing/src/main/java/com/ts3client/ui/tray/X11.java
new file mode 100644
index 0000000..a03847a
--- /dev/null
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/tray/X11.java
@@ -0,0 +1,411 @@
+package com.ts3client.ui.tray;
+
+import java.lang.foreign.AddressLayout;
+import java.lang.foreign.Arena;
+import java.lang.foreign.FunctionDescriptor;
+import java.lang.foreign.Linker;
+import java.lang.foreign.MemorySegment;
+import java.lang.foreign.SymbolLookup;
+import java.lang.foreign.ValueLayout;
+import java.lang.invoke.MethodHandle;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Raw binding to {@code libX11} through the Foreign Function & Memory API, with
+ * just the calls {@link X11Tray} needs.
+ *
+ * This class is the only place that knows about Xlib's ABI: the entry points, the
+ * protocol constants and the offsets of the few structures that are read or written
+ * directly. Loading is lazy and failure is expected — on a machine with no X11 (or a
+ * pure Wayland session) {@link #isAvailable()} returns {@code false} and the caller
+ * falls back to AWT's own tray icon.
+ *
+ * Offsets are those of the LP64 ABI, in which {@code XID}, {@code Atom} and
+ * {@code long} are 64 bits wide and every field is naturally aligned.
+ */
+final class X11 {
+
+ private X11() {
+ }
+
+ static final AddressLayout PTR = ValueLayout.ADDRESS;
+ static final ValueLayout.OfInt INT = ValueLayout.JAVA_INT;
+ static final ValueLayout.OfLong LONG = ValueLayout.JAVA_LONG;
+
+ // ---- protocol constants (X.h) ----
+
+ static final int INPUT_OUTPUT = 1;
+ static final int ALLOC_NONE = 0;
+ static final int PROP_MODE_REPLACE = 0;
+ static final int Z_PIXMAP = 2;
+ static final int SUCCESS = 0;
+
+ /** Fields of {@code XSetWindowAttributes} passed to {@code XCreateWindow}. */
+ static final long CW_BACK_PIXEL = 1L << 1;
+ static final long CW_BORDER_PIXEL = 1L << 3;
+ static final long CW_EVENT_MASK = 1L << 11;
+ static final long CW_COLORMAP = 1L << 13;
+
+ static final long BUTTON_PRESS_MASK = 1L << 2;
+ static final long EXPOSURE_MASK = 1L << 15;
+ static final long STRUCTURE_NOTIFY_MASK = 1L << 17;
+ static final long NO_EVENT_MASK = 0L;
+
+ static final int BUTTON_PRESS = 4;
+ static final int EXPOSE = 12;
+ static final int MAP_NOTIFY = 19;
+ static final int REPARENT_NOTIFY = 21;
+ static final int CONFIGURE_NOTIFY = 22;
+ static final int CLIENT_MESSAGE = 33;
+
+ /** Predefined atoms (Xatom.h). */
+ static final long XA_STRING = 31;
+ static final long XA_VISUALID = 32;
+
+ static final long VISUAL_ID_MASK = 0x1;
+
+ // ---- structure layout ----
+
+ /** {@code XEvent} is a union; 192 bytes is its size on every LP64 platform. */
+ static final long EVENT_SIZE = 192;
+ static final long EVENT_TYPE = 0;
+
+ /** {@code XExposeEvent} / {@code XConfigureEvent} / {@code XButtonEvent} share this prefix. */
+ static final long EVENT_WINDOW = 32;
+
+ /** {@code XConfigureEvent}: the reconfigured window, then its new geometry. */
+ static final long CONFIGURE_WINDOW = 40;
+ static final long CONFIGURE_WIDTH = 56;
+ static final long CONFIGURE_HEIGHT = 60;
+
+ /** {@code XReparentEvent}: the reparented window and its new parent. */
+ static final long REPARENT_WINDOW = 40;
+ static final long REPARENT_PARENT = 48;
+
+ /** {@code XButtonEvent}. */
+ static final long BUTTON_X_ROOT = 72;
+ static final long BUTTON_Y_ROOT = 76;
+ static final long BUTTON_BUTTON = 84;
+
+ /** {@code XClientMessageEvent}. */
+ static final long CLIENT_MESSAGE_TYPE = 40;
+ static final long CLIENT_MESSAGE_FORMAT = 48;
+ static final long CLIENT_MESSAGE_DATA = 56;
+
+ /** {@code XSetWindowAttributes}. */
+ static final long ATTRIBUTES_SIZE = 112;
+ static final long ATTRIBUTES_BACKGROUND_PIXEL = 8;
+ static final long ATTRIBUTES_BORDER_PIXEL = 24;
+ static final long ATTRIBUTES_EVENT_MASK = 72;
+ static final long ATTRIBUTES_COLORMAP = 96;
+
+ /** {@code XSizeHints} fields we set, and its {@code flags} bits (Xutil.h). */
+ static final long SIZE_HINTS_SIZE = 80;
+ static final long SIZE_HINTS_FLAGS = 0;
+ static final long SIZE_HINTS_MIN_WIDTH = 24;
+ static final long SIZE_HINTS_MIN_HEIGHT = 28;
+ static final long SIZE_HINTS_MAX_WIDTH = 32;
+ static final long SIZE_HINTS_MAX_HEIGHT = 36;
+ static final long SIZE_HINTS_BASE_WIDTH = 64;
+ static final long SIZE_HINTS_BASE_HEIGHT = 68;
+ static final long P_MIN_SIZE = 1L << 4;
+ static final long P_MAX_SIZE = 1L << 5;
+ static final long P_BASE_SIZE = 1L << 8;
+
+ /** {@code XVisualInfo}. */
+ static final long VISUAL_INFO_SIZE = 64;
+ static final long VISUAL_INFO_VISUAL = 0;
+ static final long VISUAL_INFO_VISUALID = 8;
+ static final long VISUAL_INFO_DEPTH = 20;
+
+ /** {@code XImage}: only the data pointer is touched, to unhook it before freeing. */
+ static final long IMAGE_DATA = 16;
+
+ private static final Linker LINKER = Linker.nativeLinker();
+ private static final String[] LIBRARY_NAMES = {"libX11.so.6", "libX11.so"};
+
+ private static final class Handles {
+ static final SymbolLookup LOOKUP = load();
+
+ static final MethodHandle X_OPEN_DISPLAY =
+ downcall("XOpenDisplay", FunctionDescriptor.of(PTR, PTR));
+ static final MethodHandle X_CLOSE_DISPLAY =
+ downcall("XCloseDisplay", FunctionDescriptor.of(INT, PTR));
+ static final MethodHandle X_INTERN_ATOM =
+ downcall("XInternAtom", FunctionDescriptor.of(LONG, PTR, PTR, INT));
+ static final MethodHandle X_GET_SELECTION_OWNER =
+ downcall("XGetSelectionOwner", FunctionDescriptor.of(LONG, PTR, LONG));
+ static final MethodHandle X_DEFAULT_SCREEN =
+ downcall("XDefaultScreen", FunctionDescriptor.of(INT, PTR));
+ static final MethodHandle X_ROOT_WINDOW =
+ downcall("XRootWindow", FunctionDescriptor.of(LONG, PTR, INT));
+ static final MethodHandle X_GET_WINDOW_PROPERTY =
+ downcall("XGetWindowProperty", FunctionDescriptor.of(INT, PTR, LONG, LONG, LONG, LONG,
+ INT, LONG, PTR, PTR, PTR, PTR, PTR));
+ static final MethodHandle X_GET_VISUAL_INFO =
+ downcall("XGetVisualInfo", FunctionDescriptor.of(PTR, PTR, LONG, PTR, PTR));
+ static final MethodHandle X_FREE =
+ downcall("XFree", FunctionDescriptor.of(INT, PTR));
+ static final MethodHandle X_CREATE_COLORMAP =
+ downcall("XCreateColormap", FunctionDescriptor.of(LONG, PTR, LONG, PTR, INT));
+ static final MethodHandle X_CREATE_WINDOW =
+ downcall("XCreateWindow", FunctionDescriptor.of(LONG, PTR, LONG, INT, INT, INT, INT,
+ INT, INT, INT, PTR, LONG, PTR));
+ static final MethodHandle X_DESTROY_WINDOW =
+ downcall("XDestroyWindow", FunctionDescriptor.of(INT, PTR, LONG));
+ static final MethodHandle X_CHANGE_PROPERTY =
+ downcall("XChangeProperty", FunctionDescriptor.of(INT, PTR, LONG, LONG, LONG, INT,
+ INT, PTR, INT));
+ static final MethodHandle X_SELECT_INPUT =
+ downcall("XSelectInput", FunctionDescriptor.of(INT, PTR, LONG, LONG));
+ static final MethodHandle X_SEND_EVENT =
+ downcall("XSendEvent", FunctionDescriptor.of(INT, PTR, LONG, INT, LONG, PTR));
+ static final MethodHandle X_SET_WM_NORMAL_HINTS =
+ downcall("XSetWMNormalHints", FunctionDescriptor.ofVoid(PTR, LONG, PTR));
+ static final MethodHandle X_RESIZE_WINDOW =
+ downcall("XResizeWindow", FunctionDescriptor.of(INT, PTR, LONG, INT, INT));
+ static final MethodHandle X_MAP_WINDOW =
+ downcall("XMapWindow", FunctionDescriptor.of(INT, PTR, LONG));
+ static final MethodHandle X_UNMAP_WINDOW =
+ downcall("XUnmapWindow", FunctionDescriptor.of(INT, PTR, LONG));
+ static final MethodHandle X_FLUSH =
+ downcall("XFlush", FunctionDescriptor.of(INT, PTR));
+ static final MethodHandle X_PENDING =
+ downcall("XPending", FunctionDescriptor.of(INT, PTR));
+ static final MethodHandle X_NEXT_EVENT =
+ downcall("XNextEvent", FunctionDescriptor.of(INT, PTR, PTR));
+ static final MethodHandle X_CREATE_GC =
+ downcall("XCreateGC", FunctionDescriptor.of(PTR, PTR, LONG, LONG, PTR));
+ static final MethodHandle X_FREE_GC =
+ downcall("XFreeGC", FunctionDescriptor.of(INT, PTR, PTR));
+ static final MethodHandle X_CREATE_IMAGE =
+ downcall("XCreateImage", FunctionDescriptor.of(PTR, PTR, PTR, INT, INT, INT, PTR,
+ INT, INT, INT, INT));
+ static final MethodHandle X_PUT_IMAGE =
+ downcall("XPutImage", FunctionDescriptor.of(INT, PTR, LONG, PTR, PTR, INT, INT, INT,
+ INT, INT, INT));
+
+ private static SymbolLookup load() {
+ IllegalArgumentException last = null;
+ for (String name : LIBRARY_NAMES) {
+ try {
+ return SymbolLookup.libraryLookup(name, Arena.global());
+ } catch (IllegalArgumentException e) {
+ last = e;
+ }
+ }
+ throw (last != null) ? last : new IllegalArgumentException("libX11 not found");
+ }
+
+ private static MethodHandle downcall(String symbol, FunctionDescriptor descriptor) {
+ return LINKER.downcallHandle(
+ LOOKUP.find(symbol).orElseThrow(() ->
+ new UnsatisfiedLinkError("libX11: unresolved symbol " + symbol)),
+ descriptor);
+ }
+ }
+
+ /** {@code true} when libX11 could be loaded and every symbol we need resolved. */
+ static boolean isAvailable() {
+ try {
+ return Handles.LOOKUP != null;
+ } catch (Throwable t) {
+ return false;
+ }
+ }
+
+ // ---- display ----
+
+ /** @return the display, or {@link MemorySegment#NULL} when it cannot be opened */
+ static MemorySegment openDisplay() {
+ return (MemorySegment) call(Handles.X_OPEN_DISPLAY, MemorySegment.NULL);
+ }
+
+ static void closeDisplay(MemorySegment display) {
+ call(Handles.X_CLOSE_DISPLAY, display);
+ }
+
+ static int defaultScreen(MemorySegment display) {
+ return (int) call(Handles.X_DEFAULT_SCREEN, display);
+ }
+
+ static long rootWindow(MemorySegment display, int screen) {
+ return (long) call(Handles.X_ROOT_WINDOW, display, screen);
+ }
+
+ static long internAtom(MemorySegment display, Arena arena, String name) {
+ return (long) call(Handles.X_INTERN_ATOM, display, arena.allocateFrom(name), 0);
+ }
+
+ static long selectionOwner(MemorySegment display, long atom) {
+ return (long) call(Handles.X_GET_SELECTION_OWNER, display, atom);
+ }
+
+ static void flush(MemorySegment display) {
+ call(Handles.X_FLUSH, display);
+ }
+
+ // ---- properties ----
+
+ /**
+ * Reads a single 32-bit property value (which Xlib hands back as a C {@code long}).
+ *
+ * @return the value, or {@code 0} when the window has no such property
+ */
+ static long cardinalProperty(MemorySegment display, Arena arena, long window, long property,
+ long type) {
+ MemorySegment actualType = arena.allocate(LONG);
+ MemorySegment actualFormat = arena.allocate(INT);
+ MemorySegment items = arena.allocate(LONG);
+ MemorySegment bytesAfter = arena.allocate(LONG);
+ MemorySegment data = arena.allocate(PTR);
+ int status = (int) call(Handles.X_GET_WINDOW_PROPERTY, display, window, property, 0L, 1L, 0,
+ type, actualType, actualFormat, items, bytesAfter, data);
+ MemorySegment values = data.get(PTR, 0);
+ if (status != SUCCESS || values.equals(MemorySegment.NULL)) return 0;
+ long value = items.get(LONG, 0) > 0
+ ? values.reinterpret(Long.BYTES).get(LONG, 0)
+ : 0;
+ call(Handles.X_FREE, values);
+ return value;
+ }
+
+ static void setCardinals(MemorySegment display, Arena arena, long window, long property,
+ long type, long... values) {
+ MemorySegment data = arena.allocate(LONG, values.length);
+ for (int i = 0; i < values.length; i++) data.setAtIndex(LONG, i, values[i]);
+ call(Handles.X_CHANGE_PROPERTY, display, window, property, type, 32, PROP_MODE_REPLACE,
+ data, values.length);
+ }
+
+ static void setText(MemorySegment display, Arena arena, long window, long property, long type,
+ String text) {
+ byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
+ MemorySegment data = arena.allocate(bytes.length + 1);
+ MemorySegment.copy(bytes, 0, data, ValueLayout.JAVA_BYTE, 0, bytes.length);
+ call(Handles.X_CHANGE_PROPERTY, display, window, property, type, 8, PROP_MODE_REPLACE,
+ data, bytes.length);
+ }
+
+ // ---- visuals ----
+
+ /**
+ * Looks a visual up by id.
+ *
+ * @return its {@code XVisualInfo} (owned by Xlib, to be released with {@link #free})
+ * or {@link MemorySegment#NULL} when the id is unknown
+ */
+ static MemorySegment visualInfo(MemorySegment display, Arena arena, long visualId) {
+ MemorySegment template = arena.allocate(VISUAL_INFO_SIZE);
+ template.set(LONG, VISUAL_INFO_VISUALID, visualId);
+ MemorySegment count = arena.allocate(INT);
+ MemorySegment info = (MemorySegment) call(Handles.X_GET_VISUAL_INFO, display,
+ VISUAL_ID_MASK, template, count);
+ if (info.equals(MemorySegment.NULL) || count.get(INT, 0) < 1) return MemorySegment.NULL;
+ return info.reinterpret(VISUAL_INFO_SIZE);
+ }
+
+ static void free(MemorySegment pointer) {
+ call(Handles.X_FREE, pointer);
+ }
+
+ // ---- windows ----
+
+ static long createColormap(MemorySegment display, long window, MemorySegment visual) {
+ return (long) call(Handles.X_CREATE_COLORMAP, display, window, visual, ALLOC_NONE);
+ }
+
+ static long createWindow(MemorySegment display, long parent, int width, int height, int depth,
+ MemorySegment visual, MemorySegment attributes, long valueMask) {
+ return (long) call(Handles.X_CREATE_WINDOW, display, parent, 0, 0, width, height, 0, depth,
+ INPUT_OUTPUT, visual, valueMask, attributes);
+ }
+
+ static void destroyWindow(MemorySegment display, long window) {
+ call(Handles.X_DESTROY_WINDOW, display, window);
+ }
+
+ static void selectInput(MemorySegment display, long window, long mask) {
+ call(Handles.X_SELECT_INPUT, display, window, mask);
+ }
+
+ /**
+ * Tells the panel how big the icon wants to be. XEmbed trays lay their children
+ * out from these hints; without them a panel is free to allocate a sliver.
+ */
+ static void setSizeHints(MemorySegment display, Arena arena, long window, int size) {
+ MemorySegment hints = arena.allocate(SIZE_HINTS_SIZE);
+ hints.fill((byte) 0);
+ hints.set(LONG, SIZE_HINTS_FLAGS, P_MIN_SIZE | P_MAX_SIZE | P_BASE_SIZE);
+ hints.set(INT, SIZE_HINTS_MIN_WIDTH, size);
+ hints.set(INT, SIZE_HINTS_MIN_HEIGHT, size);
+ hints.set(INT, SIZE_HINTS_MAX_WIDTH, size);
+ hints.set(INT, SIZE_HINTS_MAX_HEIGHT, size);
+ hints.set(INT, SIZE_HINTS_BASE_WIDTH, size);
+ hints.set(INT, SIZE_HINTS_BASE_HEIGHT, size);
+ call(Handles.X_SET_WM_NORMAL_HINTS, display, window, hints);
+ }
+
+ static void resizeWindow(MemorySegment display, long window, int width, int height) {
+ call(Handles.X_RESIZE_WINDOW, display, window, width, height);
+ }
+
+ static void mapWindow(MemorySegment display, long window) {
+ call(Handles.X_MAP_WINDOW, display, window);
+ }
+
+ static void unmapWindow(MemorySegment display, long window) {
+ call(Handles.X_UNMAP_WINDOW, display, window);
+ }
+
+ // ---- events ----
+
+ static int pending(MemorySegment display) {
+ return (int) call(Handles.X_PENDING, display);
+ }
+
+ static void nextEvent(MemorySegment display, MemorySegment event) {
+ call(Handles.X_NEXT_EVENT, display, event);
+ }
+
+ static void sendEvent(MemorySegment display, long window, long mask, MemorySegment event) {
+ call(Handles.X_SEND_EVENT, display, window, 0, mask, event);
+ }
+
+ // ---- drawing ----
+
+ static MemorySegment createGC(MemorySegment display, long drawable) {
+ return (MemorySegment) call(Handles.X_CREATE_GC, display, drawable, 0L, MemorySegment.NULL);
+ }
+
+ static void freeGC(MemorySegment display, MemorySegment gc) {
+ call(Handles.X_FREE_GC, display, gc);
+ }
+
+ /** Wraps a caller-owned pixel buffer in an {@code XImage} of 32-bit ARGB pixels. */
+ static MemorySegment createImage(MemorySegment display, MemorySegment visual, int depth,
+ MemorySegment data, int width, int height) {
+ MemorySegment image = (MemorySegment) call(Handles.X_CREATE_IMAGE, display, visual, depth,
+ Z_PIXMAP, 0, data, width, height, 32, 0);
+ return image.equals(MemorySegment.NULL) ? image : image.reinterpret(IMAGE_DATA + 8);
+ }
+
+ /** Frees the {@code XImage} without touching the pixel buffer, which is ours. */
+ static void destroyImage(MemorySegment image) {
+ image.set(PTR, IMAGE_DATA, MemorySegment.NULL);
+ call(Handles.X_FREE, image);
+ }
+
+ static void putImage(MemorySegment display, long drawable, MemorySegment gc,
+ MemorySegment image, int width, int height) {
+ call(Handles.X_PUT_IMAGE, display, drawable, gc, image, 0, 0, 0, 0, width, height);
+ }
+
+ private static Object call(MethodHandle handle, Object... args) {
+ try {
+ return handle.invokeWithArguments(args);
+ } catch (Throwable t) {
+ throw new IllegalStateException("libX11 call failed", t);
+ }
+ }
+}
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/tray/X11Tray.java b/ts3-client/swing/src/main/java/com/ts3client/ui/tray/X11Tray.java
new file mode 100644
index 0000000..f5eb716
--- /dev/null
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/tray/X11Tray.java
@@ -0,0 +1,436 @@
+package com.ts3client.ui.tray;
+
+import java.awt.Graphics2D;
+import java.awt.Image;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import java.awt.image.DataBufferInt;
+import java.lang.foreign.Arena;
+import java.lang.foreign.MemorySegment;
+import java.lang.foreign.ValueLayout;
+import java.util.function.IntFunction;
+
+/**
+ * A tray icon docked into the panel's notification area by hand, over the
+ * freedesktop system tray protocol.
+ *
+ * AWT can do this too, but its icon is never transparent on X11: the toolkit
+ * embeds an ordinary window of the screen's default (opaque) visual and fills it
+ * with a background colour before drawing, so every icon sits on a coloured box.
+ * Panels that support transparency — MATE's, Xfce's, GNOME's — advertise an ARGB
+ * visual in {@code _NET_SYSTEM_TRAY_VISUAL} and composite the icon themselves;
+ * all that is needed is to dock a window of that visual, which is what
+ * this class does.
+ *
+ * Everything here runs on one thread of its own, which owns the display
+ * connection and pumps the X event queue; the rest of the client only posts new
+ * icons to it.
+ */
+public final class X11Tray implements TrayBackend {
+
+ /** The size to ask for; the panel resizes us to whatever it wants. */
+ private static final int DEFAULT_SIZE = 24;
+ private static final int POLL_MS = 40;
+ /** How long to give the panel to adopt the icon before falling back to AWT. */
+ private static final int DOCK_TIMEOUT_MS = 1500;
+ /** Anything narrower than this is a sliver, not an icon the panel meant to give us. */
+ private static final int MIN_SENSIBLE_SIZE = 8;
+ /** A tall panel should still not get a huge icon. */
+ private static final int MAX_ICON_SIZE = 64;
+ /**
+ * Panels that composite the icon themselves need not send us a single expose, so
+ * the icon is also repainted at this interval — it costs one 24×24 image.
+ */
+ private static final int REPAINT_MS = 2000;
+
+ /** {@code _NET_SYSTEM_TRAY_OPCODE} message: dock this window. */
+ private static final long SYSTEM_TRAY_REQUEST_DOCK = 0;
+ /** {@code _XEMBED_INFO} flag: the icon wants to be mapped. */
+ private static final long XEMBED_MAPPED = 1;
+
+ private final Listener listener;
+ /** Lives as long as the icon: the display connection and every buffer we pass to it. */
+ private final Arena arena = Arena.ofShared();
+
+ private final MemorySegment display;
+ private final long root;
+ private final long window;
+ private final MemorySegment visual;
+ private final int depth;
+ private final MemorySegment gc;
+
+ private final long selectionAtom;
+ private final long opcodeAtom;
+ private final long managerAtom;
+
+ private final Thread thread;
+ private volatile boolean running = true;
+ /** Set once the panel has reparented the icon into itself. */
+ private volatile boolean docked;
+ /** The square size last asked of the panel, so we ask only once per size. */
+ private int requested;
+
+ /** The icon and the size the panel gave us, both read by the event thread. */
+ private volatile IntFunction