From 7c86a1f1662b79d95934ede2f2081f9d37923ce4 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Fri, 14 Aug 2026 07:06:23 +0000 Subject: [PATCH] Show server and channel group icons in the tree Download the icons a server advertises for its groups over the file transfer channel, cache them under the config directory, and draw them as a badge strip on each client's row, right-aligned against the tree's visible edge like the TeamSpeak client does. Groups without a custom icon fall back to the bundled default set. Co-Authored-By: Claude Opus 5 --- .../java/com/ts3client/config/Settings.java | 3 +- .../com/ts3client/net/ConnectionListener.java | 3 + .../main/java/com/ts3client/net/Group.java | 19 ++ .../com/ts3client/net/IconRepository.java | 164 ++++++++++++++++++ .../java/com/ts3client/net/ServerModel.java | 45 +++-- .../ts3client/net/TeamspeakConnection.java | 37 +++- .../net/filetransfer/FileTransferManager.java | 35 ++++ .../src/main/resources/icons/group_100.png | Bin 0 -> 809 bytes .../src/main/resources/icons/group_200.png | Bin 0 -> 781 bytes .../src/main/resources/icons/group_300.png | Bin 0 -> 820 bytes .../src/main/resources/icons/group_500.png | Bin 0 -> 803 bytes .../src/main/resources/icons/group_600.png | Bin 0 -> 757 bytes .../java/com/ts3client/ui/GroupIcons.java | 101 +++++++++++ .../main/java/com/ts3client/ui/InfoPanel.java | 37 +++- .../main/java/com/ts3client/ui/MainFrame.java | 1 + .../main/java/com/ts3client/ui/ServerTab.java | 19 +- .../com/ts3client/ui/ServerTreePanel.java | 73 +++++++- .../java/com/ts3client/ui/SettingsDialog.java | 98 +++++++++-- 18 files changed, 597 insertions(+), 38 deletions(-) create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/Group.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java create mode 100644 ts3-client/core/src/main/resources/icons/group_100.png create mode 100644 ts3-client/core/src/main/resources/icons/group_200.png create mode 100644 ts3-client/core/src/main/resources/icons/group_300.png create mode 100644 ts3-client/core/src/main/resources/icons/group_500.png create mode 100644 ts3-client/core/src/main/resources/icons/group_600.png create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/GroupIcons.java 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 0076bf7..3eed341 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 @@ -123,7 +123,8 @@ public final class Settings { return new File(identityFile); } - public File configDir() { + /** The directory holding all persistent client state (settings, identities, caches). */ + public static File configDir() { return DIR; } diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java index 1b5d615..94bf3bb 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ConnectionListener.java @@ -21,6 +21,9 @@ public interface ConnectionListener { /** On-demand channel/client details finished loading; refresh any info view. */ void onInfoUpdated(); + /** A group icon finished downloading; repaint anything showing icons. */ + void onIconsUpdated(); + void onChat(ChatScope scope, int fromClientId, String fromName, String message); /** A client (possibly the local one) started or stopped talking. */ diff --git a/ts3-client/core/src/main/java/com/ts3client/net/Group.java b/ts3-client/core/src/main/java/com/ts3client/net/Group.java new file mode 100644 index 0000000..4865653 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/Group.java @@ -0,0 +1,19 @@ +package com.ts3client.net; + +/** A server or channel group as announced by the server. */ +public final class Group { + + public final int id; + public final String name; + /** Id of the group's icon (0 = none); resolved through {@link IconRepository}. */ + public final long iconId; + /** Display order among groups; lower comes first. */ + public final int sortId; + + public Group(int id, String name, long iconId, int sortId) { + this.id = id; + this.name = name; + this.iconId = iconId; + this.sortId = sortId; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java b/ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java new file mode 100644 index 0000000..ad31867 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/net/IconRepository.java @@ -0,0 +1,164 @@ +package com.ts3client.net; + +import com.ts3client.config.Settings; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Supplies the raw PNG bytes of a TeamSpeak icon id, as used by server and channel + * groups. + * + *

Ids up to {@link #MAX_BUNDLED_ID} identify the icons shipped with the client + * (TeamSpeak's default group icons); anything above that is a server-specific icon + * stored in the virtual server's file repository as {@code /icon_} in channel 0, + * which is downloaded once and then cached in memory and on disk. + * + *

Lookups never block: {@link #get(long)} returns {@code null} while an icon is + * still being fetched and the listener is notified once it becomes available. + */ +public final class IconRepository { + + /** Icons at or below this id are bundled defaults rather than server uploads. */ + public static final int MAX_BUNDLED_ID = 1000; + + /** Downloads {@code /icon_} from the connected server's file repository. */ + public interface Fetcher { + byte[] fetchIcon(long iconId) throws Exception; + } + + private final Fetcher fetcher; + private final Runnable onIconLoaded; + private final File cacheDir = new File(Settings.configDir(), "icons"); + private final Map icons = new ConcurrentHashMap<>(); + private final Set pending = ConcurrentHashMap.newKeySet(); + private final Set failed = ConcurrentHashMap.newKeySet(); + private final ExecutorService downloads = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "ts3j-icons"); + t.setDaemon(true); + return t; + }); + + /** + * @param onIconLoaded run (off the UI thread) whenever a previously missing icon + * has arrived, so the view can repaint + */ + public IconRepository(Fetcher fetcher, Runnable onIconLoaded) { + this.fetcher = fetcher; + this.onIconLoaded = onIconLoaded; + } + + /** + * @return the icon's PNG bytes, or {@code null} if it is unknown, still loading or + * unavailable. A server icon that is not cached yet starts downloading. + */ + public byte[] get(long iconId) { + long id = normalize(iconId); + if (id == 0) return null; + + byte[] cached = icons.get(id); + if (cached != null) return cached; + + byte[] bundled = readBundled(id); + if (bundled != null) { + icons.put(id, bundled); + return bundled; + } + if (id <= MAX_BUNDLED_ID) return null; // a default icon we do not ship + + byte[] fromDisk = readDiskCache(id); + if (fromDisk != null) { + icons.put(id, fromDisk); + return fromDisk; + } + if (!failed.contains(id) && pending.add(id)) { + downloads.execute(() -> download(id)); + } + return null; + } + + /** + * The icon as a file on disk, materialising bundled icons into the cache directory + * on first use, for callers that can only reference images by URL (HTML views). + * + * @return the file, or {@code null} while the icon is unavailable + */ + public File file(long iconId) { + long id = normalize(iconId); + byte[] data = get(id); + if (data == null) return null; + File file = new File(cacheDir, "icon_" + id + ".png"); + if (!file.isFile()) writeDiskCache(id, data); + return file.isFile() ? file : null; + } + + /** Forgets download failures so icons can be retried on the next reconnect. */ + public void retryFailed() { + failed.clear(); + } + + public void shutdown() { + downloads.shutdownNow(); + } + + private void download(long id) { + try { + byte[] data = fetcher.fetchIcon(id); + if (data == null || data.length == 0) { + failed.add(id); + return; + } + icons.put(id, data); + writeDiskCache(id, data); + if (onIconLoaded != null) onIconLoaded.run(); + } catch (Exception e) { + // Missing or permission-denied icons are common; don't ask again. + failed.add(id); + } finally { + pending.remove(id); + } + } + + /** + * TeamSpeak transmits icon ids as unsigned 32-bit CRCs, which arrive here through + * signed parsing; fold them back into their unsigned value. + */ + private static long normalize(long iconId) { + return iconId < 0 ? iconId & 0xFFFFFFFFL : iconId; + } + + private static byte[] readBundled(long id) { + try (InputStream in = IconRepository.class.getResourceAsStream("/icons/group_" + id + ".png")) { + return in == null ? null : in.readAllBytes(); + } catch (IOException e) { + return null; + } + } + + private byte[] readDiskCache(long id) { + File file = new File(cacheDir, "icon_" + id + ".png"); + if (!file.isFile()) return null; + try { + return Files.readAllBytes(file.toPath()); + } catch (IOException e) { + return null; + } + } + + private void writeDiskCache(long id, byte[] data) { + try { + //noinspection ResultOfMethodCallIgnored + cacheDir.mkdirs(); + Files.write(new File(cacheDir, "icon_" + id + ".png").toPath(), data); + } catch (IOException ignored) { + // the in-memory cache still serves this session + } + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java index db1d4ab..ed62de2 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/ServerModel.java @@ -16,8 +16,8 @@ public final class ServerModel { private final Map channels = new LinkedHashMap<>(); private final Map clients = new LinkedHashMap<>(); - private final Map serverGroups = new LinkedHashMap<>(); - private final Map channelGroups = new LinkedHashMap<>(); + private final Map serverGroups = new LinkedHashMap<>(); + private final Map channelGroups = new LinkedHashMap<>(); private String serverName = "TeamSpeak Server"; public synchronized void clear() { @@ -29,25 +29,43 @@ public final class ServerModel { // ---- groups ---- - public synchronized void putServerGroup(int id, String name) { - if (name != null) serverGroups.put(id, name); + public synchronized void putServerGroup(Group group) { + if (group != null && group.name != null) serverGroups.put(group.id, group); } - public synchronized void putChannelGroup(int id, String name) { - if (name != null) channelGroups.put(id, name); + public synchronized void putChannelGroup(Group group) { + if (group != null && group.name != null) channelGroups.put(group.id, group); } - public synchronized String channelGroupName(int id) { + public synchronized Group channelGroup(int id) { return channelGroups.get(id); } - /** Resolves server-group ids to their names, keeping unknown ids as "#id". */ - public synchronized java.util.List serverGroupNames(int[] ids) { - java.util.List names = new java.util.ArrayList<>(); + public synchronized String channelGroupName(int id) { + Group g = channelGroups.get(id); + return g == null ? null : g.name; + } + + /** Resolves a client's server-group ids to groups, ordered for display. */ + public synchronized List serverGroupsOf(int[] ids) { + List groups = new ArrayList<>(); if (ids != null) { for (int id : ids) { - String name = serverGroups.get(id); - names.add(name != null ? name : "#" + id); + Group g = serverGroups.get(id); + if (g != null) groups.add(g); + } + } + groups.sort(Comparator.comparingInt((Group g) -> g.sortId).thenComparingInt(g -> g.id)); + return groups; + } + + /** Resolves server-group ids to their names, keeping unknown ids as "#id". */ + public synchronized List serverGroupNames(int[] ids) { + List names = new ArrayList<>(); + if (ids != null) { + for (int id : ids) { + Group g = serverGroups.get(id); + names.add(g != null ? g.name : "#" + id); } } return names; @@ -56,7 +74,8 @@ public final class ServerModel { /** Primary (first) server-group name for compact display, or {@code null}. */ public synchronized String primaryServerGroupName(int[] ids) { if (ids == null || ids.length == 0) return null; - return serverGroups.get(ids[0]); + Group g = serverGroups.get(ids[0]); + return g == null ? null : g.name; } public synchronized String getServerName() { diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index d0a2b15..9dcefd1 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -33,10 +33,14 @@ import java.util.function.Consumer; */ public final class TeamspeakConnection implements TS3Listener { + /** Upper bound for a downloaded group icon; anything larger is not an icon. */ + private static final int MAX_ICON_BYTES = 1024 * 1024; + private final Settings settings; private final AudioBackend audio; private final ServerModel model = new ServerModel(); private final ConnectionListener ui; + private final IconRepository icons; private LocalTeamspeakClientSocket client; private VoiceInput microphone; @@ -64,6 +68,7 @@ public final class TeamspeakConnection implements TS3Listener { this.settings = settings; this.audio = audio; this.ui = ui; + this.icons = new IconRepository(this::fetchIcon, ui::onIconsUpdated); } public ServerModel getModel() { @@ -201,6 +206,7 @@ public final class TeamspeakConnection implements TS3Listener { selfClientId = client.getClientId(); fileTransfers = new FileTransferManager(client, () -> serverHost); client.setMicrophone(microphone); + icons.retryFailed(); connected = true; connectedAtMs = System.currentTimeMillis(); ui.onConnected(); @@ -647,13 +653,17 @@ public final class TeamspeakConnection implements TS3Listener { @Override public void onServerGroupList(ServerGroupListEvent e) { int id = safeInt(e, "sgid"); - if (id > 0) model.putServerGroup(id, e.get("name")); + if (id > 0) model.putServerGroup(toGroup(e, id)); } @Override public void onChannelGroupList(ChannelGroupListEvent e) { int id = safeInt(e, "cgid"); - if (id > 0) model.putChannelGroup(id, e.get("name")); + if (id > 0) model.putChannelGroup(toGroup(e, id)); + } + + private static Group toGroup(BaseEvent e, int id) { + return new Group(id, e.get("name"), safeLong(e, "iconid"), safeInt(e, "sortid")); } /** Fetches a channel's description (and topic) on demand, then notifies the UI. */ @@ -697,6 +707,20 @@ public final class TeamspeakConnection implements TS3Listener { }, "ts3j-clientinfo").start(); } + // ---- icons ---- + + /** Group icons for this server: bundled defaults plus the server's own uploads. */ + public IconRepository getIcons() { + return icons; + } + + /** Reads an icon out of the virtual server's file repository (channel 0). */ + private byte[] fetchIcon(long iconId) throws Exception { + FileTransferManager ft = fileTransfers; + if (ft == null) throw new IllegalStateException("Not connected"); + return ft.downloadToMemory(0, "", "/icon_" + iconId, MAX_ICON_BYTES); + } + // ---- file transfers ---- /** Whether a live connection capable of file transfers is available. */ @@ -1022,6 +1046,15 @@ public final class TeamspeakConnection implements TS3Listener { // ---- helpers ---- + private static long safeLong(BaseEvent e, String key) { + try { + String v = e.get(key); + return v == null ? 0 : Long.parseLong(v.trim()); + } catch (Exception ex) { + return 0; + } + } + private static int safeInt(BaseEvent e, String key) { try { String v = e.get(key); diff --git a/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransferManager.java b/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransferManager.java index c8d1d43..f2802e8 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransferManager.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/filetransfer/FileTransferManager.java @@ -176,6 +176,41 @@ public final class FileTransferManager { return transfer; } + /** + * Downloads a small file straight into memory, blocking until it is complete. + * Intended for icons and other assets that never reach the disk. + */ + public byte[] downloadToMemory(int channelId, String channelPassword, String remoteFullPath, + int maxBytes) throws Exception { + int ftfid = clientTransferId.getAndIncrement(); + SingleCommand cmd = new SingleCommand("ftinitdownload", ProtocolRole.CLIENT, + new CommandSingleParameter("clientftfid", Integer.toString(ftfid)), + new CommandSingleParameter("name", remoteFullPath), + new CommandSingleParameter("cid", Integer.toString(channelId)), + new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword), + new CommandSingleParameter("seekpos", "0"), + new CommandSingleParameter("proto", "0")); + + FileTransferParameters params = negotiate(ftfid, cmd); + long size = params.getFileSize(); + if (size <= 0 || size > maxBytes) { + throw new IOException("Unexpected file size " + size + " for " + remoteFullPath); + } + + byte[] data = new byte[(int) size]; + try (Socket fileSocket = openFileSocket(params); + InputStream in = new BufferedInputStream(fileSocket.getInputStream())) { + sendKey(fileSocket, params); + int offset = 0; + while (offset < data.length) { + int read = in.read(data, offset, data.length - offset); + if (read < 0) throw new IOException("File server closed the connection early"); + offset += read; + } + } + return data; + } + private void runDownload(int channelId, String channelPassword, String remoteFullPath, FileTransfer transfer) { try { diff --git a/ts3-client/core/src/main/resources/icons/group_100.png b/ts3-client/core/src/main/resources/icons/group_100.png new file mode 100644 index 0000000000000000000000000000000000000000..7c948d4845cde7bb80a9e4c60cf0da3cc975d6ea GIT binary patch literal 809 zcmV+^1J?YBP)z@;o?%Qu_W%F@AY({UO#lFTB>(_`g8%^e{{R4h=l}px z2mk>USO5SzmjD14Z`WEMkN^MzpGibPRCwB?lfO?BaTvy*>s?!V2R%p)bbzx0u}l&Q zMMw)K*hxhpF&GE@6Oif&(8w@m>UP7&|}%!}#ARDF|$Z}TJbxo>Ns_5+WfDnSk z#bt2|{0|55 z{KZrF2mA46dK$W3fv#yFgw%R_dk;+jNs^FCr4B)XUq3ex{t$-13-H|YAQD+XIQ$Xa z-CmrYog8*_Najye6a|`Ao+*}!QCg=sI!a>h{TwRB6Mxw4h@6~L1A)M*AP8oFVHjXp7Mq)!6P~*gthNRm zCzCimNy6@M!0#VGVk83-Ha9V~>pI--HcCk8 nT`)MZX0zG;Ya?^pI{zC00DU<`2^Fi$00000NkvXXu0mjfgd$G7 literal 0 HcmV?d00001 diff --git a/ts3-client/core/src/main/resources/icons/group_200.png b/ts3-client/core/src/main/resources/icons/group_200.png new file mode 100644 index 0000000000000000000000000000000000000000..4acadb5e25e656331049d350fde82d0a708b19f1 GIT binary patch literal 781 zcmV+o1M>WdP)PK^VrL?I&zY7gIx6d<%}T`P%%6nr5666E7+4bjWfR zsg#JKdI6Kkgp8bl%Tb^eh$@K7ivm)Yf{isRC>ex&4`T2L3Ys9Yvm%zQgzU^||Zx1qAIU)!mE-s2t zRTc4*6S!R-6pJbhhF?Y#0piga&qs6wsgy8zdKyPsyCX5t-_?asD1?oTO|-YQY3o05 zmOO*eNMUbpXHrMtYxRX=v6$-du*P68h)0hf;auwiq3ZJA!p!sxva(FPQCnJC!a4$$ zWubWxhDQ74^BTrqjKfCTP=CE1qt8b%HT4ROjZHZIb`;jK0v!QKmn>(7gp?306-zic zh+yQ|2y#jRMv4OCsD|A^BfTpPk-a(snx@g+-LrUju%E89X&bdRRyi0j%oQkwb2u0V z9M7fwe*YrLhfaV*)@U?heSN*Z{u&Fjr2>bM2#${;uxgR+=xE2*MyQ{3BA?IeuRw~) z*W25(e9E0XX>M+qV5+MqRcou;?J7x9>ZwM!Y_VAW{u=*v{1jjSH=;nUG5D7B00000 LNkvXXu0mjft88R@ literal 0 HcmV?d00001 diff --git a/ts3-client/core/src/main/resources/icons/group_300.png b/ts3-client/core/src/main/resources/icons/group_300.png new file mode 100644 index 0000000000000000000000000000000000000000..d048e3ef49b75531b02a3a8bbefa000b422057f9 GIT binary patch literal 820 zcmV-41Izr0P)z@;o?%Qu_W%F@AY({UO#lFTB>(_`g8%^e{{R4h=l}px z2mk>USO5SzmjD14Z`WEMkN^Mzs!2paRCwB?lg&@l02s!f(yrY)&5R>k30X1G;cmnM z2?-kqgcylL{D{iYa2Dp33jspBXcQ8S7o!nOjD$ZxE<{2i@}+^m#xTo1*au}R-Dn4N z>*_@gq(|PQoF44ua!2Oig`xNKvLtl;pl098GKrfCQUL#VBF;a-mu$>eU;($cd1 zw6E$^h+Q~LPjVS6<$?K zsHx$Q&lm9S?R!*_6dv5a3!BY`jrC1991aAxHU)!&#>S@2L?QuA)3CC#1iSq*`UB74 z^ZW31dIrB1m!NAE2*Mujba!tW0J5S)a{D=yN@d9M30}T>gH$So=4LO(Mu*_>c#zoL z1!JNi2>THO;NT!Pk>1N7o85=SLc?rkF#T-?1N{LkE-s_9vmKIj0D=UzLt7IDfcJ(s zoJ{U&f{=sTeGPqmPf=ZMhttWy?RFz02>(x-*BTlc!Uh1(^N7b|VG~8*)5l4KLP7NQ zK0)AlKeD+ThKGj1^H&gyMZ+!^Z~R126rm_`pdicNOQjOVMqi+!S5PXcP?RE|6L2`H z5s8EXqA0@PfMr?ueE!+LJKF^-djXnO0Y%avNg7&3hm+%w*-IB%TYa-utJMflRTU&j zVs&-3hp(%JiDnRs$B;`8l0000%VDkn literal 0 HcmV?d00001 diff --git a/ts3-client/core/src/main/resources/icons/group_500.png b/ts3-client/core/src/main/resources/icons/group_500.png new file mode 100644 index 0000000000000000000000000000000000000000..f303433e0d63433e5c77971e400c4c8835c09e11 GIT binary patch literal 803 zcmV+;1Kj+HP)Px#24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV0GgZ_00007bV*G`2igS& z3ONX$GQ}GJ00N^)L_t(I%cYY~NEBfh#-DFTXZDY>ZvI7d6G==2$wOTP4WqzL3V}o` zCMe=1y$P}?Y?nQB$qsqx;6d09VPKcmPzW@FQgktK-4tm>8{OGlW}QFZeDih5gU!+R z^j_W{&%^sXgmaFoOu@x6F)@Lvsw%8ZPCoJw^4RNgJ?swz+%|yMnM}UO<$8B++iz}7 zO?_4r1(8Su06;DjjFy$j^`cnT>hX}q{(cm7c0vdQz$pbiJcJsLV{2gnS;p)vW0~P} zy5f?8Qb8ykUR)$ZlE6+*V97FURRw1ZYCH~iOAA`->!ELMx_&Gzl{zdOP->c~l$&!7 zy1x&9Z!e7PZ4gO9ML3LKeSIJv4|qP0lxdm|{@uAQ06T^uvuqaD@iC<4X3(P}d>ItJK#$vcG zNobTL6t%a**xd!?9EX&$!qAhX;|sxH5Z^T|d0Ud2K1Cw9#~7+-W>9TeF!%Nl35RjV z>%~`1OV-u~ok>&_1*euZW}TjX)NLCt!eQ6|blXOTF>uatLlm)+&ySTT3LF-^UN0(! zhvRFqoC^p7DCZ~uP)G>5Vy+Q>-U=kqxMrfEVD1iU>v>uxG9hldcXYZ}%y z4Mi>&dIJHx&*i!~=g@WCX+aPK2!ep%z|-aQ!iQ(~i;G{@h$1m<8#mo<{+rU#nt_33 hQ4}w=kzBRTe*v&CLC-*FK^Oo4002ovPDHLkV1fZuQknn& literal 0 HcmV?d00001 diff --git a/ts3-client/core/src/main/resources/icons/group_600.png b/ts3-client/core/src/main/resources/icons/group_600.png new file mode 100644 index 0000000000000000000000000000000000000000..d625f2ae9a51c479565b1ae9b69e9356bf8b016b GIT binary patch literal 757 zcmVPx#24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV0GgZ_00007bV*G`2igJ* z6eTpB4_40r00MPML_t(I%dL@1PZMzz#m{tRifsc8_5p1|tBDl}#D!ABLN-8%5iyXs z@l(h*A+R^7_*|5gnn*%~rUaCf2oY?XQmMnEowhTbVIG}-X~$I!QeEj;-FtG+FDJRc zKZ-hUgu}NUFbw<9YGv;Z3=CW_O%u`RirUiB5+Me8ytcj;{Ucy_c!+noyzZ{905vi) zgsa`%u-j}Pgg};LB$7$QVlk+iTGw^G^y2xv7XX+Bz;1WCW@e@-hG8L!WmE}4g#u1b zPf;uu(9z+=wr`nTO3z6o&iM*j}S zagb#hnx?_$>%>tm2SF4tICvXJhes%vCFJ?Md2f7trwO2FYNk{YAP6G1wzlDRyOGUi zp`0k_>+QwMSFb?`0mHCVfiGm50HRo$Jw85!Ae4~FWMLQv{60T|!65WX1)K3rNU{Wm zu~-s`-PuO&*B4e}tErxAJvF~SK^=;`07U@|&7#%mu*eB%>TKq>2C>-c;$Au_+Z=Xing)x722C@l)l9UuwIR>v<>lq& z#lHdoARdoTTnYw2Q8e=TJPHLKG{Ybq?#I{dFB9kQ>GMV|m)q~V-Y0lG7w^~|4$9;4 nA{4q}R;$LNNF?$G|64x+lSwgoDU!|z00000NkvXXu0mjfQ+r5d literal 0 HcmV?d00001 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 new file mode 100644 index 0000000..e241b3f --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/GroupIcons.java @@ -0,0 +1,101 @@ +package com.ts3client.ui; + +import com.ts3client.net.Group; +import com.ts3client.net.IconRepository; + +import javax.swing.Icon; +import javax.swing.ImageIcon; +import java.awt.Component; +import java.awt.Graphics; +import java.awt.Image; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Turns the {@link IconRepository}'s icon bytes into Swing icons for one server's + * groups, caching the decoded images. Icons that still have to be downloaded simply + * do not render yet; the repository repaints the view once they arrive. + */ +public final class GroupIcons { + + /** Tree rows are 16×16 like TeamSpeak's own icon set. */ + private static final int SIZE = 16; + + private final IconRepository repository; + private final Map decoded = new ConcurrentHashMap<>(); + + public GroupIcons(IconRepository repository) { + this.repository = repository; + } + + /** @return the group's icon, or {@code null} if it has none or it is not loaded yet */ + public ImageIcon iconOf(Group group) { + if (group == null || group.iconId == 0) return null; + return icon(group.iconId); + } + + public ImageIcon icon(long iconId) { + ImageIcon cached = decoded.get(iconId); + if (cached != null) return cached; + + byte[] data = repository.get(iconId); + if (data == null) return null; + + ImageIcon icon = new ImageIcon(data); + if (icon.getIconWidth() <= 0) return null; + if (icon.getIconWidth() != SIZE || icon.getIconHeight() != SIZE) { + icon = new ImageIcon(icon.getImage().getScaledInstance(SIZE, SIZE, Image.SCALE_SMOOTH)); + } + decoded.put(iconId, icon); + return icon; + } + + /** Collects the icons of a client's server groups plus its channel group, in display order. */ + public List iconsOf(List serverGroups, Group channelGroup) { + List icons = new ArrayList<>(); + for (Group g : serverGroups) { + ImageIcon icon = iconOf(g); + if (icon != null) icons.add(icon); + } + ImageIcon channelIcon = iconOf(channelGroup); + if (channelIcon != null) icons.add(channelIcon); + return icons; + } + + /** Lays several icons out in a row, so a single tree cell can show a badge strip. */ + public static final class Row implements Icon { + + private static final int GAP = 2; + + private final List icons; + + public Row(List icons) { + this.icons = icons; + } + + @Override + public void paintIcon(Component c, Graphics g, int x, int y) { + int offset = x; + for (Icon icon : icons) { + icon.paintIcon(c, g, offset, y + (getIconHeight() - icon.getIconHeight()) / 2); + offset += icon.getIconWidth() + GAP; + } + } + + @Override + public int getIconWidth() { + int width = 0; + for (Icon icon : icons) width += icon.getIconWidth() + GAP; + return Math.max(0, width - GAP); + } + + @Override + public int getIconHeight() { + int height = 0; + for (Icon icon : icons) height = Math.max(height, icon.getIconHeight()); + return height; + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java index bb573fb..0aca914 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/InfoPanel.java @@ -2,12 +2,15 @@ package com.ts3client.ui; import com.ts3client.net.ChannelNode; import com.ts3client.net.ClientEntry; +import com.ts3client.net.Group; +import com.ts3client.net.IconRepository; import com.ts3client.net.ServerModel; import com.ts3client.text.BBCode; import javax.swing.BorderFactory; import javax.swing.JEditorPane; import javax.swing.JScrollPane; +import java.io.File; import java.util.List; /** @@ -67,15 +70,27 @@ public final class InfoPanel extends JScrollPane { setHtml(sb.toString()); } - public void showClient(ClientEntry cl, ServerModel model) { + public void showClient(ClientEntry cl, ServerModel model, IconRepository icons) { StringBuilder sb = new StringBuilder(); sb.append(heading(esc(cl.nickname) + (cl.self ? " (you)" : ""))); - List serverGroups = model.serverGroupNames(cl.serverGroupIds); - row(sb, "Server groups", serverGroups.isEmpty() ? "—" : esc(String.join(", ", serverGroups))); + List serverGroups = model.serverGroupsOf(cl.serverGroupIds); + if (serverGroups.isEmpty()) { + List names = model.serverGroupNames(cl.serverGroupIds); + row(sb, "Server groups", names.isEmpty() ? "—" : esc(String.join(", ", names))); + } else { + StringBuilder groups = new StringBuilder(); + for (Group g : serverGroups) { + if (groups.length() > 0) groups.append(", "); + groups.append(iconTag(g, icons)).append(esc(g.name)); + } + row(sb, "Server groups", groups.toString()); + } - String channelGroup = model.channelGroupName(cl.channelGroupId); - row(sb, "Channel group", channelGroup != null ? esc(channelGroup) : "#" + cl.channelGroupId); + Group channelGroup = model.channelGroup(cl.channelGroupId); + row(sb, "Channel group", channelGroup != null + ? iconTag(channelGroup, icons) + esc(channelGroup.name) + : "#" + cl.channelGroupId); if (cl.talkPower != 0) row(sb, "Talk power", Integer.toString(cl.talkPower)); if (!cl.platform.isEmpty()) row(sb, "Platform", esc(cl.platform)); @@ -97,6 +112,18 @@ public final class InfoPanel extends JScrollPane { pane.setCaretPosition(0); } + /** + * An inline {@code } for a group's icon, or nothing while the icon is missing + * or still downloading. Swing's HTML renderer only loads images by URL, so the icon + * is served from the repository's on-disk cache. + */ + private static String iconTag(Group group, IconRepository icons) { + if (group.iconId == 0) return ""; + File file = icons.file(group.iconId); + if (file == null) return ""; + return " "; + } + private static String heading(String text) { return "

" + text + "
"; } 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 57291d0..827c32d 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 @@ -314,6 +314,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { if (tabs.size() == 1) return; // always keep one view around tabs.remove(tab); + tab.dispose(); tabPane.removeTab(tab); if (micTab == tab) micTab = null; if (selected == tab) { 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 a46ecaa..9d52bbf 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 @@ -31,6 +31,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions { private final IdentityStore identities; private final TeamspeakConnection conn; + private final GroupIcons groupIcons; private final ServerTreePanel treePanel; private final ChatPanel chatPanel; private final InfoPanel infoPanel = new InfoPanel(); @@ -56,7 +57,8 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions { this.settings = settings; this.identities = identities; this.conn = new TeamspeakConnection(settings, audio, this); - this.treePanel = new ServerTreePanel(conn.getModel(), this); + this.groupIcons = new GroupIcons(conn.getIcons()); + this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, this); this.chatPanel = new ChatPanel(); chatPanel.setSendHandler(this::onSendChat); @@ -189,6 +191,11 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions { if (conn.isConnected()) conn.disconnect(); } + /** Releases the background resources of a tab that is being thrown away. */ + void dispose() { + conn.getIcons().shutdown(); + } + /** Synchronous teardown for shutdown paths, so the server sees us leave. */ void shutdown() { if (conn.isConnected()) conn.disconnectBlocking("Leaving"); @@ -361,7 +368,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions { if (sel instanceof ChannelNode) { infoPanel.showChannel((ChannelNode) sel); } else if (sel instanceof ClientEntry) { - infoPanel.showClient((ClientEntry) sel, conn.getModel()); + infoPanel.showClient((ClientEntry) sel, conn.getModel(), conn.getIcons()); } else { infoPanel.clear(); } @@ -425,6 +432,14 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions { SwingUtilities.invokeLater(this::renderInfo); } + @Override + public void onIconsUpdated() { + SwingUtilities.invokeLater(() -> { + treePanel.refreshRowSizes(); + renderInfo(); + }); + } + @Override public void onChat(ChatScope scope, int fromClientId, String fromName, String message) { switch (scope) { 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 d1942f7..a54d6b1 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 @@ -6,6 +6,7 @@ import com.ts3client.net.ServerModel; import com.ts3client.text.TsLink; import javax.swing.DropMode; +import javax.swing.Icon; import javax.swing.ImageIcon; import javax.swing.JComponent; import javax.swing.JScrollPane; @@ -77,11 +78,13 @@ public final class ServerTreePanel extends JScrollPane { private final DefaultMutableTreeNode root = new DefaultMutableTreeNode(); private final DefaultTreeModel treeModel = new DefaultTreeModel(root); private final ServerModel model; + private final GroupIcons groupIcons; private final Actions actions; private int selfClientId = -1; - public ServerTreePanel(ServerModel model, Actions actions) { + public ServerTreePanel(ServerModel model, GroupIcons groupIcons, Actions actions) { this.model = model; + this.groupIcons = groupIcons; this.actions = actions; root.setUserObject("Not connected"); this.tree = new DropIndicatorTree(treeModel); @@ -364,11 +367,47 @@ public final class ServerTreePanel extends JScrollPane { } } + // ---- group icon strip ---- + + /** Gap kept between a row's label and the right-aligned icon strip. */ + private static final int BADGE_GAP = 8; + /** Inset of the strip from the visible right edge. */ + private static final int BADGE_MARGIN = 4; + + /** + * Paints every visible client's group icons flush with the right edge of the + * viewport, the way TeamSpeak lines them up. Drawing them here rather than in the + * cell renderer keeps the rows' measured widths (and thus the selection highlight) + * tied to the label alone. + */ + private void paintBadges(Graphics g, JTree tree) { + Rectangle visible = tree.getVisibleRect(); + int right = visible.x + visible.width - BADGE_MARGIN; + for (int row = 0; row < tree.getRowCount(); row++) { + Rectangle bounds = tree.getRowBounds(row); + if (bounds == null || bounds.y + bounds.height < visible.y) continue; + if (bounds.y > visible.y + visible.height) break; + + TreePath path = tree.getPathForRow(row); + Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject(); + if (!(obj instanceof ClientEntry)) continue; + ClientEntry cl = (ClientEntry) obj; + + List icons = groupIcons.iconsOf( + model.serverGroupsOf(cl.serverGroupIds), model.channelGroup(cl.channelGroupId)); + if (icons.isEmpty()) continue; + + GroupIcons.Row strip = new GroupIcons.Row(icons); + int x = Math.max(bounds.x + bounds.width + BADGE_GAP, right - strip.getIconWidth()); + strip.paintIcon(tree, g, x, bounds.y + (bounds.height - strip.getIconHeight()) / 2); + } + } + /** * Draws where the drop will land: an insertion line between rows, or an outline * around the row that will receive the dragged node. */ - private static final class DropIndicatorTree extends JTree { + private final class DropIndicatorTree extends JTree { /** Set while a drop would move a client into this channel row. */ private TreePath highlight; @@ -385,6 +424,7 @@ public final class ServerTreePanel extends JScrollPane { @Override protected void paintComponent(Graphics g) { super.paintComponent(g); + paintBadges(g, this); JTree.DropLocation loc = getDropLocation(); if (loc == null || loc.getPath() == null) return; @@ -477,7 +517,26 @@ public final class ServerTreePanel extends JScrollPane { tree.repaint(); } + /** + * Re-measures every visible row, for changes that alter a row's width (a group + * icon that finished downloading). A plain repaint would keep the cached widths + * and clip the new icons. + */ + public void refreshRowSizes() { + for (int i = tree.getRowCount() - 1; i >= 0; i--) { + TreePath path = tree.getPathForRow(i); + if (path != null) { + treeModel.nodeChanged((DefaultMutableTreeNode) path.getLastPathComponent()); + } + } + } + + /** + * Draws a tree row: the status icon and the label. The group icon strip is painted + * separately, right-aligned, by {@link #paintBadges}. + */ private final class Renderer extends DefaultTreeCellRenderer { + @Override public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel, boolean expanded, boolean leaf, int row, @@ -504,9 +563,15 @@ public final class ServerTreePanel extends JScrollPane { } } else if (obj instanceof ClientEntry) { ClientEntry cl = (ClientEntry) obj; - String primaryGroup = model.primaryServerGroupName(cl.serverGroupIds); String label = cl.nickname; - if (primaryGroup != null) label += " [" + primaryGroup + "]"; + boolean hasIcons = !groupIcons.iconsOf( + model.serverGroupsOf(cl.serverGroupIds), + model.channelGroup(cl.channelGroupId)).isEmpty(); + if (!hasIcons) { + // No icons (yet): fall back to naming the primary group inline. + String primaryGroup = model.primaryServerGroupName(cl.serverGroupIds); + if (primaryGroup != null) label += " [" + primaryGroup + "]"; + } setText(label); setIcon(iconFor(cl)); setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT); 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 20b3605..2d66230 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 @@ -13,12 +13,14 @@ import javax.swing.ButtonGroup; import javax.swing.JButton; import javax.swing.JCheckBox; import javax.swing.JComboBox; +import javax.swing.JComponent; import javax.swing.JDialog; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JRadioButton; import javax.swing.JSlider; import javax.swing.JTabbedPane; +import javax.swing.Scrollable; import javax.swing.SwingUtilities; import java.awt.BorderLayout; import java.awt.Dimension; @@ -26,6 +28,7 @@ import java.awt.Frame; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; import java.awt.Insets; +import java.awt.Rectangle; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; import java.util.List; @@ -37,6 +40,14 @@ import java.util.List; */ public final class SettingsDialog extends JDialog { + /** Preferred widths of the form's field column; rows shrink with the dialog from there. */ + private static final int FIELD_WIDTH = 240; + private static final int SLIDER_WIDTH = 200; + private static final int MIN_FIELD_WIDTH = 60; + + private static final int MIN_BITRATE_KBITS = 8; + private static final int MAX_BITRATE_KBITS = 160; + private final Settings settings; private final VoiceInput liveMic; private final VoiceOutput livePlayback; @@ -114,13 +125,13 @@ public final class SettingsDialog extends JDialog { pack(); setSize(new Dimension(480, 540)); + setMinimumSize(new Dimension(420, 360)); setLocationRelativeTo(owner); startMeter(); } private JPanel buildDevicesTab() { - JPanel p = new JPanel(new GridBagLayout()); - p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + JPanel p = formPanel(); GridBagConstraints c = gbc(); List ins = AudioDevices.inputDevices(); @@ -136,6 +147,8 @@ public final class SettingsDialog extends JDialog { + "ALSA: entries talk to the sound card directly, taking it exclusively."; inputCombo.setToolTipText(deviceHint); outputCombo.setToolTipText(deviceHint); + limitWidth(inputCombo, FIELD_WIDTH); + limitWidth(outputCombo, FIELD_WIDTH); int row = 0; addRow(p, c, row++, new JLabel("Capture device (microphone):"), inputCombo); @@ -143,6 +156,8 @@ public final class SettingsDialog extends JDialog { inputGain = new JSlider(0, 200, (int) Math.round(settings.inputVolume * 100)); outputVol = new JSlider(0, 200, (int) Math.round(settings.outputVolume * 100)); + limitWidth(inputGain, SLIDER_WIDTH); + limitWidth(outputVol, SLIDER_WIDTH); addRow(p, c, row++, new JLabel("Microphone gain:"), inputGain); addRow(p, c, row++, new JLabel("Playback volume:"), outputVol); @@ -165,6 +180,7 @@ public final class SettingsDialog extends JDialog { denoiseCheck = new JCheckBox("Remove background noise", settings.denoise); denoiseCheck.setToolTipText("Attempt to filter out background noises."); denoiseLevel = new JSlider(0, 100, (int) Math.round(settings.denoiserLevel * 100)); + limitWidth(denoiseLevel, SLIDER_WIDTH); denoiseLevel.setToolTipText("Higher = more aggressive noise removal."); typingCheck = new JCheckBox("Typing attenuation", settings.typingAttenuation); typingCheck.setToolTipText("Typing attenuation tries to detect and " @@ -214,8 +230,7 @@ public final class SettingsDialog extends JDialog { } private JPanel buildVoiceTab() { - JPanel p = new JPanel(new GridBagLayout()); - p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + JPanel p = formPanel(); GridBagConstraints c = gbc(); vadRadio = new JRadioButton("Voice Activation Detection"); @@ -295,18 +310,18 @@ public final class SettingsDialog extends JDialog { p.add(vadOverPttCheck, c); c.gridwidth = 1; - bitrateSlider = new JSlider(8, 128, settings.bitrate / 1000); - bitrateLabel = new JLabel(settings.bitrate / 1000 + " kbit/s"); + int kbits = Math.max(MIN_BITRATE_KBITS, Math.min(MAX_BITRATE_KBITS, settings.bitrate / 1000)); + bitrateSlider = new JSlider(MIN_BITRATE_KBITS, MAX_BITRATE_KBITS, kbits); + bitrateLabel = new JLabel(kbits + " kbit/s"); bitrateSlider.addChangeListener(e -> { bitrateLabel.setText(bitrateSlider.getValue() + " kbit/s"); pushOpusLive(); }); - JPanel brPanel = new JPanel(new BorderLayout(6, 0)); - brPanel.add(bitrateSlider, BorderLayout.CENTER); - brPanel.add(bitrateLabel, BorderLayout.EAST); - addRow(p, c, row++, new JLabel("Opus bitrate:"), brPanel); + addRow(p, c, row++, new JLabel("Opus bitrate:"), + sliderWithLabel(bitrateSlider, bitrateLabel, 70)); complexitySlider = new JSlider(0, 10, settings.complexity); + limitWidth(complexitySlider, SLIDER_WIDTH); complexitySlider.addChangeListener(e -> pushOpusLive()); addRow(p, c, row++, new JLabel("Opus complexity:"), complexitySlider); @@ -389,6 +404,47 @@ public final class SettingsDialog extends JDialog { } } + /** + * The form panel used by both tabs. It follows the scroll pane's width instead of + * demanding its own preferred one, so rows stay inside the dialog. + */ + private static JPanel formPanel() { + JPanel p = new FormPanel(); + p.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + return p; + } + + private static final class FormPanel extends JPanel implements Scrollable { + FormPanel() { + super(new GridBagLayout()); + } + + @Override + public Dimension getPreferredScrollableViewportSize() { + return getPreferredSize(); + } + + @Override + public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) { + return 16; + } + + @Override + public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) { + return visible.height; + } + + @Override + public boolean getScrollableTracksViewportWidth() { + return true; + } + + @Override + public boolean getScrollableTracksViewportHeight() { + return false; + } + } + private static javax.swing.JScrollPane scrollable(JPanel content) { javax.swing.JScrollPane sp = new javax.swing.JScrollPane(content, javax.swing.ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, @@ -399,13 +455,33 @@ public final class SettingsDialog extends JDialog { } private static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel) { + return sliderWithLabel(slider, valueLabel, 48); + } + + /** + * Pairs a slider with its value readout. The readout gets a fixed width wide enough for + * the longest value so the slider does not jump around as it is dragged. + */ + private static JPanel sliderWithLabel(JSlider slider, JLabel valueLabel, int labelWidth) { JPanel panel = new JPanel(new BorderLayout(6, 0)); + limitWidth(slider, SLIDER_WIDTH); panel.add(slider, BorderLayout.CENTER); - valueLabel.setPreferredSize(new Dimension(48, valueLabel.getPreferredSize().height)); + valueLabel.setPreferredSize(new Dimension(labelWidth, valueLabel.getPreferredSize().height)); panel.add(valueLabel, BorderLayout.EAST); return panel; } + /** + * Caps a field's preferred width and lets it shrink: long device names and wide sliders + * would otherwise force the form past the dialog's edge, where the scroll pane (which + * never scrolls horizontally) simply clips them. + */ + private static void limitWidth(JComponent comp, int preferredWidth) { + int height = comp.getPreferredSize().height; + comp.setPreferredSize(new Dimension(preferredWidth, height)); + comp.setMinimumSize(new Dimension(MIN_FIELD_WIDTH, height)); + } + private OpusParameters currentOpusParameters() { return new OpusParameters( bitrateSlider.getValue() * 1000,