Draw the interface with TeamSpeak icon packs
Icon packs are read in TeamSpeak's own format — a zip (or unpacked folder) of SVG/PNG art plus a settings.ini mapping icon keys to files — so the packs of an installed client and the ones from its add-on site work unchanged. Legacy packs without that mapping (default.zip) are resolved by their file naming convention instead, picking the resolution closest to the drawn size. The pack draws the tree, toolbar, menus, context menus, file browser and the default group icons; a pack's FALLBACK option decides whether what it lacks comes from default.zip, and anything still missing falls back to the icons the client draws itself. Options → Design picks the pack and shows a viewer of everything in it. Vector art is rasterised with JSVG, and the pack search roots are shared with the sound packs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<File> searchRoots(String subfolder, File userDirectory, String extraDirectory) {
|
||||
List<File> 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<File> existing = new ArrayList<>();
|
||||
for (File root : roots) {
|
||||
if (root.isDirectory()) existing.add(root);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
314
ts3-client/core/src/main/java/com/ts3client/gfx/IconPack.java
Normal file
314
ts3-client/core/src/main/java/com/ts3client/gfx/IconPack.java
Normal file
@@ -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:
|
||||
*
|
||||
* <pre>
|
||||
* [options]
|
||||
* FALLBACK=true
|
||||
*
|
||||
* [gfxfiles]
|
||||
* CHANNEL_GREEN = channel_green.svg
|
||||
* CONNECT = connect.svg
|
||||
* </pre>
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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 <width>x<height>_<key>.<ext>}. */
|
||||
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<String, List<Entry>> entries;
|
||||
|
||||
private IconPack(String id, File source, String name, boolean zipped, boolean fallback,
|
||||
Map<String, List<Entry>> 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<String> 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<Entry> 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<String, Long> 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<String, String> mapping = new LinkedHashMap<>();
|
||||
Map<String, String> info = new LinkedHashMap<>();
|
||||
boolean fallback = true;
|
||||
if (iniPath != null) {
|
||||
byte[] ini = read(source, zipped, iniPath);
|
||||
if (ini != null) fallback = parseIni(ini, mapping, info);
|
||||
}
|
||||
|
||||
Map<String, List<Entry>> 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<String, String> mapping, Map<String, String> 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<String, List<Entry>> fromMapping(Map<String, String> mapping, Map<String, Long> files) {
|
||||
Map<String, List<Entry>> entries = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, String> 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<String, List<Entry>> byConvention(Map<String, Long> files) {
|
||||
Map<String, List<Entry>> 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<Entry> 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<String, Long> 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<String, Long> listZip(File file) {
|
||||
if (!file.getName().toLowerCase(Locale.ROOT).endsWith(".zip")) return null;
|
||||
Map<String, Long> 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<String, Long> listDirectory(File dir) {
|
||||
Map<String, Long> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<IconPack> findAll(File userDirectory, String extraDirectory) {
|
||||
Map<String, IconPack> 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<IconPack> 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<IconPack> 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<IconPack> packs) {
|
||||
return select(packs, DEFAULT_ID);
|
||||
}
|
||||
}
|
||||
@@ -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<SoundPack> findAll(File userDirectory, String extraDirectory) {
|
||||
Map<String, SoundPack> 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<File> searchRoots(File userDirectory, String extraDirectory) {
|
||||
List<File> 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<File> existing = new ArrayList<>();
|
||||
for (File root : roots) {
|
||||
if (root.isDirectory()) existing.add(root);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, String> entries) throws Exception {
|
||||
File file = dir.resolve(name).toFile();
|
||||
try (OutputStream out = Files.newOutputStream(file.toPath());
|
||||
ZipOutputStream zip = new ZipOutputStream(out)) {
|
||||
for (Map.Entry<String, String> 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", "<svg/>",
|
||||
"disconnect.svg", "<svg>disconnect</svg>",
|
||||
"channel_green.png", "png bytes",
|
||||
"emoticons/smile.svg", "<svg/>")));
|
||||
assertNotNull(pack);
|
||||
|
||||
assertEquals("test_pack.zip", pack.id());
|
||||
assertEquals("Test Icons", pack.name());
|
||||
assertFalse(pack.fallsBack());
|
||||
|
||||
assertArrayEquals("<svg/>".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("<svg>disconnect</svg>".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", "<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"), "<svg/>");
|
||||
|
||||
IconPack pack = IconPack.load(unpacked.toFile());
|
||||
assertNotNull(pack);
|
||||
assertEquals("My Icons", pack.name());
|
||||
assertArrayEquals("<svg/>".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));
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
<maven.compiler.release>26</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<ts3j.version>1.0.3</ts3j.version>
|
||||
<jsvg.version>2.1.0</jsvg.version>
|
||||
<surefire.version>3.5.6</surefire.version>
|
||||
</properties>
|
||||
|
||||
@@ -44,6 +45,11 @@
|
||||
<artifactId>ts3j</artifactId>
|
||||
<version>${ts3j.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.weisj</groupId>
|
||||
<artifactId>jsvg</artifactId>
|
||||
<version>${jsvg.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ts3client</groupId>
|
||||
<artifactId>ts3-client-core</artifactId>
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
<groupId>com.github.manevolent</groupId>
|
||||
<artifactId>ts3j</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.weisj</groupId>
|
||||
<artifactId>jsvg</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
@@ -66,6 +70,14 @@
|
||||
<include>**</include>
|
||||
</includes>
|
||||
</filter>
|
||||
<!-- JSVG resolves its node types by service loader/reflection,
|
||||
which minimizeJar cannot follow. -->
|
||||
<filter>
|
||||
<artifact>com.github.weisj:jsvg</artifact>
|
||||
<includes>
|
||||
<include>**</include>
|
||||
</includes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<Object> packCombo = new JComboBox<>();
|
||||
private final JLabel packInfo = new JLabel();
|
||||
private final JTextField packDirField;
|
||||
private final DefaultListModel<String> previewModel = new DefaultListModel<>();
|
||||
private final JList<String> 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);
|
||||
}
|
||||
}
|
||||
167
ts3-client/swing/src/main/java/com/ts3client/ui/IconTheme.java
Normal file
167
ts3-client/swing/src/main/java/com/ts3client/ui/IconTheme.java
Normal file
@@ -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}.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>Rendering is cached per key and size: vector art is rasterised at the size it
|
||||
* is asked for, bitmaps are scaled from the closest resolution the pack ships.
|
||||
*/
|
||||
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<String, ImageIcon> cache = new ConcurrentHashMap<>();
|
||||
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
private volatile List<IconPack> 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<IconPack> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ public final class SettingsDialog extends JDialog {
|
||||
private final Runnable onApply;
|
||||
|
||||
private NotificationsPanel notificationsPanel;
|
||||
private IconPackPanel iconPackPanel;
|
||||
|
||||
private JComboBox<AudioDevices.Device> inputCombo;
|
||||
private JComboBox<AudioDevices.Device> 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) {
|
||||
|
||||
Reference in New Issue
Block a user