diff --git a/ts3-client/README.md b/ts3-client/README.md index 6177ef2..08a4046 100644 --- a/ts3-client/README.md +++ b/ts3-client/README.md @@ -93,6 +93,21 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged. - Sounds are mixed onto one playback line, so overlapping events never fight over the device; pack volume is separate from the voice volume. +### Icon packs +- **Icon packs** in TeamSpeak's own format: a `.zip` (or unpacked folder) of SVG/PNG + artwork plus a `settings.ini` mapping icon keys (`CHANNEL_GREEN`, `PLAYER_ON`, + `CONNECT`, …) to files. Packs are picked up from `~/.ts3jclient/gfx`, an installed + TeamSpeak 3 client (`$TS3_CLIENT_DIR` or the usual install paths) and a folder of + your choosing, so the official packs (`default_colored_2014.zip`, + `default_mono_2014.zip`, the legacy `default.zip`) work unchanged. +- The pack draws the tree (server, channel, client state), the toolbar, the menus, + the context menus, the file browser and TeamSpeak's default group icons. Vector + art is rasterised at the size it is drawn at; a pack's `FALLBACK` option decides + whether icons it lacks come from `default.zip`, and anything still missing falls + back to the icons the client draws itself. +- **Pick one in Options → Design**, which also shows a viewer of everything the pack + contains; switching redraws the window right away. + ## Requirements - Java 26+ (developed/tested on Temurin 26) - The native Opus library on the system: @@ -133,6 +148,9 @@ core/ com.ts3client │ ├── VoiceOutput voice-packet playback sink │ ├── OpusParameters live-tunable encoder settings │ └── SpeechDetector feature-based speech-probability VAD (Automatic/Hybrid) +├── gfx +│ ├── IconPack an icon pack zip/folder + its settings.ini mapping +│ └── IconPacks discovery of installed packs ├── sound │ ├── SoundEvent catalogue of actions (TeamSpeak's own event ids) │ ├── SoundPack a pack folder + its settings.ini mapping @@ -165,12 +183,14 @@ swing/ com.ts3client ├── ChatPanel chat log + input ├── SettingsDialog audio + VAD options with live meter ├── NotificationsPanel sound pack + per-action sound/important configuration + ├── IconPackPanel icon pack chooser + icon viewer (Options → Design) ├── ConnectDialog connect form ├── BookmarksDialog manage saved servers ├── IdentitiesDialog manage identities (new/import/export/improve) ├── IdentityChooser identity drop-down shared by connect/bookmark forms ├── LevelMeter dBFS meter with threshold marker - ├── Icons programmatic vector icons (no image assets) + ├── IconTheme active icon pack: rasterising (JSVG) and caching + ├── Icons icon lookup by TeamSpeak key, with drawn fallbacks └── Theme palette + fonts ``` @@ -178,7 +198,6 @@ swing/ com.ts3client - The **Automatic/Hybrid** VAD uses a lightweight energy/spectral detector rather than the WebRTC GMM model the official client ships; it is intentionally dependency-free and reusable in the core library. -- Group display shows names; **group icons** are not rendered. - Whisper is received/played but not yet **sendable** from the UI. - Playback decodes streams as mono; stereo music-bot audio is down-mixed. - Push-to-talk is captured via Swing key events, so it only works while the app diff --git a/ts3-client/core/src/main/java/com/ts3client/config/InstallDirs.java b/ts3-client/core/src/main/java/com/ts3client/config/InstallDirs.java new file mode 100644 index 0000000..7510ca2 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/config/InstallDirs.java @@ -0,0 +1,60 @@ +package com.ts3client.config; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * Locates the folders add-ons (sound packs, icon packs) are loaded from: the + * client's own folder, a folder the user configured, and the matching folder of + * an installed TeamSpeak 3 client, whose add-ons are usable as they are. + */ +public final class InstallDirs { + + /** Overrides the TeamSpeak install location the add-ons are borrowed from. */ + private static final String CLIENT_DIR_PROPERTY = "ts3.client.dir"; + private static final String CLIENT_DIR_ENV = "TS3_CLIENT_DIR"; + + private InstallDirs() { + } + + /** + * @param subfolder the add-on folder inside a TeamSpeak installation ({@code sound}, {@code gfx}) + * @param userDirectory the client's own add-on folder (may not exist yet) + * @param extraDirectory an additional folder chosen by the user, or {@code null} + * @return the folders that exist, most specific first + */ + public static List searchRoots(String subfolder, File userDirectory, String extraDirectory) { + List roots = new ArrayList<>(); + if (userDirectory != null) roots.add(userDirectory); + if (extraDirectory != null && !extraDirectory.isBlank()) roots.add(new File(extraDirectory.trim())); + + String configured = System.getProperty(CLIENT_DIR_PROPERTY, System.getenv(CLIENT_DIR_ENV)); + if (configured != null && !configured.isBlank()) roots.add(new File(configured.trim(), subfolder)); + + String home = System.getProperty("user.home", "."); + for (String candidate : new String[]{ + "TeamSpeak3-Client-linux_amd64", "TeamSpeak3-Client-linux_x86", + ".local/share/TeamSpeak3-Client-linux_amd64", "Applications/TeamSpeak3-Client-linux_amd64"}) { + roots.add(new File(new File(home, candidate), subfolder)); + } + for (String candidate : new String[]{ + "/opt/teamspeak3-client", "/opt/teamspeak3", "/usr/share/teamspeak3", + "/usr/share/teamspeak3-client"}) { + roots.add(new File(candidate, subfolder)); + } + String programFiles = System.getenv("ProgramFiles"); + if (programFiles != null) { + roots.add(new File(programFiles, "TeamSpeak 3 Client" + File.separator + subfolder)); + } + // Running from a checkout that has the official client unpacked next to it. + roots.add(new File("TeamSpeak3-Client-linux_amd64/" + subfolder)); + roots.add(new File("../TeamSpeak3-Client-linux_amd64/" + subfolder)); + + List existing = new ArrayList<>(); + for (File root : roots) { + if (root.isDirectory()) existing.add(root); + } + return existing; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java index 9d649d2..641ee4c 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java @@ -99,6 +99,12 @@ public final class Settings { public double soundVolume = 1.0; /** Extra folder to look for sound packs in, on top of the well-known locations. */ public String soundPackDir = ""; + // ---- icons ---- + /** File name of the active icon pack; empty uses the built-in icons. */ + public String iconPack = "default_colored_2014.zip"; + /** Extra folder to look for icon packs in, on top of the well-known locations. */ + public String iconPackDir = ""; + /** Which actions make a sound, and which ones are important enough to survive muting. */ public final NotificationSettings notifications = new NotificationSettings(); @@ -169,6 +175,8 @@ public final class Settings { soundPack = props.getProperty("soundPack", soundPack); soundVolume = parseD(props.getProperty("soundVolume"), soundVolume); soundPackDir = props.getProperty("soundPackDir", soundPackDir); + iconPack = props.getProperty("iconPack", iconPack); + iconPackDir = props.getProperty("iconPackDir", iconPackDir); notifications.load(props); } @@ -201,6 +209,8 @@ public final class Settings { props.setProperty("soundPack", soundPack); props.setProperty("soundVolume", Double.toString(soundVolume)); props.setProperty("soundPackDir", soundPackDir); + props.setProperty("iconPack", iconPack); + props.setProperty("iconPackDir", iconPackDir); notifications.store(props); } diff --git a/ts3-client/core/src/main/java/com/ts3client/gfx/IconPack.java b/ts3-client/core/src/main/java/com/ts3client/gfx/IconPack.java new file mode 100644 index 0000000..993358d --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/gfx/IconPack.java @@ -0,0 +1,314 @@ +package com.ts3client.gfx; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * A TeamSpeak icon pack: a zip (or unpacked directory) of images plus an optional + * {@code settings.ini} naming the file behind every icon of the user interface. + * The format is TeamSpeak's, so the packs shipped with the official client and the + * ones from its add-on site can be used as they are: + * + *
+ * [options]
+ * FALLBACK=true
+ *
+ * [gfxfiles]
+ * CHANNEL_GREEN = channel_green.svg
+ * CONNECT       = connect.svg
+ * 
+ * + *

Packs without a {@code settings.ini} are the legacy ones (TeamSpeak's own + * {@code default.zip}), whose files are named after the icon key directly, each + * prefixed with the size it was drawn for: {@code 16x16_connect.png}. Those are + * resolved by convention, picking the size closest to the one that is asked for. + * + *

Icon data is returned as bytes; rasterising it (the packs from 2014 on are + * vector art) is the frontend's job. + */ +public final class IconPack { + + private static final String INI = "settings.ini"; + /** Legacy file names: {@code x_.}. */ + private static final Pattern SIZED = Pattern.compile("(\\d+)x(\\d+)_(.+)"); + + /** One image inside a pack, with the size it was authored at (0 when unknown). */ + private record Entry(String path, int size) { + } + + private final String id; + private final File source; + private final String name; + private final boolean zipped; + private final boolean fallback; + /** Icon key to the images that can serve it, smallest first. */ + private final Map> entries; + + private IconPack(String id, File source, String name, boolean zipped, boolean fallback, + Map> entries) { + this.id = id; + this.source = source; + this.name = name; + this.zipped = zipped; + this.fallback = fallback; + this.entries = entries; + } + + /** Stable identifier used in the settings: the file (or directory) name. */ + public String id() { + return id; + } + + public String name() { + return name; + } + + public File source() { + return source; + } + + /** Whether missing icons should be looked up in the default pack, as the pack asks. */ + public boolean fallsBack() { + return fallback; + } + + /** All icon keys this pack can draw, sorted. */ + public List keys() { + return new ArrayList<>(new TreeSet<>(entries.keySet())); + } + + public boolean has(String key) { + return entries.containsKey(normalizeKey(key)); + } + + /** + * Reads the image for an icon. + * + * @param key a TeamSpeak icon key, e.g. {@code CHANNEL_GREEN} + * @param preferredSize the size it will be drawn at, so packs holding several + * resolutions can pick the closest one + * @return the image bytes (PNG or SVG), or {@code null} if the pack has no such icon + */ + public byte[] icon(String key, int preferredSize) { + Entry entry = select(entries.get(normalizeKey(key)), preferredSize); + if (entry == null) return null; + try { + return zipped ? readFromZip(entry.path()) : Files.readAllBytes(new File(source, entry.path()).toPath()); + } catch (IOException e) { + return null; + } + } + + /** @return whether the icon's image is vector art the frontend has to rasterise */ + public boolean isVector(String key, int preferredSize) { + Entry entry = select(entries.get(normalizeKey(key)), preferredSize); + return entry != null && entry.path().toLowerCase(Locale.ROOT).endsWith(".svg"); + } + + @Override + public String toString() { + return name; + } + + /** The smallest image that still covers the wanted size, else the largest one. */ + private static Entry select(List candidates, int preferredSize) { + if (candidates == null || candidates.isEmpty()) return null; + for (Entry e : candidates) { + if (e.size() == 0 || e.size() >= preferredSize) return e; + } + return candidates.get(candidates.size() - 1); + } + + private byte[] readFromZip(String path) throws IOException { + try (ZipFile zip = new ZipFile(source)) { + ZipEntry entry = zip.getEntry(path); + if (entry == null) return null; + try (InputStream in = zip.getInputStream(entry)) { + return in.readAllBytes(); + } + } + } + + private static String normalizeKey(String key) { + return key == null ? "" : key.trim().toUpperCase(Locale.ROOT); + } + + // ---- loading ---- + + /** + * Reads a pack from a {@code .zip} file or from a directory holding its contents. + * + * @return the pack, or {@code null} when it holds no icons + */ + public static IconPack load(File source) { + if (source == null) return null; + boolean zipped = source.isFile(); + if (!zipped && !source.isDirectory()) return null; + + Map files = zipped ? listZip(source) : listDirectory(source); + if (files == null || files.isEmpty()) return null; + + String iniPath = files.keySet().stream() + .filter(p -> p.equalsIgnoreCase(INI)) + .findFirst().orElse(null); + Map mapping = new LinkedHashMap<>(); + Map info = new LinkedHashMap<>(); + boolean fallback = true; + if (iniPath != null) { + byte[] ini = read(source, zipped, iniPath); + if (ini != null) fallback = parseIni(ini, mapping, info); + } + + Map> entries = iniPath != null + ? fromMapping(mapping, files) + : byConvention(files); + if (entries.isEmpty()) return null; + + String id = source.getName(); + String name = info.getOrDefault("name", displayName(id)); + return new IconPack(id, source, name, zipped, fallback, entries); + } + + /** Turns {@code default_colored_2014.zip} into {@code Default Colored 2014}. */ + private static String displayName(String fileName) { + String base = fileName.toLowerCase(Locale.ROOT).endsWith(".zip") + ? fileName.substring(0, fileName.length() - 4) : fileName; + StringBuilder out = new StringBuilder(); + for (String word : base.split("[_\\-\\s]+")) { + if (word.isEmpty()) continue; + if (out.length() > 0) out.append(' '); + out.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)); + } + return out.length() == 0 ? fileName : out.toString(); + } + + /** @return the pack's FALLBACK option */ + private static boolean parseIni(byte[] data, Map mapping, Map info) { + boolean fallback = true; + try (BufferedReader in = new BufferedReader( + new InputStreamReader(new java.io.ByteArrayInputStream(data), StandardCharsets.UTF_8))) { + String section = ""; + String line; + while ((line = in.readLine()) != null) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith(";")) continue; + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + section = trimmed.substring(1, trimmed.length() - 1).trim().toLowerCase(Locale.ROOT); + continue; + } + int eq = trimmed.indexOf('='); + if (eq < 0) continue; + String key = trimmed.substring(0, eq).trim(); + String value = trimmed.substring(eq + 1).trim(); + switch (section) { + case "gfxfiles" -> { + if (!value.isEmpty()) mapping.put(normalizeKey(key), value); + } + case "info" -> info.put(key.toLowerCase(Locale.ROOT), value); + case "options" -> { + if (key.equalsIgnoreCase("FALLBACK")) fallback = !value.equalsIgnoreCase("false"); + } + default -> { + } + } + } + } catch (IOException ignored) { + } + return fallback; + } + + private static Map> fromMapping(Map mapping, Map files) { + Map> entries = new LinkedHashMap<>(); + for (Map.Entry e : mapping.entrySet()) { + String path = resolve(e.getValue(), files); + if (path != null) entries.put(e.getKey(), List.of(new Entry(path, 0))); + } + return entries; + } + + /** + * Legacy packs (and the extra files of newer ones) are keyed by their name: + * {@code 16x16_connect.png} serves {@code CONNECT}, as does {@code 32x32_connect.png} + * when no smaller one exists. + */ + private static Map> byConvention(Map files) { + Map> entries = new LinkedHashMap<>(); + for (String path : files.keySet()) { + int dot = path.lastIndexOf('.'); + if (dot < 0 || path.indexOf('/') >= 0) continue; + String ext = path.substring(dot + 1).toLowerCase(Locale.ROOT); + if (!ext.equals("png") && !ext.equals("svg")) continue; + + String base = path.substring(0, dot); + int size = 0; + Matcher m = SIZED.matcher(base); + if (m.matches()) { + size = Integer.parseInt(m.group(1)); + base = m.group(3); + } + entries.computeIfAbsent(normalizeKey(base), k -> new ArrayList<>()).add(new Entry(path, size)); + } + for (List candidates : entries.values()) { + candidates.sort((a, b) -> Integer.compare(a.size(), b.size())); + } + return entries; + } + + /** Pack authors write file names as they see them, so a mismatched case is retried. */ + private static String resolve(String fileName, Map files) { + if (files.containsKey(fileName)) return fileName; + for (String path : files.keySet()) { + if (path.equalsIgnoreCase(fileName)) return path; + } + return null; + } + + private static byte[] read(File source, boolean zipped, String path) { + try { + if (!zipped) return Files.readAllBytes(new File(source, path).toPath()); + try (ZipFile zip = new ZipFile(source)) { + ZipEntry entry = zip.getEntry(path); + if (entry == null) return null; + try (InputStream in = zip.getInputStream(entry)) { + return in.readAllBytes(); + } + } + } catch (IOException e) { + return null; + } + } + + private static Map listZip(File file) { + if (!file.getName().toLowerCase(Locale.ROOT).endsWith(".zip")) return null; + Map files = new LinkedHashMap<>(); + try (ZipFile zip = new ZipFile(file)) { + zip.stream().filter(e -> !e.isDirectory()).forEach(e -> files.put(e.getName(), e.getSize())); + } catch (IOException e) { + return null; + } + return files; + } + + private static Map listDirectory(File dir) { + Map files = new LinkedHashMap<>(); + File[] children = dir.listFiles(File::isFile); + if (children == null) return files; + for (File f : children) files.put(f.getName(), f.length()); + return files; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/gfx/IconPacks.java b/ts3-client/core/src/main/java/com/ts3client/gfx/IconPacks.java new file mode 100644 index 0000000..348ece9 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/gfx/IconPacks.java @@ -0,0 +1,64 @@ +package com.ts3client.gfx; + +import com.ts3client.config.InstallDirs; + +import java.io.File; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Finds the icon packs installed on this machine: the client's own {@code gfx/} + * folder, an extra folder the user configured, and the {@code gfx/} folder of an + * installed TeamSpeak 3 client (whose packs are usable as they are, see + * {@link IconPack}). + */ +public final class IconPacks { + + /** The pack every other one falls back to, as TeamSpeak does. */ + public static final String DEFAULT_ID = "default.zip"; + + private IconPacks() { + } + + /** + * Loads every pack found under {@code extraDirectory} and the well-known + * locations, the first search root winning on a name clash. + * + * @param userDirectory the client's own pack folder (may not exist yet) + * @param extraDirectory an additional folder chosen by the user, or {@code null} + * @return the packs, ordered by display name + */ + public static List findAll(File userDirectory, String extraDirectory) { + Map byId = new LinkedHashMap<>(); + for (File root : InstallDirs.searchRoots("gfx", userDirectory, extraDirectory)) { + File[] children = root.listFiles(); + if (children == null) continue; + for (File child : children) { + if (child.isFile() && !child.getName().toLowerCase(Locale.ROOT).endsWith(".zip")) continue; + if (byId.containsKey(child.getName())) continue; + IconPack pack = IconPack.load(child); + if (pack != null) byId.put(pack.id(), pack); + } + } + List packs = new ArrayList<>(byId.values()); + packs.sort(Comparator.comparing(p -> p.name().toLowerCase(Locale.ROOT))); + return packs; + } + + /** @return the pack with this id, or {@code null} when it is not installed */ + public static IconPack select(List packs, String id) { + for (IconPack p : packs) { + if (p.id().equals(id)) return p; + } + return null; + } + + /** @return the pack unresolved icons are taken from, or {@code null} */ + public static IconPack fallback(List packs) { + return select(packs, DEFAULT_ID); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/sound/SoundPacks.java b/ts3-client/core/src/main/java/com/ts3client/sound/SoundPacks.java index b82242b..021d0eb 100644 --- a/ts3-client/core/src/main/java/com/ts3client/sound/SoundPacks.java +++ b/ts3-client/core/src/main/java/com/ts3client/sound/SoundPacks.java @@ -1,5 +1,7 @@ package com.ts3client.sound; +import com.ts3client.config.InstallDirs; + import java.io.File; import java.util.ArrayList; import java.util.Comparator; @@ -15,10 +17,6 @@ import java.util.Map; */ public final class SoundPacks { - /** Overrides the TeamSpeak install location the packs are borrowed from. */ - private static final String CLIENT_DIR_PROPERTY = "ts3.client.dir"; - private static final String CLIENT_DIR_ENV = "TS3_CLIENT_DIR"; - private SoundPacks() { } @@ -32,7 +30,7 @@ public final class SoundPacks { */ public static List findAll(File userDirectory, String extraDirectory) { Map byId = new LinkedHashMap<>(); - for (File root : searchRoots(userDirectory, extraDirectory)) { + for (File root : InstallDirs.searchRoots("sound", userDirectory, extraDirectory)) { File[] entries = root.listFiles(File::isDirectory); if (entries == null) continue; for (File dir : entries) { @@ -52,38 +50,4 @@ public final class SoundPacks { } return packs.isEmpty() ? null : packs.get(0); } - - private static List searchRoots(File userDirectory, String extraDirectory) { - List roots = new ArrayList<>(); - if (userDirectory != null) roots.add(userDirectory); - if (extraDirectory != null && !extraDirectory.isBlank()) roots.add(new File(extraDirectory.trim())); - - String configured = System.getProperty(CLIENT_DIR_PROPERTY, System.getenv(CLIENT_DIR_ENV)); - if (configured != null && !configured.isBlank()) roots.add(new File(configured.trim(), "sound")); - - String home = System.getProperty("user.home", "."); - for (String candidate : new String[]{ - "TeamSpeak3-Client-linux_amd64", "TeamSpeak3-Client-linux_x86", - ".local/share/TeamSpeak3-Client-linux_amd64", "Applications/TeamSpeak3-Client-linux_amd64"}) { - roots.add(new File(new File(home, candidate), "sound")); - } - for (String candidate : new String[]{ - "/opt/teamspeak3-client", "/opt/teamspeak3", "/usr/share/teamspeak3", - "/usr/share/teamspeak3-client"}) { - roots.add(new File(candidate, "sound")); - } - String programFiles = System.getenv("ProgramFiles"); - if (programFiles != null) { - roots.add(new File(programFiles, "TeamSpeak 3 Client" + File.separator + "sound")); - } - // Running from a checkout that has the official client unpacked next to it. - roots.add(new File("TeamSpeak3-Client-linux_amd64/sound")); - roots.add(new File("../TeamSpeak3-Client-linux_amd64/sound")); - - List existing = new ArrayList<>(); - for (File root : roots) { - if (root.isDirectory()) existing.add(root); - } - return existing; - } } diff --git a/ts3-client/core/src/test/java/com/ts3client/gfx/IconPackTest.java b/ts3-client/core/src/test/java/com/ts3client/gfx/IconPackTest.java new file mode 100644 index 0000000..7772fa0 --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/gfx/IconPackTest.java @@ -0,0 +1,127 @@ +package com.ts3client.gfx; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class IconPackTest { + + private static final String INI = """ + [info] + name = Test Icons + + [options] + FALLBACK = false + + [gfxfiles] + # a comment + CONNECT = connect.svg + DISCONNECT = Disconnect.SVG + CHANNEL_GREEN= channel_green.png + MISSING = not_shipped.svg + EMPTY = + """; + + private static File zip(Path dir, String name, Map entries) throws Exception { + File file = dir.resolve(name).toFile(); + try (OutputStream out = Files.newOutputStream(file.toPath()); + ZipOutputStream zip = new ZipOutputStream(out)) { + for (Map.Entry e : entries.entrySet()) { + zip.putNextEntry(new ZipEntry(e.getKey())); + zip.write(e.getValue().getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + } + return file; + } + + @Test + void readsTheMappingOfAModernPack(@TempDir Path dir) throws Exception { + IconPack pack = IconPack.load(zip(dir, "test_pack.zip", Map.of( + "settings.ini", INI, + "connect.svg", "", + "disconnect.svg", "disconnect", + "channel_green.png", "png bytes", + "emoticons/smile.svg", ""))); + assertNotNull(pack); + + assertEquals("test_pack.zip", pack.id()); + assertEquals("Test Icons", pack.name()); + assertFalse(pack.fallsBack()); + + assertArrayEquals("".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 16)); + assertTrue(pack.isVector("CONNECT", 16)); + assertFalse(pack.isVector("CHANNEL_GREEN", 16)); + // Keys are case-insensitive, as are the file names pack authors write. + assertArrayEquals("disconnect".getBytes(StandardCharsets.UTF_8), + pack.icon("disconnect", 16)); + + assertNull(pack.icon("MISSING", 16), "mapped to a file the pack does not ship"); + assertNull(pack.icon("EMPTY", 16)); + assertNull(pack.icon("NOT_IN_THE_PACK", 16)); + } + + @Test + void fallsBackByDefault(@TempDir Path dir) throws Exception { + IconPack pack = IconPack.load(zip(dir, "p.zip", Map.of( + "settings.ini", "[gfxfiles]\nCONNECT = connect.svg\n", + "connect.svg", ""))); + assertNotNull(pack); + assertTrue(pack.fallsBack()); + } + + @Test + void resolvesLegacyPacksByFileName(@TempDir Path dir) throws Exception { + IconPack pack = IconPack.load(zip(dir, "default.zip", Map.of( + "16x16_connect.png", "small", + "32x32_connect.png", "large", + "24x24_upload_avatar.png", "avatar", + "group_100.png", "group", + "emoticons/smile.png", "ignored"))); + assertNotNull(pack); + assertEquals("Default", pack.name()); + + // The smallest image that still covers the requested size wins. + assertArrayEquals("small".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 16)); + assertArrayEquals("large".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 32)); + assertArrayEquals("large".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 64)); + + assertArrayEquals("avatar".getBytes(StandardCharsets.UTF_8), pack.icon("UPLOAD_AVATAR", 16)); + assertArrayEquals("group".getBytes(StandardCharsets.UTF_8), pack.icon("GROUP_100", 16)); + assertFalse(pack.has("SMILE"), "emoticons are not icons of the user interface"); + } + + @Test + void readsUnpackedDirectories(@TempDir Path dir) throws Exception { + Path unpacked = Files.createDirectory(dir.resolve("my_icons")); + Files.writeString(unpacked.resolve("settings.ini"), "[gfxfiles]\nCONNECT = connect.svg\n"); + Files.writeString(unpacked.resolve("connect.svg"), ""); + + IconPack pack = IconPack.load(unpacked.toFile()); + assertNotNull(pack); + assertEquals("My Icons", pack.name()); + assertArrayEquals("".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 16)); + } + + @Test + void ignoresWhatIsNotAPack(@TempDir Path dir) throws Exception { + assertNull(IconPack.load(dir.resolve("nope.zip").toFile())); + assertNull(IconPack.load(Files.createDirectory(dir.resolve("empty")).toFile())); + assertNull(IconPack.load(null)); + } +} diff --git a/ts3-client/pom.xml b/ts3-client/pom.xml index 76f8a85..db3079e 100644 --- a/ts3-client/pom.xml +++ b/ts3-client/pom.xml @@ -22,6 +22,7 @@ 26 UTF-8 1.0.3 + 2.1.0 3.5.6 @@ -44,6 +45,11 @@ ts3j ${ts3j.version} + + com.github.weisj + jsvg + ${jsvg.version} + com.ts3client ts3-client-core diff --git a/ts3-client/swing/pom.xml b/ts3-client/swing/pom.xml index 3ae2101..70a17b0 100644 --- a/ts3-client/swing/pom.xml +++ b/ts3-client/swing/pom.xml @@ -31,6 +31,10 @@ com.github.manevolent ts3j + + com.github.weisj + jsvg + @@ -66,6 +70,14 @@ ** + + + com.github.weisj:jsvg + + ** + + *:* diff --git a/ts3-client/swing/src/main/java/com/ts3client/Main.java b/ts3-client/swing/src/main/java/com/ts3client/Main.java index 45a1af0..0be765e 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/Main.java +++ b/ts3-client/swing/src/main/java/com/ts3client/Main.java @@ -1,6 +1,7 @@ package com.ts3client; import com.ts3client.config.Settings; +import com.ts3client.ui.IconTheme; import com.ts3client.ui.MainFrame; import javax.swing.SwingUtilities; @@ -27,6 +28,7 @@ public final class Main { } catch (Exception ignored) { // fall back to cross-platform L&F } + IconTheme.get().reload(settings); new MainFrame(settings).setVisible(true); }); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelMenu.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelMenu.java index b3dcabf..7025a07 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelMenu.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChannelMenu.java @@ -16,11 +16,11 @@ final class ChannelMenu { static JPopupMenu build(ChannelNode channel, ServerTreePanel.Actions actions) { JPopupMenu menu = new JPopupMenu(); - JMenuItem join = new JMenuItem("Join channel"); + JMenuItem join = new JMenuItem("Join channel", Icons.of("CHANNEL_SWITCH")); join.addActionListener(a -> actions.joinChannel(channel.id)); menu.add(join); menu.addSeparator(); - JMenuItem files = new JMenuItem("Browse files"); + JMenuItem files = new JMenuItem("Browse files", Icons.of("FILETRANSFER")); files.addActionListener(a -> actions.browseFiles(channel)); menu.add(files); return menu; diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java index 813f9e3..a10b80b 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientMenu.java @@ -17,24 +17,25 @@ final class ClientMenu { static JPopupMenu build(ClientEntry client, boolean self, ServerTreePanel.Actions actions) { JPopupMenu menu = new JPopupMenu(); if (!self) { - JMenuItem pm = new JMenuItem("Open text chat"); + JMenuItem pm = new JMenuItem("Open text chat", Icons.of("PLAYER_CHAT")); pm.addActionListener(a -> actions.openPrivateChat(client)); menu.add(pm); - JMenuItem poke = new JMenuItem("Poke"); + JMenuItem poke = new JMenuItem("Poke", Icons.of("POKE")); poke.addActionListener(a -> actions.pokeClient(client)); menu.add(poke); menu.addSeparator(); boolean muted = actions.isClientLocallyMuted(client.id); - JMenuItem mute = new JMenuItem(muted ? "Unmute client" : "Mute client"); + JMenuItem mute = new JMenuItem(muted ? "Unmute client" : "Mute client", + Icons.of(muted ? "PLAYER_ON" : "INPUT_MUTED")); mute.addActionListener(a -> actions.toggleClientMute(client)); menu.add(mute); } else { - JMenuItem me = new JMenuItem("This is you"); + JMenuItem me = new JMenuItem("This is you", Icons.of("PLAYER_OFF")); me.setEnabled(false); menu.add(me); } menu.addSeparator(); - JMenuItem info = new JMenuItem("Connection Info"); + JMenuItem info = new JMenuItem("Connection Info", Icons.of("INFO")); info.addActionListener(a -> actions.showConnectionInfo(client)); menu.add(info); return menu; diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/FileBrowserDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/FileBrowserDialog.java index c6138d4..5f3f706 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/FileBrowserDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/FileBrowserDialog.java @@ -52,9 +52,9 @@ public final class FileBrowserDialog extends JDialog { private final FileTableModel tableModel = new FileTableModel(); private final JTable table = new JTable(tableModel); private final JLabel pathLabel = new JLabel("/"); - private final JButton upButton = new JButton("Up"); - private final JButton downloadButton = new JButton("Download"); - private final JButton deleteButton = new JButton("Delete"); + private final JButton upButton = new JButton("Up", Icons.of("FILE_UP")); + private final JButton downloadButton = new JButton("Download", Icons.of("DOWNLOAD")); + private final JButton deleteButton = new JButton("Delete", Icons.of("DELETE")); private final JPanel transfersPanel = new JPanel(); private volatile String currentPath = "/"; @@ -88,11 +88,11 @@ public final class FileBrowserDialog extends JDialog { bar.setBackground(Theme.WINDOW_BG); upButton.addActionListener(e -> navigateUp()); - JButton refresh = new JButton("Refresh"); + JButton refresh = new JButton("Refresh", Icons.of("FILE_REFRESH")); refresh.addActionListener(e -> refresh()); - JButton mkdir = new JButton("New folder"); + JButton mkdir = new JButton("New folder", Icons.of("ADD_FOLDER")); mkdir.addActionListener(e -> createDirectory()); - JButton upload = new JButton("Upload…"); + JButton upload = new JButton("Upload…", Icons.of("UPLOAD")); upload.addActionListener(e -> chooseUpload()); downloadButton.addActionListener(e -> downloadSelected()); deleteButton.addActionListener(e -> deleteSelected()); @@ -122,6 +122,7 @@ public final class FileBrowserDialog extends JDialog { table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); table.setFillsViewportHeight(true); table.getColumnModel().getColumn(0).setPreferredWidth(300); + table.getColumnModel().getColumn(0).setCellRenderer(new NameRenderer()); table.getColumnModel().getColumn(1).setPreferredWidth(90); table.getColumnModel().getColumn(2).setPreferredWidth(70); table.getColumnModel().getColumn(3).setPreferredWidth(150); @@ -437,6 +438,18 @@ public final class FileBrowserDialog extends JDialog { } } + /** Marks folders and files with the icon pack's own glyphs. */ + private final class NameRenderer extends javax.swing.table.DefaultTableCellRenderer { + @Override + public java.awt.Component getTableCellRendererComponent(JTable t, Object value, boolean selected, + boolean focus, int row, int column) { + super.getTableCellRendererComponent(t, value, selected, focus, row, column); + boolean directory = row >= 0 && row < tableModel.getRowCount() && tableModel.get(row).isDirectory(); + setIcon(Icons.of(directory ? "FOLDER" : "DEFAULT")); + return this; + } + } + // ---- formatting ---- private static String formatBytes(long bytes) { diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/GroupIcons.java b/ts3-client/swing/src/main/java/com/ts3client/ui/GroupIcons.java index e241b3f..f25c331 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/GroupIcons.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/GroupIcons.java @@ -37,6 +37,15 @@ 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, SIZE); + if (themed != null) return themed; + } + ImageIcon cached = decoded.get(iconId); if (cached != null) return cached; diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/IconPackPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/IconPackPanel.java new file mode 100644 index 0000000..214dcc6 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/IconPackPanel.java @@ -0,0 +1,198 @@ +package com.ts3client.ui; + +import com.ts3client.config.Settings; +import com.ts3client.gfx.IconPack; + +import javax.swing.BorderFactory; +import javax.swing.DefaultListCellRenderer; +import javax.swing.DefaultListModel; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.SwingConstants; +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.io.File; + +/** + * Options page for icon packs: which pack the user interface is drawn with, where + * to look for more of them, and a viewer showing everything the pack contains. + * + *

Picking a pack applies it right away so the change can be seen in the window + * behind the dialog; cancelling puts the previous one back. + */ +final class IconPackPanel extends JPanel { + + /** Size of the tiles in the icon viewer. */ + private static final int PREVIEW_SIZE = 32; + 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 final Settings settings; + private final String originalPackId; + private final String originalPackDir; + + private final JComboBox packCombo = new JComboBox<>(); + private final JLabel packInfo = new JLabel(); + private final JTextField packDirField; + private final DefaultListModel previewModel = new DefaultListModel<>(); + private final JList preview = new JList<>(previewModel); + + IconPackPanel(Settings settings) { + super(new BorderLayout(0, 8)); + this.settings = settings; + this.originalPackId = settings.iconPack; + this.originalPackDir = settings.iconPackDir; + this.packDirField = new JTextField(settings.iconPackDir, 18); + + setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + add(buildHeader(), BorderLayout.NORTH); + add(buildPreview(), BorderLayout.CENTER); + + reloadPacks(); + } + + /** Copies the chosen pack into the settings; the caller saves them. */ + void apply() { + settings.iconPackDir = packDirField.getText().trim(); + Object selected = packCombo.getSelectedItem(); + settings.iconPack = selected instanceof IconPack pack ? pack.id() : ""; + } + + /** Puts back the pack the dialog started with, for a cancelled edit. */ + void revert() { + settings.iconPack = originalPackId; + settings.iconPackDir = originalPackDir; + IconTheme.get().reload(settings); + } + + // ---- layout ---- + + private JComponent buildHeader() { + JPanel p = new JPanel(new GridBagLayout()); + GridBagConstraints c = new GridBagConstraints(); + c.insets = new Insets(2, 2, 2, 2); + c.anchor = GridBagConstraints.WEST; + c.fill = GridBagConstraints.HORIZONTAL; + + packCombo.setToolTipText("Icon packs installed here or in a TeamSpeak 3 client"); + packCombo.addActionListener(e -> onPackSelected()); + packCombo.setRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, + boolean selected, boolean focus) { + super.getListCellRendererComponent(list, value, index, selected, focus); + setText(value instanceof IconPack pack ? pack.name() : String.valueOf(value)); + return this; + } + }); + + int row = 0; + addRow(p, c, row++, new JLabel("Icon pack:"), packCombo); + c.gridx = 1; + c.gridy = row++; + packInfo.setEnabled(false); + p.add(packInfo, c); + + JPanel dirRow = new JPanel(new BorderLayout(6, 0)); + JButton browse = new JButton("Browse…"); + browse.addActionListener(e -> browseForPackFolder()); + dirRow.add(packDirField, BorderLayout.CENTER); + dirRow.add(browse, BorderLayout.EAST); + packDirField.setToolTipText("Extra folder to look for icon packs in"); + addRow(p, c, row, new JLabel("Extra pack folder:"), dirRow); + return p; + } + + private JComponent buildPreview() { + preview.setLayoutOrientation(JList.HORIZONTAL_WRAP); + preview.setVisibleRowCount(-1); + preview.setFixedCellWidth(TILE); + preview.setFixedCellHeight(TILE); + preview.setCellRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, + boolean selected, boolean focus) { + super.getListCellRendererComponent(list, value, index, selected, focus); + String key = String.valueOf(value); + ImageIcon icon = Icons.of(key, PREVIEW_SIZE); + setText(""); + setIcon(icon); + setToolTipText(key); + setHorizontalAlignment(SwingConstants.CENTER); + return this; + } + }); + + JScrollPane scroll = new JScrollPane(preview); + scroll.setPreferredSize(new Dimension(360, 200)); + scroll.setBorder(BorderFactory.createTitledBorder("Icons in this pack")); + return scroll; + } + + // ---- behaviour ---- + + private void reloadPacks() { + IconTheme theme = IconTheme.get(); + theme.reload(settings); + // Filling the combo box changes its selection, and with it the active pack, + // so remember the one the settings asked for before touching it. + IconPack selected = theme.activePack(); + + packCombo.removeAllItems(); + packCombo.addItem(BUILT_IN); + for (IconPack pack : theme.packs()) packCombo.addItem(pack); + packCombo.setSelectedItem(selected == null ? BUILT_IN : selected); + onPackSelected(); + } + + private void onPackSelected() { + Object selected = packCombo.getSelectedItem(); + IconPack pack = selected instanceof IconPack p ? p : null; + IconTheme.get().setPack(pack); + + previewModel.clear(); + if (pack == null) { + packInfo.setText("Drawn by the client itself"); + } else { + for (String key : pack.keys()) previewModel.addElement(key); + packInfo.setText(pack.keys().size() + " icons" + + (pack.fallsBack() ? ", missing ones from the default pack" : "")); + } + } + + private void browseForPackFolder() { + JFileChooser chooser = new JFileChooser(); + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + chooser.setDialogTitle("Folder containing icon packs"); + String current = packDirField.getText().trim(); + if (!current.isEmpty()) chooser.setCurrentDirectory(new File(current)); + if (chooser.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) return; + packDirField.setText(chooser.getSelectedFile().getAbsolutePath()); + settings.iconPackDir = packDirField.getText(); + reloadPacks(); + } + + private static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, Component field) { + c.gridx = 0; + c.gridy = row; + c.weightx = 0; + p.add(label, c); + c.gridx = 1; + c.weightx = 1; + p.add(field, c); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/IconTheme.java b/ts3-client/swing/src/main/java/com/ts3client/ui/IconTheme.java new file mode 100644 index 0000000..bbeaa30 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/IconTheme.java @@ -0,0 +1,167 @@ +package com.ts3client.ui; + +import com.github.weisj.jsvg.SVGDocument; +import com.github.weisj.jsvg.parser.LoaderContext; +import com.github.weisj.jsvg.parser.SVGLoader; +import com.github.weisj.jsvg.view.ViewBox; +import com.ts3client.config.Settings; +import com.ts3client.gfx.IconPack; +import com.ts3client.gfx.IconPacks; + +import javax.imageio.ImageIO; +import javax.swing.ImageIcon; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * The icons the user interface draws itself with, taken from the active + * {@link IconPack}. + * + *

Packs are TeamSpeak's, so an installed client's {@code gfx/} folder (and the + * packs from its add-on site) can be used as they are. A pack's own + * {@code FALLBACK} option decides whether icons it does not define are looked up + * in {@code default.zip}; whatever is still missing is drawn by {@link Icons} + * instead, so the client always has a complete set. + * + *

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. + */ +public final class IconTheme { + + /** The size of a tree row / menu item icon, as in the official client. */ + public static final int SIZE = 16; + /** The size of the toolbar's buttons. */ + public static final int TOOLBAR_SIZE = 24; + + private static final IconTheme INSTANCE = new IconTheme(); + + private final Map cache = new ConcurrentHashMap<>(); + private final List listeners = new CopyOnWriteArrayList<>(); + + private volatile List packs = List.of(); + private volatile IconPack active; + private volatile IconPack fallback; + + private IconTheme() { + } + + public static IconTheme get() { + return INSTANCE; + } + + /** The packs found on this machine, in display order. */ + public List packs() { + return packs; + } + + public IconPack activePack() { + return active; + } + + /** Re-scans the pack folders and activates the one the settings name. */ + public void reload(Settings settings) { + packs = IconPacks.findAll(new File(Settings.configDir(), "gfx"), settings.iconPackDir); + fallback = IconPacks.fallback(packs); + setPack(IconPacks.select(packs, settings.iconPack)); + } + + /** 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; + cache.clear(); + for (Runnable listener : listeners) listener.run(); + } + + /** Registers a repaint to run whenever the icons change. */ + public void addListener(Runnable listener) { + listeners.add(listener); + } + + /** @return the icon for a TeamSpeak icon key at 16×16, or {@code null} if no pack has it */ + public static ImageIcon icon(String key) { + return icon(key, SIZE); + } + + /** @return the icon for a TeamSpeak icon key, or {@code null} if no pack has it */ + public static ImageIcon icon(String key, int size) { + return INSTANCE.lookup(key, size); + } + + private ImageIcon lookup(String key, int size) { + if (key == null) return null; + String cacheKey = key + '@' + size; + 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); + } + 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); + return image == null ? null : new ImageIcon(image); + } + + private static BufferedImage rasterize(byte[] data, int size) { + try { + SVGDocument document = new SVGLoader().load( + new ByteArrayInputStream(data), null, LoaderContext.createDefault()); + if (document == null) return null; + BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = image.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); + document.render(null, g, new ViewBox(size, size)); + } finally { + g.dispose(); + } + return image; + } catch (Exception e) { + return null; + } + } + + private static BufferedImage decode(byte[] data, int size) { + try { + BufferedImage source = ImageIO.read(new ByteArrayInputStream(data)); + if (source == null) return null; + if (source.getWidth() == size && source.getHeight() == size) return source; + + BufferedImage scaled = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = scaled.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + // Keep the aspect ratio of packs whose art is not square. + double factor = Math.min((double) size / source.getWidth(), (double) size / source.getHeight()); + int w = Math.max(1, (int) Math.round(source.getWidth() * factor)); + int h = Math.max(1, (int) Math.round(source.getHeight() * factor)); + g.drawImage(source.getScaledInstance(w, h, Image.SCALE_SMOOTH), + (size - w) / 2, (size - h) / 2, null); + } finally { + g.dispose(); + } + return scaled; + } catch (Exception e) { + return null; + } + } +} 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 9a7bf78..9ba1fff 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 @@ -8,13 +8,17 @@ import java.awt.RenderingHints; import java.awt.image.BufferedImage; /** - * Programmatically drawn vector icons (no external image assets), so the client - * is fully self-contained. All icons are rendered at 16×16 with a small - * cache. + * The user interface's icons, taken from the active {@link IconTheme} pack and + * named by TeamSpeak's icon keys. + * + *

The icons the client cannot do without are also drawn programmatically here, + * so a machine with no icon pack installed still gets a complete (if plain) set. + * Everything else — menu and dialog decoration — simply stays blank without a pack, + * which is why {@link #of(String)} may return {@code null}. */ public final class Icons { - private static final int SZ = 16; + private static final int SZ = IconTheme.SIZE; private Icons() { } @@ -23,6 +27,25 @@ public final class Icons { void paint(Graphics2D g); } + /** @return the pack's icon for a TeamSpeak icon key, or {@code null} when it has none */ + public static ImageIcon of(String key) { + return IconTheme.icon(key); + } + + /** @return the pack's icon at a given size, or {@code null} when it has none */ + public static ImageIcon of(String key, int size) { + return IconTheme.icon(key, size); + } + + private static ImageIcon themed(String key, Painter fallback) { + return themed(key, SZ, fallback); + } + + private static ImageIcon themed(String key, int size, Painter fallback) { + ImageIcon icon = IconTheme.icon(key, size); + return icon != null ? icon : make(fallback); + } + private static ImageIcon make(Painter p) { BufferedImage img = new BufferedImage(SZ, SZ, BufferedImage.TYPE_INT_ARGB); Graphics2D g = img.createGraphics(); @@ -36,174 +59,225 @@ public final class Icons { // ---- tree icons ---- public static ImageIcon server() { - return make(g -> { - g.setColor(new Color(0x2C6EA5)); - g.fillRoundRect(2, 3, 12, 4, 2, 2); - g.fillRoundRect(2, 9, 12, 4, 2, 2); - g.setColor(new Color(0x9FD0F0)); - g.fillOval(4, 4, 2, 2); - g.fillOval(4, 10, 2, 2); - }); + return themed("SERVER_GREEN", Icons::paintServer); } public static ImageIcon channel() { - return channelPainted(new Color(0x3E7CB1), false); + return themed("CHANNEL_GREEN", g -> paintChannel(g, new Color(0x3E7CB1), false)); } + /** A channel nobody can join without its password. */ public static ImageIcon channelLocked() { - return channelPainted(new Color(0x8A6D3B), true); + return themed("CHANNEL_PRIVATE", g -> paintChannel(g, new Color(0x8A6D3B), true)); } - private static ImageIcon channelPainted(Color c, boolean lock) { - return make(g -> { - g.setColor(c); - g.setStroke(new BasicStroke(1.6f)); - // simple speaker-cone glyph - g.fillRect(2, 6, 3, 4); - int[] xs = {5, 9, 9, 5}; - int[] ys = {6, 3, 13, 10}; - g.fillPolygon(xs, ys, 4); - g.setStroke(new BasicStroke(1.4f)); - g.drawArc(9, 5, 4, 6, -60, 120); - if (lock) { - g.setColor(new Color(0xB8860B)); - g.fillRoundRect(10, 9, 5, 5, 1, 1); - g.setColor(Color.WHITE); - g.fillRect(12, 10, 1, 2); - } - }); + /** A channel that has reached its client limit. */ + public static ImageIcon channelFull() { + return themed("CHANNEL_RED", g -> paintChannel(g, new Color(0xA53F3F), false)); } // ---- client status icons ---- public static ImageIcon clientIdle() { - return person(Theme.IDLE_CLIENT); + return themed("PLAYER_OFF", g -> paintPerson(g, Theme.IDLE_CLIENT)); } public static ImageIcon clientTalking() { - return person(Theme.TALKING); + return themed("PLAYER_ON", g -> paintPerson(g, Theme.TALKING)); } public static ImageIcon clientAway() { - return person(Theme.AWAY); + return themed("AWAY", g -> paintPerson(g, Theme.AWAY)); } - private static ImageIcon person(Color c) { - return make(g -> { - g.setColor(c); - g.fillOval(5, 2, 6, 6); // head - g.fillRoundRect(3, 9, 10, 6, 4, 4); // shoulders - }); + /** A channel commander that is not talking. */ + public static ImageIcon clientCommander() { + return themed("PLAYER_COMMANDER_OFF", g -> paintPerson(g, Theme.IDLE_CLIENT)); + } + + /** A channel commander that is talking. */ + public static ImageIcon clientCommanderTalking() { + return themed("PLAYER_COMMANDER_ON", g -> paintPerson(g, Theme.TALKING)); + } + + public static ImageIcon clientQuery() { + return themed("SERVER_QUERY", g -> paintPerson(g, Theme.IDLE_CLIENT)); } public static ImageIcon micMuted() { - return make(g -> { - g.setColor(Theme.MUTED); - g.fillRoundRect(6, 2, 4, 7, 2, 2); - g.setStroke(new BasicStroke(1.4f)); - g.drawArc(4, 6, 8, 6, 200, 140); - g.drawLine(8, 12, 8, 14); - g.setStroke(new BasicStroke(2f)); - g.drawLine(2, 2, 14, 14); // slash - }); + return themed("INPUT_MUTED", Icons::paintMicMuted); } public static ImageIcon speakerMuted() { - return make(g -> { - g.setColor(Theme.MUTED); - g.fillRect(2, 6, 3, 4); - int[] xs = {5, 9, 9, 5}; - int[] ys = {6, 3, 13, 10}; - g.fillPolygon(xs, ys, 4); - g.setStroke(new BasicStroke(2f)); - g.drawLine(2, 2, 14, 14); - }); + return themed("OUTPUT_MUTED", Icons::paintSpeakerMuted); } // ---- toolbar / action icons ---- public static ImageIcon connect() { - return make(g -> { - g.setColor(new Color(0x2E8B57)); - g.setStroke(new BasicStroke(2f)); - g.drawLine(3, 8, 8, 8); - g.fillRoundRect(8, 5, 5, 6, 2, 2); - g.drawLine(10, 3, 10, 5); - g.drawLine(12, 3, 12, 5); - }); + return themed("CONNECT", IconTheme.TOOLBAR_SIZE, Icons::paintConnect); } public static ImageIcon disconnect() { - return make(g -> { - g.setColor(Theme.MUTED); - g.setStroke(new BasicStroke(2f)); - g.drawLine(3, 8, 8, 8); - g.fillRoundRect(8, 5, 5, 6, 2, 2); - g.drawLine(2, 3, 6, 13); - }); + return themed("DISCONNECT", IconTheme.TOOLBAR_SIZE, Icons::paintDisconnect); } public static ImageIcon mic() { - return make(g -> { - g.setColor(new Color(0x37474F)); - g.fillRoundRect(6, 2, 4, 7, 2, 2); - g.setStroke(new BasicStroke(1.4f)); - g.drawArc(4, 6, 8, 6, 200, 140); - g.drawLine(8, 12, 8, 14); - g.drawLine(6, 14, 10, 14); - }); + return themed("CAPTURE", IconTheme.TOOLBAR_SIZE, Icons::paintMic); } public static ImageIcon speaker() { - return make(g -> { - g.setColor(new Color(0x37474F)); - g.fillRect(2, 6, 3, 4); - int[] xs = {5, 9, 9, 5}; - int[] ys = {6, 3, 13, 10}; - g.fillPolygon(xs, ys, 4); - g.setStroke(new BasicStroke(1.4f)); - g.drawArc(9, 5, 4, 6, -60, 120); - }); + return themed("PLAYBACK", IconTheme.TOOLBAR_SIZE, Icons::paintSpeaker); + } + + public static ImageIcon micMutedLarge() { + return themed("INPUT_MUTED", IconTheme.TOOLBAR_SIZE, Icons::paintMicMuted); + } + + public static ImageIcon speakerMutedLarge() { + return themed("OUTPUT_MUTED", IconTheme.TOOLBAR_SIZE, Icons::paintSpeakerMuted); } /** Marks the server that currently owns the microphone. */ public static ImageIcon micActive() { - return make(g -> { - g.setColor(Theme.TALKING); - g.fillOval(1, 1, 14, 14); - g.setColor(Color.WHITE); - g.fillRoundRect(6, 3, 4, 6, 2, 2); - g.setStroke(new BasicStroke(1.4f)); - g.drawArc(4, 6, 8, 6, 200, 140); - g.drawLine(8, 11, 8, 13); - }); + return themed("ACTIVATE_MICROPHONE", IconTheme.TOOLBAR_SIZE, Icons::paintMicActive); + } + + /** The same marker at tab-label size. */ + public static ImageIcon micActiveSmall() { + return themed("ACTIVATE_MICROPHONE", Icons::paintMicActive); } public static ImageIcon settings() { - return make(g -> { - g.setColor(new Color(0x37474F)); - g.setStroke(new BasicStroke(2f)); - g.drawOval(5, 5, 6, 6); - for (int a = 0; a < 360; a += 45) { - double r = Math.toRadians(a); - int x1 = (int) (8 + Math.cos(r) * 5); - int y1 = (int) (8 + Math.sin(r) * 5); - int x2 = (int) (8 + Math.cos(r) * 7); - int y2 = (int) (8 + Math.sin(r) * 7); - g.drawLine(x1, y1, x2, y2); - } - }); + return themed("SETTINGS", IconTheme.TOOLBAR_SIZE, Icons::paintSettings); } public static ImageIcon app() { - return make(g -> { - g.setColor(Theme.ACCENT); - g.fillRoundRect(1, 1, 14, 14, 4, 4); + return make(Icons::paintApp); + } + + // ---- built-in painters ---- + + private static void paintServer(Graphics2D g) { + g.setColor(new Color(0x2C6EA5)); + g.fillRoundRect(2, 3, 12, 4, 2, 2); + g.fillRoundRect(2, 9, 12, 4, 2, 2); + g.setColor(new Color(0x9FD0F0)); + g.fillOval(4, 4, 2, 2); + g.fillOval(4, 10, 2, 2); + } + + private static void paintChannel(Graphics2D g, Color c, boolean lock) { + g.setColor(c); + g.setStroke(new BasicStroke(1.6f)); + // simple speaker-cone glyph + g.fillRect(2, 6, 3, 4); + int[] xs = {5, 9, 9, 5}; + int[] ys = {6, 3, 13, 10}; + g.fillPolygon(xs, ys, 4); + g.setStroke(new BasicStroke(1.4f)); + g.drawArc(9, 5, 4, 6, -60, 120); + if (lock) { + g.setColor(new Color(0xB8860B)); + g.fillRoundRect(10, 9, 5, 5, 1, 1); g.setColor(Color.WHITE); - g.setStroke(new BasicStroke(1.6f)); - g.drawArc(4, 5, 8, 8, 30, 120); - g.drawArc(2, 3, 12, 12, 30, 120); - g.fillOval(7, 9, 2, 2); - }); + g.fillRect(12, 10, 1, 2); + } + } + + private static void paintPerson(Graphics2D g, Color c) { + g.setColor(c); + g.fillOval(5, 2, 6, 6); // head + g.fillRoundRect(3, 9, 10, 6, 4, 4); // shoulders + } + + private static void paintMicMuted(Graphics2D g) { + g.setColor(Theme.MUTED); + g.fillRoundRect(6, 2, 4, 7, 2, 2); + g.setStroke(new BasicStroke(1.4f)); + g.drawArc(4, 6, 8, 6, 200, 140); + g.drawLine(8, 12, 8, 14); + g.setStroke(new BasicStroke(2f)); + g.drawLine(2, 2, 14, 14); // slash + } + + private static void paintSpeakerMuted(Graphics2D g) { + g.setColor(Theme.MUTED); + g.fillRect(2, 6, 3, 4); + int[] xs = {5, 9, 9, 5}; + int[] ys = {6, 3, 13, 10}; + g.fillPolygon(xs, ys, 4); + g.setStroke(new BasicStroke(2f)); + g.drawLine(2, 2, 14, 14); + } + + private static void paintConnect(Graphics2D g) { + g.setColor(new Color(0x2E8B57)); + g.setStroke(new BasicStroke(2f)); + g.drawLine(3, 8, 8, 8); + g.fillRoundRect(8, 5, 5, 6, 2, 2); + g.drawLine(10, 3, 10, 5); + g.drawLine(12, 3, 12, 5); + } + + private static void paintDisconnect(Graphics2D g) { + g.setColor(Theme.MUTED); + g.setStroke(new BasicStroke(2f)); + g.drawLine(3, 8, 8, 8); + g.fillRoundRect(8, 5, 5, 6, 2, 2); + g.drawLine(2, 3, 6, 13); + } + + private static void paintMic(Graphics2D g) { + g.setColor(new Color(0x37474F)); + g.fillRoundRect(6, 2, 4, 7, 2, 2); + g.setStroke(new BasicStroke(1.4f)); + g.drawArc(4, 6, 8, 6, 200, 140); + g.drawLine(8, 12, 8, 14); + g.drawLine(6, 14, 10, 14); + } + + private static void paintSpeaker(Graphics2D g) { + g.setColor(new Color(0x37474F)); + g.fillRect(2, 6, 3, 4); + int[] xs = {5, 9, 9, 5}; + int[] ys = {6, 3, 13, 10}; + g.fillPolygon(xs, ys, 4); + g.setStroke(new BasicStroke(1.4f)); + g.drawArc(9, 5, 4, 6, -60, 120); + } + + private static void paintMicActive(Graphics2D g) { + g.setColor(Theme.TALKING); + g.fillOval(1, 1, 14, 14); + g.setColor(Color.WHITE); + g.fillRoundRect(6, 3, 4, 6, 2, 2); + g.setStroke(new BasicStroke(1.4f)); + g.drawArc(4, 6, 8, 6, 200, 140); + g.drawLine(8, 11, 8, 13); + } + + private static void paintSettings(Graphics2D g) { + g.setColor(new Color(0x37474F)); + g.setStroke(new BasicStroke(2f)); + g.drawOval(5, 5, 6, 6); + for (int a = 0; a < 360; a += 45) { + double r = Math.toRadians(a); + int x1 = (int) (8 + Math.cos(r) * 5); + int y1 = (int) (8 + Math.sin(r) * 5); + int x2 = (int) (8 + Math.cos(r) * 7); + int y2 = (int) (8 + Math.sin(r) * 7); + g.drawLine(x1, y1, x2, y2); + } + } + + private static void paintApp(Graphics2D g) { + g.setColor(Theme.ACCENT); + g.fillRoundRect(1, 1, 14, 14, 4, 4); + g.setColor(Color.WHITE); + g.setStroke(new BasicStroke(1.6f)); + g.drawArc(4, 5, 8, 8, 30, 120); + g.drawArc(2, 3, 12, 12, 30, 120); + g.fillOval(7, 9, 2, 2); } } 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 a1e7b7f..8a3e70d 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 @@ -72,6 +72,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { private final JLabel statusLabel = new JLabel("Not connected"); private final JLabel codecLabel = new JLabel(); + private JToolBar toolbar; private JButton connectButton; private JButton disconnectButton; private JToggleButton activeButton; @@ -108,7 +109,10 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { setJMenuBar(buildMenuBar()); - add(buildToolbar(), BorderLayout.NORTH); + toolbar = buildToolbar(); + add(toolbar, BorderLayout.NORTH); + // Switching the icon pack in the options dialog re-decorates the whole window. + IconTheme.get().addListener(() -> SwingUtilities.invokeLater(this::rebuildIcons)); add(tabPane, BorderLayout.CENTER); add(buildStatusBar(), BorderLayout.SOUTH); @@ -140,15 +144,15 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { JMenuBar bar = new JMenuBar(); JMenu connections = new JMenu("Connections"); - JMenuItem connect = new JMenuItem("Connect…"); + JMenuItem connect = new JMenuItem("Connect…", Icons.of("CONNECT")); connect.setAccelerator(KeyStroke.getKeyStroke("control S")); connect.addActionListener(e -> showConnectDialog()); - JMenuItem disconnect = new JMenuItem("Disconnect"); + JMenuItem disconnect = new JMenuItem("Disconnect", Icons.of("DISCONNECT")); disconnect.addActionListener(e -> doDisconnect()); - JMenuItem closeTab = new JMenuItem("Close tab"); + JMenuItem closeTab = new JMenuItem("Close tab", Icons.of("CLOSE_BUTTON")); closeTab.setAccelerator(KeyStroke.getKeyStroke("control W")); closeTab.addActionListener(e -> closeTab(selected)); - JMenuItem quit = new JMenuItem("Quit"); + JMenuItem quit = new JMenuItem("Quit", Icons.of("QUIT")); quit.addActionListener(e -> { shutdown(); System.exit(0); @@ -163,17 +167,17 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { rebuildBookmarksMenu(); JMenu self = new JMenu("Self"); - JMenuItem mute = new JMenuItem("Toggle microphone"); + JMenuItem mute = new JMenuItem("Toggle microphone", Icons.of("CAPTURE")); mute.addActionListener(e -> micButton.doClick()); - JMenuItem deaf = new JMenuItem("Toggle speakers"); + JMenuItem deaf = new JMenuItem("Toggle speakers", Icons.of("PLAYBACK")); deaf.addActionListener(e -> speakerButton.doClick()); - awayItem = new JCheckBoxMenuItem("Away"); + awayItem = new JCheckBoxMenuItem("Away", Icons.of("AWAY"), false); awayItem.addActionListener(e -> toggleAway()); - commanderItem = new JCheckBoxMenuItem("Channel commander"); + commanderItem = new JCheckBoxMenuItem("Channel commander", Icons.of("CHANNEL_COMMANDER"), false); commanderItem.addActionListener(e -> { if (selected != null) selected.setCommander(commanderItem.isSelected()); }); - JMenuItem nick = new JMenuItem("Change nickname…"); + JMenuItem nick = new JMenuItem("Change nickname…", Icons.of("CHANGE_NICKNAME")); nick.addActionListener(e -> changeNickname()); self.add(mute); self.add(deaf); @@ -184,16 +188,16 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { self.add(nick); JMenu tools = new JMenu("Tools"); - JMenuItem identitiesItem = new JMenuItem("Identities…"); + JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER")); identitiesItem.addActionListener(e -> showIdentities()); - JMenuItem options = new JMenuItem("Options…"); + JMenuItem options = new JMenuItem("Options…", Icons.of("SETTINGS")); options.addActionListener(e -> showSettings()); tools.add(identitiesItem); tools.addSeparator(); tools.add(options); JMenu help = new JMenu("Help"); - JMenuItem about = new JMenuItem("About"); + JMenuItem about = new JMenuItem("About", Icons.of("ABOUT")); about.addActionListener(e -> showAbout()); help.add(about); @@ -208,14 +212,14 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { private void rebuildBookmarksMenu() { bookmarksMenu.removeAll(); for (Bookmark b : bookmarks.all()) { - JMenuItem item = new JMenuItem(b.displayName()); + JMenuItem item = new JMenuItem(b.displayName(), Icons.of("SERVER_GREEN")); item.addActionListener(e -> connectToBookmark(b)); bookmarksMenu.add(item); } if (!bookmarks.all().isEmpty()) bookmarksMenu.addSeparator(); - JMenuItem addCurrent = new JMenuItem("Add current server…"); + JMenuItem addCurrent = new JMenuItem("Add current server…", Icons.of("BOOKMARK_ADD")); addCurrent.addActionListener(e -> addCurrentServerBookmark()); - JMenuItem manage = new JMenuItem("Manage bookmarks…"); + JMenuItem manage = new JMenuItem("Manage bookmarks…", Icons.of("BOOKMARK_MANAGER")); manage.addActionListener(e -> new BookmarksDialog(this, bookmarks, identities, this::connectToBookmark, this::rebuildBookmarksMenu).setVisible(true)); bookmarksMenu.add(addCurrent); @@ -275,6 +279,19 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { return tb; } + /** Rebuilds the icon-bearing chrome after the active icon pack changed. */ + private void rebuildIcons() { + setIconImage(Icons.app().getImage()); + setJMenuBar(buildMenuBar()); + remove(toolbar); + toolbar = buildToolbar(); + add(toolbar, BorderLayout.NORTH); + updateToolbar(); + refreshTabs(); + revalidate(); + repaint(); + } + private JPanel buildStatusBar() { JPanel bar = new JPanel(new BorderLayout()); bar.setBackground(Theme.STATUS_BG); @@ -574,9 +591,9 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { boolean micMuted = connected && selected.isMicMuted(); boolean deaf = connected && selected.isDeafened(); micButton.setSelected(micMuted); - micButton.setIcon(micMuted ? Icons.micMuted() : Icons.mic()); + micButton.setIcon(micMuted ? Icons.micMutedLarge() : Icons.mic()); speakerButton.setSelected(deaf); - speakerButton.setIcon(deaf ? Icons.speakerMuted() : Icons.speaker()); + speakerButton.setIcon(deaf ? Icons.speakerMutedLarge() : Icons.speaker()); activeButton.setSelected(selected != null && selected == micTab); awayItem.setSelected(connected && selected.isAway()); commanderItem.setSelected(connected && selected.isCommander()); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabPane.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabPane.java index 9fa799b..e627e9f 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabPane.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabPane.java @@ -132,7 +132,7 @@ final class ServerTabPane extends JPanel { cell.setOpaque(false); cell.setBorder(BorderFactory.createEmptyBorder(1, 0, 1, 0)); - JLabel label = new JLabel(tab.title(), hasMic ? Icons.micActive() : null, JLabel.LEADING); + JLabel label = new JLabel(tab.title(), hasMic ? Icons.micActiveSmall() : null, JLabel.LEADING); label.setFont(Theme.UI_FONT); label.setToolTipText(hasMic ? "Speaking on this server" : tab.status()); // A label with a tooltip swallows mouse events, so select the tab explicitly. diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java index 8a100ca..477a588 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java @@ -583,7 +583,7 @@ public final class ServerTreePanel extends JScrollPane { setFont(Theme.UI_FONT); } else { setText(c.name); - setIcon(c.hasPassword ? Icons.channelLocked() : Icons.channel()); + setIcon(iconFor(c)); setForeground(Theme.CHANNEL_TEXT); setFont(Theme.UI_BOLD); } @@ -609,10 +609,21 @@ public final class ServerTreePanel extends JScrollPane { return this; } + private ImageIcon iconFor(ChannelNode c) { + if (c.hasPassword) return Icons.channelLocked(); + if (c.maxClients >= 0 && c.clients.size() >= c.maxClients) return Icons.channelFull(); + return Icons.channel(); + } + + /** The client's state, in the order the official client gives them priority. */ private ImageIcon iconFor(ClientEntry cl) { + if (cl.isQuery()) return Icons.clientQuery(); if (cl.outputMuted) return Icons.speakerMuted(); if (cl.inputMuted) return Icons.micMuted(); if (cl.away) return Icons.clientAway(); + if (cl.channelCommander) { + return cl.talking ? Icons.clientCommanderTalking() : Icons.clientCommander(); + } if (cl.talking) return Icons.clientTalking(); return Icons.clientIdle(); } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java index 34c9c43..5281383 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java @@ -56,6 +56,7 @@ public final class SettingsDialog extends JDialog { private final Runnable onApply; private NotificationsPanel notificationsPanel; + private IconPackPanel iconPackPanel; private JComboBox inputCombo; private JComboBox outputCombo; @@ -104,6 +105,8 @@ public final class SettingsDialog extends JDialog { tabs.addTab("Voice Activation", scrollable(buildVoiceTab())); notificationsPanel = new NotificationsPanel(settings, sounds); tabs.addTab("Notifications", notificationsPanel); + iconPackPanel = new IconPackPanel(settings); + tabs.addTab("Design", iconPackPanel); JPanel buttons = new JPanel(new BorderLayout()); JPanel right = new JPanel(); @@ -115,6 +118,7 @@ public final class SettingsDialog extends JDialog { }); cancel.addActionListener(e -> { notificationsPanel.revert(); + iconPackPanel.revert(); close(); }); right.add(ok); @@ -549,6 +553,7 @@ public final class SettingsDialog extends JDialog { settings.fec = fecCheck.isSelected(); settings.music = musicCheck.isSelected(); notificationsPanel.apply(); + iconPackPanel.apply(); settings.save(); if (liveMic != null) {