Share icon packs between desktop and Android

The Android app's Material icons become a TeamSpeak-format pack shipped in
core, which the desktop offers next to the TeamSpeak packs. Android now
draws its icons from packs too and imports TeamSpeak's own zips, falling
back to the Material pack for what they lack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 09:39:42 +00:00
parent a7aa122728
commit cdf81f3258
92 changed files with 786 additions and 139 deletions

View File

@@ -43,7 +43,7 @@ final class DesignPanel extends JPanel {
private static final int TILE = 44;
/** Stands for "no pack": the icons the client draws itself. */
private static final String BUILT_IN = "Built-in icons";
private static final String BUILT_IN = "Plain (drawn by the client)";
/** The scale factors offered besides following the desktop (0). */
private static final double[] SCALES = {0, 1, 1.25, 1.5, 1.75, 2, 2.5, 3};

View File

@@ -12,6 +12,8 @@ import com.ts3client.gfx.IconPacks;
import javax.imageio.ImageIO;
import javax.swing.ImageIcon;
import javax.swing.UIManager;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.RenderingHints;
@@ -34,7 +36,8 @@ import java.util.concurrent.CopyOnWriteArrayList;
* instead, so the client always has a complete set.
*
* <p>Rendering is cached per key and size: vector art is rasterised at the size it
* is asked for, bitmaps are scaled from the closest resolution the pack ships.
* is asked for, in the look-and-feel's text colour where it paints with
* {@code currentColor}; bitmaps are scaled from the closest resolution the pack ships.
* {@link #SIZE} and {@link #TOOLBAR_SIZE} are unscaled; sizes passed in are pixels.
*/
public final class IconTheme {
@@ -51,7 +54,8 @@ public final class IconTheme {
private volatile List<IconPack> packs = List.of();
private volatile IconPack active;
private volatile IconPack fallback;
/** Where icons are looked up; what none of these has is drawn by {@link Icons}. */
private volatile List<IconPack> lookupOrder = List.of();
private IconTheme() {
}
@@ -72,14 +76,23 @@ public final class IconTheme {
/** Re-scans the pack folders and activates the one the settings name. */
public void reload(Settings settings) {
packs = IconPacks.findAll(new File(AppDirs.profile(), "gfx"), settings.iconPackDir);
fallback = IconPacks.fallback(packs);
setPack(IconPacks.select(packs, settings.iconPack));
IconPack pack = IconPacks.select(packs, settings.iconPack);
// The shipped pack survives a re-scan as the same object, so setPack alone may not notice.
lookupOrder = IconPacks.lookupOrder(pack, packs, null);
setPack(pack);
}
/** Switches to a pack (which may be {@code null} for the built-in icons). */
public void setPack(IconPack pack) {
if (active == pack) return;
active = pack;
lookupOrder = IconPacks.lookupOrder(pack, packs, null);
cache.clear();
for (Runnable listener : listeners) listener.run();
}
/** Re-renders the icons, which follow the look-and-feel's text colour. */
public void refresh() {
cache.clear();
for (Runnable listener : listeners) listener.run();
}
@@ -105,22 +118,29 @@ public final class IconTheme {
ImageIcon cached = cache.get(cacheKey);
if (cached != null) return cached;
ImageIcon icon = render(active, key, size);
if (icon == null && active != null && active.fallsBack() && fallback != active) {
icon = render(fallback, key, size);
ImageIcon icon = null;
for (IconPack pack : lookupOrder) {
icon = render(pack, key, size);
if (icon != null) break;
}
if (icon != null) cache.put(cacheKey, icon);
return icon;
}
private static ImageIcon render(IconPack pack, String key, int size) {
if (pack == null) return null;
byte[] data = pack.icon(key, size);
if (data == null || data.length == 0) return null;
BufferedImage image = pack.isVector(key, size) ? rasterize(data, size) : decode(data, size);
BufferedImage image = pack.isVector(key, size)
? rasterize(IconPack.withCurrentColor(data, textColor().getRGB()), size)
: decode(data, size);
return image == null ? null : new ImageIcon(image);
}
private static Color textColor() {
Color c = UIManager.getColor("Label.foreground");
return c != null ? c : Color.BLACK;
}
private static BufferedImage rasterize(byte[] data, int size) {
try {
SVGDocument document = new SVGLoader().load(

View File

@@ -1,6 +1,7 @@
package com.ts3client.ui;
import com.formdev.flatlaf.util.UIScale;
import com.ts3client.gfx.IconKeys;
import javax.swing.ImageIcon;
import java.awt.BasicStroke;
@@ -73,10 +74,7 @@ public final class Icons {
return icon != null ? icon : make(size, fallback);
}
/**
* Like {@link #themed(String, Painter)}, but tries the keys in turn — packs need not
* ship every variant (none of TeamSpeak's own has a subscribed password channel).
*/
/** Like {@link #themed(String, Painter)}, but tries the keys in turn. */
private static ImageIcon themed(String[] keys, Painter fallback) {
for (String key : keys) {
ImageIcon icon = IconTheme.icon(key, small());
@@ -108,27 +106,22 @@ public final class Icons {
}
public static ImageIcon channel(boolean subscribed) {
return themed(keys("CHANNEL_GREEN", subscribed),
return themed(IconKeys.channel(false, false, subscribed),
g -> paintChannel(g, new Color(0x3E7CB1), false, subscribed));
}
/** A channel nobody can join without its password. */
public static ImageIcon channelLocked(boolean subscribed) {
return themed(keys("CHANNEL_PRIVATE", subscribed),
return themed(IconKeys.channel(true, false, subscribed),
g -> paintChannel(g, new Color(0x8A6D3B), true, subscribed));
}
/** A channel that has reached its client limit. */
public static ImageIcon channelFull(boolean subscribed) {
return themed(keys("CHANNEL_RED", subscribed),
return themed(IconKeys.channel(false, true, subscribed),
g -> paintChannel(g, new Color(0xA53F3F), false, subscribed));
}
/** The pack keys to try for a channel icon, most specific first. */
private static String[] keys(String base, boolean subscribed) {
return subscribed ? new String[]{base + "_SUBSCRIBED", base} : new String[]{base};
}
/** The chat tab for the current channel. */
public static ImageIcon channelChat() {
ImageIcon icon = of("CHANNEL_CHAT");

View File

@@ -43,6 +43,7 @@ public final class LookAndFeelManager {
*/
public static void switchTo(Settings.Appearance appearance) {
install(appearance);
IconTheme.get().refresh();
FlatLaf.updateUI();
for (Window w : Window.getWindows()) {
w.repaint();