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

@@ -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(