Compare commits

...

10 Commits

Author SHA1 Message Date
97148cfe9a Move a client into your own channel from its context menu
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 21:30:12 +02:00
6dee5106c7 Open text chat by double-clicking a client
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 14:48:54 +00:00
9bb1ec7060 Open connection info by middle-clicking a client
The dialog now disposes when its window button is used, too: hiding it
left the one-second refresh timer polling the server for a window
nobody could see again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:39:19 +00:00
69003ced9a Close the client's dialogs with Escape
Only the option-pane prompts closed on Escape, because Swing installs
that binding on the option pane itself. Dialogs.closeOnEscape puts the
same window-wide binding on a dialog's root pane, routed to whatever
that dialog does when its Close or Cancel button is pressed — reverting
the live-previewing settings, or flushing the bookmark being edited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:36:17 +00:00
0f8de796ea Kick and ban clients from the context menu
The client menu gains "Kick Client from Channel", "Kick Client from
Server" and "Ban Client", each asking for a reason first. Kicks use a
shared prompt that enforces the protocol's 40-character limit; the ban
dialog adds a duration, whose unit dropdown ends in "Permanent" — the
zero-length ban the server understands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:29:21 +00:00
d37c6c9ba9 Name the server node and stop it collapsing
The tree's top row showed the placeholder name, since nothing ever read the
real one; take it from initserver, which ts3j now keeps.

There is only ever one top-level row, so its expand control bought nothing and
cost horizontal space in a narrow view. Drop it, ignore collapse requests on
the root, and tighten the per-level indent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:15:32 +00:00
46206e3047 Show and control channel subscriptions
Track the subscription state from the notifychannelsubscribed events, draw
subscribed and unsubscribed channels apart with the pack's icons (and the
built-in glyph as a hollow cone), and offer subscribe/unsubscribe entries —
channel and family — in the channel context menu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:57:25 +00:00
245ae5ecc5 Add an away button with global actions and message presets
The toolbar gains an away toggle with a drop-down: it toggles away on the
current server with no message, while the menu carries the global actions
("Set Globally Away", "Set Globally Away Status"), the saved presets and
their editor. Presets live in ~/.ts3jclient/away.properties and are
managed in a list with add/remove and double-click renaming.

Clients in the channel tree now show their away message in brackets after
the nickname instead of their primary server group.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:40:19 +00:00
25a78f52fe Show our own mute, away and commander state right away
The server sends notifyclientupdated to the other clients only, so a
clientupdate of our own never came back to us and our row in the tree kept
its old icons until something else refreshed it. Apply the change to the
local model as we send it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:46:55 +00:00
676e95023e 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>
2026-08-14 12:46:39 +00:00
36 changed files with 2350 additions and 222 deletions

View File

@@ -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 - Sounds are mixed onto one playback line, so overlapping events never fight over
the device; pack volume is separate from the voice volume. 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 ## Requirements
- Java 26+ (developed/tested on Temurin 26) - Java 26+ (developed/tested on Temurin 26)
- The native Opus library on the system: - The native Opus library on the system:
@@ -133,6 +148,9 @@ core/ com.ts3client
│ ├── VoiceOutput voice-packet playback sink │ ├── VoiceOutput voice-packet playback sink
│ ├── OpusParameters live-tunable encoder settings │ ├── OpusParameters live-tunable encoder settings
│ └── SpeechDetector feature-based speech-probability VAD (Automatic/Hybrid) │ └── 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 ├── sound
│ ├── SoundEvent catalogue of actions (TeamSpeak's own event ids) │ ├── SoundEvent catalogue of actions (TeamSpeak's own event ids)
│ ├── SoundPack a pack folder + its settings.ini mapping │ ├── SoundPack a pack folder + its settings.ini mapping
@@ -165,12 +183,14 @@ swing/ com.ts3client
├── ChatPanel chat log + input ├── ChatPanel chat log + input
├── SettingsDialog audio + VAD options with live meter ├── SettingsDialog audio + VAD options with live meter
├── NotificationsPanel sound pack + per-action sound/important configuration ├── NotificationsPanel sound pack + per-action sound/important configuration
├── IconPackPanel icon pack chooser + icon viewer (Options → Design)
├── ConnectDialog connect form ├── ConnectDialog connect form
├── BookmarksDialog manage saved servers ├── BookmarksDialog manage saved servers
├── IdentitiesDialog manage identities (new/import/export/improve) ├── IdentitiesDialog manage identities (new/import/export/improve)
├── IdentityChooser identity drop-down shared by connect/bookmark forms ├── IdentityChooser identity drop-down shared by connect/bookmark forms
├── LevelMeter dBFS meter with threshold marker ├── 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 └── Theme palette + fonts
``` ```
@@ -178,7 +198,6 @@ swing/ com.ts3client
- The **Automatic/Hybrid** VAD uses a lightweight energy/spectral detector rather - The **Automatic/Hybrid** VAD uses a lightweight energy/spectral detector rather
than the WebRTC GMM model the official client ships; it is intentionally than the WebRTC GMM model the official client ships; it is intentionally
dependency-free and reusable in the core library. 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. - Whisper is received/played but not yet **sendable** from the UI.
- Playback decodes streams as mono; stereo music-bot audio is down-mixed. - 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 - Push-to-talk is captured via Swing key events, so it only works while the app

View File

@@ -0,0 +1,89 @@
package com.ts3client.config;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
/**
* Persistent list of away-message presets, stored alongside the settings file.
* Frontend-agnostic: no UI dependencies.
*/
public final class AwayMessages {
private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient");
private static final File FILE = new File(DIR, "away.properties");
private final List<String> entries = new ArrayList<>();
public List<String> all() {
return entries;
}
public void add(String message) {
entries.add(message == null ? "" : message);
}
public void set(int index, String message) {
if (index >= 0 && index < entries.size()) entries.set(index, message == null ? "" : message);
}
public void remove(int index) {
if (index >= 0 && index < entries.size()) entries.remove(index);
}
public static AwayMessages load() {
AwayMessages m = new AwayMessages();
if (!FILE.isFile()) return m.withDefaults();
Properties p = new Properties();
try (FileInputStream in = new FileInputStream(FILE)) {
p.load(in);
} catch (Exception e) {
return m.withDefaults();
}
int count = parseInt(p.getProperty("count"), 0);
for (int i = 0; i < count; i++) {
String text = p.getProperty("message." + i);
if (text != null && !text.isBlank()) m.entries.add(text);
}
return m;
}
public void save() {
Properties p = new Properties();
p.setProperty("count", Integer.toString(entries.size()));
for (int i = 0; i < entries.size(); i++) {
p.setProperty("message." + i, entries.get(i));
}
try {
if (!DIR.isDirectory()) {
//noinspection ResultOfMethodCallIgnored
DIR.mkdirs();
}
try (FileOutputStream out = new FileOutputStream(FILE)) {
p.store(out, "TS3J client away messages");
}
} catch (Exception ignored) {
}
}
/** First run: seed the presets a fresh client offers. */
private AwayMessages withDefaults() {
entries.add("Away from keyboard");
entries.add("Be right back");
entries.add("Lunch");
entries.add("Busy");
return this;
}
private static int parseInt(String v, int def) {
if (v == null) return def;
try {
return Integer.parseInt(v.trim());
} catch (NumberFormatException e) {
return def;
}
}
}

View File

@@ -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;
}
}

View File

@@ -99,6 +99,12 @@ public final class Settings {
public double soundVolume = 1.0; public double soundVolume = 1.0;
/** Extra folder to look for sound packs in, on top of the well-known locations. */ /** Extra folder to look for sound packs in, on top of the well-known locations. */
public String soundPackDir = ""; 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. */ /** Which actions make a sound, and which ones are important enough to survive muting. */
public final NotificationSettings notifications = new NotificationSettings(); public final NotificationSettings notifications = new NotificationSettings();
@@ -169,6 +175,8 @@ public final class Settings {
soundPack = props.getProperty("soundPack", soundPack); soundPack = props.getProperty("soundPack", soundPack);
soundVolume = parseD(props.getProperty("soundVolume"), soundVolume); soundVolume = parseD(props.getProperty("soundVolume"), soundVolume);
soundPackDir = props.getProperty("soundPackDir", soundPackDir); soundPackDir = props.getProperty("soundPackDir", soundPackDir);
iconPack = props.getProperty("iconPack", iconPack);
iconPackDir = props.getProperty("iconPackDir", iconPackDir);
notifications.load(props); notifications.load(props);
} }
@@ -201,6 +209,8 @@ public final class Settings {
props.setProperty("soundPack", soundPack); props.setProperty("soundPack", soundPack);
props.setProperty("soundVolume", Double.toString(soundVolume)); props.setProperty("soundVolume", Double.toString(soundVolume));
props.setProperty("soundPackDir", soundPackDir); props.setProperty("soundPackDir", soundPackDir);
props.setProperty("iconPack", iconPack);
props.setProperty("iconPackDir", iconPackDir);
notifications.store(props); notifications.store(props);
} }

View 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;
}
}

View File

@@ -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);
}
}

View File

@@ -17,6 +17,8 @@ public final class ChannelNode {
public int maxClients = -1; public int maxClients = -1;
/** Id of the channel's custom icon in the server's file repository, or 0 for none. */ /** Id of the channel's custom icon in the server's file repository, or 0 for none. */
public long iconId; public long iconId;
/** Whether the server sends us this channel's client list; set from the subscription events. */
public boolean subscribed;
/** Populated when the tree is rebuilt. */ /** Populated when the tree is rebuilt. */
public final List<ChannelNode> children = new ArrayList<>(); public final List<ChannelNode> children = new ArrayList<>();
@@ -26,4 +28,35 @@ public final class ChannelNode {
this.id = id; this.id = id;
this.name = name; this.name = name;
} }
/** @return whether this channel or any channel below it is subscribed */
public boolean anySubscribed() {
if (subscribed) return true;
for (ChannelNode child : children) {
if (child.anySubscribed()) return true;
}
return false;
}
/** @return whether this channel and every channel below it is subscribed */
public boolean allSubscribed() {
if (!subscribed) return false;
for (ChannelNode child : children) {
if (!child.allSubscribed()) return false;
}
return true;
}
/** @return this channel's id, followed by those of the channels below it when {@code family} */
public List<Integer> familyIds(boolean family) {
List<Integer> ids = new ArrayList<>();
collectIds(family, ids);
return ids;
}
private void collectIds(boolean family, List<Integer> into) {
into.add(id);
if (!family) return;
for (ChannelNode child : children) child.collectIds(true, into);
}
} }

View File

@@ -22,6 +22,8 @@ public final class ClientEntry {
public boolean inputMuted; // microphone muted (client_input_muted) public boolean inputMuted; // microphone muted (client_input_muted)
public boolean outputMuted; // speakers muted / deafened (client_output_muted) public boolean outputMuted; // speakers muted / deafened (client_output_muted)
public boolean away; public boolean away;
/** The message published with the away state, empty when there is none. */
public String awayMessage = "";
public boolean channelCommander; public boolean channelCommander;
public boolean self; public boolean self;

View File

@@ -2,6 +2,7 @@ package com.ts3client.net;
import com.github.manevolent.ts3j.api.Channel; import com.github.manevolent.ts3j.api.Channel;
import com.github.manevolent.ts3j.api.Client; import com.github.manevolent.ts3j.api.Client;
import com.github.manevolent.ts3j.command.MultiCommand;
import com.github.manevolent.ts3j.command.SingleCommand; import com.github.manevolent.ts3j.command.SingleCommand;
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter; import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
import com.github.manevolent.ts3j.event.*; import com.github.manevolent.ts3j.event.*;
@@ -24,6 +25,9 @@ import com.ts3client.sound.SoundNotifier;
import java.io.File; import java.io.File;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -38,6 +42,10 @@ public final class TeamspeakConnection implements TS3Listener {
/** Upper bound for a downloaded group icon; anything larger is not an icon. */ /** Upper bound for a downloaded group icon; anything larger is not an icon. */
private static final int MAX_ICON_BYTES = 1024 * 1024; private static final int MAX_ICON_BYTES = 1024 * 1024;
/** The protocol's reason ids for the two flavours of <b>clientkick</b>. */
private static final int REASON_KICK_CHANNEL = 4;
private static final int REASON_KICK_SERVER = 5;
/** Shortest gap between two "you are talking while muted" reminders. */ /** Shortest gap between two "you are talking while muted" reminders. */
private static final long MUTED_TALK_COOLDOWN_NANOS = 5_000_000_000L; private static final long MUTED_TALK_COOLDOWN_NANOS = 5_000_000_000L;
@@ -334,6 +342,8 @@ public final class TeamspeakConnection implements TS3Listener {
} catch (Exception e) { } catch (Exception e) {
ui.onStatus("Limited subscription: " + rootMessage(e)); ui.onStatus("Limited subscription: " + rootMessage(e));
} }
// The name comes with initserver; servergetvariables never reports it.
model.setServerName(client.getServerName());
try { try {
for (Channel ch : client.listChannels()) { for (Channel ch : client.listChannels()) {
model.putChannel(ch.getId(), ch.getName(), ch.getParentChannelId(), ch.getOrder()); model.putChannel(ch.getId(), ch.getName(), ch.getParentChannelId(), ch.getOrder());
@@ -361,6 +371,7 @@ public final class TeamspeakConnection implements TS3Listener {
e.inputMuted = cl.isInputMuted(); e.inputMuted = cl.isInputMuted();
e.outputMuted = cl.isOutputMuted(); e.outputMuted = cl.isOutputMuted();
e.away = cl.isAway(); e.away = cl.isAway();
e.awayMessage = orEmpty(cl.get("client_away_message"));
e.uniqueId = cl.getUniqueIdentifier(); e.uniqueId = cl.getUniqueIdentifier();
e.serverGroupIds = cl.getServerGroups(); e.serverGroupIds = cl.getServerGroups();
e.channelGroupId = cl.getChannelGroupId(); e.channelGroupId = cl.getChannelGroupId();
@@ -472,8 +483,16 @@ public final class TeamspeakConnection implements TS3Listener {
} }
private void pushSelfFlags() { private void pushSelfFlags() {
if (microphone == null || playback == null) return;
boolean micMuted = microphone.isMuted();
boolean deaf = playback.isDeafened();
updateSelf(self -> {
self.inputMuted = micMuted;
self.outputMuted = deaf;
});
// Best-effort: publish input/output muted flags to the server so others see them. // Best-effort: publish input/output muted flags to the server so others see them.
if (client == null || !connected || microphone == null || playback == null) return; if (client == null || !connected) return;
try { try {
java.util.Map<String, String> props = new java.util.HashMap<>(); java.util.Map<String, String> props = new java.util.HashMap<>();
props.put("client_input_muted", microphone.isMuted() ? "1" : "0"); props.put("client_input_muted", microphone.isMuted() ? "1" : "0");
@@ -505,6 +524,48 @@ public final class TeamspeakConnection implements TS3Listener {
}, "ts3j-move-client").start(); }, "ts3j-move-client").start();
} }
/** Kicks a client out of its channel, back into the server's default one. */
public void kickFromChannel(int clientId, String reason) {
kick(clientId, REASON_KICK_CHANNEL, reason, "Could not kick client from the channel: ");
}
/** Kicks a client off the server entirely. */
public void kickFromServer(int clientId, String reason) {
kick(clientId, REASON_KICK_SERVER, reason, "Could not kick client from the server: ");
}
private void kick(int clientId, int reasonId, String reason, String errorPrefix) {
new Thread(() -> {
try {
SingleCommand cmd = new SingleCommand("clientkick", ProtocolRole.CLIENT);
cmd.add(new CommandSingleParameter("clid", Integer.toString(clientId)));
cmd.add(new CommandSingleParameter("reasonid", Integer.toString(reasonId)));
if (reason != null && !reason.isEmpty()) {
cmd.add(new CommandSingleParameter("reasonmsg", reason));
}
client.executeCommand(cmd).complete();
} catch (Exception e) {
error(errorPrefix + rootMessage(e));
}
}, "ts3j-kick").start();
}
/**
* Bans a client from the server.
*
* @param seconds how long the ban lasts, or 0 for a permanent one
*/
public void banClient(int clientId, long seconds, String reason) {
new Thread(() -> {
try {
client.banClient(clientId, seconds <= 0 ? null : (int) Math.min(seconds, Integer.MAX_VALUE),
reason == null || reason.isEmpty() ? null : reason);
} catch (Exception e) {
error("Could not ban client: " + rootMessage(e));
}
}, "ts3j-ban").start();
}
/** /**
* Re-parents and repositions a channel. * Re-parents and repositions a channel.
* *
@@ -525,6 +586,28 @@ public final class TeamspeakConnection implements TS3Listener {
}, "ts3j-move-channel").start(); }, "ts3j-move-channel").start();
} }
/**
* Subscribes to (or unsubscribes from) a set of channels in one command. The model
* is left alone: the server answers with the subscription events that update it.
*/
public void setChannelsSubscribed(Collection<Integer> channelIds, boolean subscribed) {
if (channelIds.isEmpty()) return;
List<Integer> ids = List.copyOf(channelIds);
String name = subscribed ? "channelsubscribe" : "channelunsubscribe";
new Thread(() -> {
try {
List<SingleCommand> parts = new ArrayList<>(ids.size());
for (int id : ids) {
parts.add(new SingleCommand(name, ProtocolRole.CLIENT,
new CommandSingleParameter("cid", Integer.toString(id))));
}
client.executeCommand(new MultiCommand(name, ProtocolRole.CLIENT, parts)).complete();
} catch (Exception e) {
error((subscribed ? "Could not subscribe: " : "Could not unsubscribe: ") + rootMessage(e));
}
}, "ts3j-channel-subscribe").start();
}
public void sendChannelMessage(String text) { public void sendChannelMessage(String text) {
new Thread(() -> { new Thread(() -> {
try { try {
@@ -573,6 +656,10 @@ public final class TeamspeakConnection implements TS3Listener {
public void setAway(boolean away, String message) { public void setAway(boolean away, String message) {
sound(away ? SoundEvent.STATUS_SET_AWAY : SoundEvent.STATUS_SET_PRESENT); sound(away ? SoundEvent.STATUS_SET_AWAY : SoundEvent.STATUS_SET_PRESENT);
updateSelf(self -> {
self.away = away;
self.awayMessage = away && message != null ? message : "";
});
selfUpdate(cmd -> { selfUpdate(cmd -> {
cmd.add(new CommandSingleParameter("client_away", away ? "1" : "0")); cmd.add(new CommandSingleParameter("client_away", away ? "1" : "0"));
cmd.add(new CommandSingleParameter("client_away_message", away && message != null ? message : "")); cmd.add(new CommandSingleParameter("client_away_message", away && message != null ? message : ""));
@@ -580,11 +667,24 @@ public final class TeamspeakConnection implements TS3Listener {
} }
public void setChannelCommander(boolean commander) { public void setChannelCommander(boolean commander) {
updateSelf(self -> self.channelCommander = commander);
selfUpdate(cmd -> selfUpdate(cmd ->
cmd.add(new CommandSingleParameter("client_is_channel_commander", commander ? "1" : "0")), cmd.add(new CommandSingleParameter("client_is_channel_commander", commander ? "1" : "0")),
"Channel commander update failed"); "Channel commander update failed");
} }
/**
* Applies a change of our own state to the local model. The server sends
* {@code notifyclientupdated} to the <em>other</em> clients only, so without this
* our own row would keep its old icons until something else refreshed it.
*/
private void updateSelf(java.util.function.Consumer<ClientEntry> change) {
ClientEntry self = model.getClient(selfClientId);
if (self == null) return;
change.accept(self);
ui.onModelChanged();
}
/** Sends a {@code clientupdate} for the local client on a background thread. */ /** Sends a {@code clientupdate} for the local client on a background thread. */
private void selfUpdate(java.util.function.Consumer<SingleCommand> fill, String errorLabel) { private void selfUpdate(java.util.function.Consumer<SingleCommand> fill, String errorLabel) {
new Thread(() -> { new Thread(() -> {
@@ -626,6 +726,7 @@ public final class TeamspeakConnection implements TS3Listener {
c.inputMuted = e.isClientInputMuted(); c.inputMuted = e.isClientInputMuted();
c.outputMuted = e.isClientOutputMuted(); c.outputMuted = e.isClientOutputMuted();
c.away = e.isClientAway(); c.away = e.isClientAway();
c.awayMessage = orEmpty(e.get("client_away_message"));
c.uniqueId = orEmpty(e.getUniqueClientIdentifier()); c.uniqueId = orEmpty(e.getUniqueClientIdentifier());
c.serverGroupIds = parseIntList(e.getClientServerGroups()); c.serverGroupIds = parseIntList(e.getClientServerGroups());
c.channelGroupId = e.getClientChannelGroupId(); c.channelGroupId = e.getClientChannelGroupId();
@@ -792,7 +893,14 @@ public final class TeamspeakConnection implements TS3Listener {
if (renamed) c.nickname = e.get("client_nickname"); if (renamed) c.nickname = e.get("client_nickname");
if (has(e, "client_input_muted")) c.inputMuted = e.getBoolean("client_input_muted"); if (has(e, "client_input_muted")) c.inputMuted = e.getBoolean("client_input_muted");
if (has(e, "client_output_muted")) c.outputMuted = e.getBoolean("client_output_muted"); if (has(e, "client_output_muted")) c.outputMuted = e.getBoolean("client_output_muted");
if (has(e, "client_away")) c.away = e.getBoolean("client_away"); if (has(e, "client_away")) {
c.away = e.getBoolean("client_away");
// Both fields travel together, so an absent message here means "no message"
// — which `has` cannot tell from "not reported".
c.awayMessage = c.away ? orEmpty(e.get("client_away_message")) : "";
} else if (has(e, "client_away_message")) {
c.awayMessage = e.get("client_away_message");
}
if (has(e, "client_talk_power")) c.talkPower = e.getInt("client_talk_power"); if (has(e, "client_talk_power")) c.talkPower = e.getInt("client_talk_power");
if (has(e, "client_is_channel_commander")) if (has(e, "client_is_channel_commander"))
c.channelCommander = e.getBoolean("client_is_channel_commander"); c.channelCommander = e.getBoolean("client_is_channel_commander");
@@ -892,6 +1000,23 @@ public final class TeamspeakConnection implements TS3Listener {
} }
} }
@Override
public void onChannelSubscribed(ChannelSubscribedEvent e) {
setSubscribed(safeInt(e, "cid"), true);
}
@Override
public void onChannelUnsubscribed(ChannelUnsubscribedEvent e) {
setSubscribed(safeInt(e, "cid"), false);
}
private void setSubscribed(int cid, boolean subscribed) {
ChannelNode ch = model.getChannel(cid);
if (ch == null || ch.subscribed == subscribed) return;
ch.subscribed = subscribed;
ui.onModelChanged();
}
@Override @Override
public void onServerEdit(ServerEditedEvent e) { public void onServerEdit(ServerEditedEvent e) {
if (has(e, "virtualserver_name")) model.setServerName(e.get("virtualserver_name")); if (has(e, "virtualserver_name")) model.setServerName(e.get("virtualserver_name"));

View File

@@ -1,5 +1,7 @@
package com.ts3client.sound; package com.ts3client.sound;
import com.ts3client.config.InstallDirs;
import java.io.File; import java.io.File;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
@@ -15,10 +17,6 @@ import java.util.Map;
*/ */
public final class SoundPacks { 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() { private SoundPacks() {
} }
@@ -32,7 +30,7 @@ public final class SoundPacks {
*/ */
public static List<SoundPack> findAll(File userDirectory, String extraDirectory) { public static List<SoundPack> findAll(File userDirectory, String extraDirectory) {
Map<String, SoundPack> byId = new LinkedHashMap<>(); 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); File[] entries = root.listFiles(File::isDirectory);
if (entries == null) continue; if (entries == null) continue;
for (File dir : entries) { for (File dir : entries) {
@@ -52,38 +50,4 @@ public final class SoundPacks {
} }
return packs.isEmpty() ? null : packs.get(0); 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;
}
} }

View File

@@ -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));
}
}

View File

@@ -22,6 +22,7 @@
<maven.compiler.release>26</maven.compiler.release> <maven.compiler.release>26</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<ts3j.version>1.0.3</ts3j.version> <ts3j.version>1.0.3</ts3j.version>
<jsvg.version>2.1.0</jsvg.version>
<surefire.version>3.5.6</surefire.version> <surefire.version>3.5.6</surefire.version>
</properties> </properties>
@@ -44,6 +45,11 @@
<artifactId>ts3j</artifactId> <artifactId>ts3j</artifactId>
<version>${ts3j.version}</version> <version>${ts3j.version}</version>
</dependency> </dependency>
<dependency>
<groupId>com.github.weisj</groupId>
<artifactId>jsvg</artifactId>
<version>${jsvg.version}</version>
</dependency>
<dependency> <dependency>
<groupId>com.ts3client</groupId> <groupId>com.ts3client</groupId>
<artifactId>ts3-client-core</artifactId> <artifactId>ts3-client-core</artifactId>

View File

@@ -31,6 +31,10 @@
<groupId>com.github.manevolent</groupId> <groupId>com.github.manevolent</groupId>
<artifactId>ts3j</artifactId> <artifactId>ts3j</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.github.weisj</groupId>
<artifactId>jsvg</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -66,6 +70,14 @@
<include>**</include> <include>**</include>
</includes> </includes>
</filter> </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> <filter>
<artifact>*:*</artifact> <artifact>*:*</artifact>
<excludes> <excludes>

View File

@@ -1,6 +1,7 @@
package com.ts3client; package com.ts3client;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import com.ts3client.ui.IconTheme;
import com.ts3client.ui.MainFrame; import com.ts3client.ui.MainFrame;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
@@ -27,6 +28,7 @@ public final class Main {
} catch (Exception ignored) { } catch (Exception ignored) {
// fall back to cross-platform L&F // fall back to cross-platform L&F
} }
IconTheme.get().reload(settings);
new MainFrame(settings).setVisible(true); new MainFrame(settings).setVisible(true);
}); });
} }

View File

@@ -0,0 +1,148 @@
package com.ts3client.ui;
import com.ts3client.config.AwayMessages;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.ListSelectionModel;
import javax.swing.table.AbstractTableModel;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.Frame;
/**
* Editor for the away-message presets: a plain list of entries that are renamed
* by double-clicking them, plus Add and Remove.
*
* <p>Backed by a one-column table because that is what gives Swing's list look
* inline editing for free. Every change is written straight through to the
* {@link AwayMessages} store.
*/
public final class AwayMessagesDialog extends JDialog {
private final AwayMessages messages;
private final Runnable onChanged;
private final Model model = new Model();
private final JTable table = new JTable(model);
public AwayMessagesDialog(Frame owner, AwayMessages messages, Runnable onChanged) {
super(owner, "Away Message Presets", true);
this.messages = messages;
this.onChanged = onChanged;
table.setTableHeader(null);
table.setShowGrid(false);
table.setFillsViewportHeight(true);
table.setRowHeight(Math.max(20, table.getRowHeight()));
table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
// Single click selects, double click starts editing — like a renameable list.
table.putClientProperty("JTable.autoStartsEdit", Boolean.FALSE);
JScrollPane scroll = new JScrollPane(table);
scroll.setBorder(BorderFactory.createEmptyBorder(8, 8, 4, 8));
getContentPane().setLayout(new BorderLayout());
getContentPane().add(scroll, BorderLayout.CENTER);
getContentPane().add(buildButtons(), BorderLayout.SOUTH);
Dialogs.closeOnEscape(this, this::closeDialog);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(new Dimension(360, 300));
setLocationRelativeTo(owner);
}
private JPanel buildButtons() {
JPanel buttons = new JPanel();
buttons.setLayout(new BoxLayout(buttons, BoxLayout.X_AXIS));
buttons.setBorder(BorderFactory.createEmptyBorder(0, 8, 8, 8));
addButton(buttons, "Add", this::addEntry);
addButton(buttons, "Remove", this::removeSelected);
buttons.add(Box.createHorizontalGlue());
addButton(buttons, "Close", this::closeDialog);
return buttons;
}
private void addButton(JPanel panel, String text, Runnable action) {
JButton b = new JButton(text);
b.addActionListener(e -> action.run());
panel.add(b);
panel.add(Box.createHorizontalStrut(4));
}
private void addEntry() {
stopEditing();
messages.add("New away message");
int row = messages.all().size() - 1;
model.fireTableRowsInserted(row, row);
table.setRowSelectionInterval(row, row);
table.scrollRectToVisible(table.getCellRect(row, 0, true));
persist();
table.editCellAt(row, 0);
if (table.getEditorComponent() != null) table.getEditorComponent().requestFocusInWindow();
}
private void removeSelected() {
stopEditing();
int row = table.getSelectedRow();
if (row < 0) return;
messages.remove(row);
model.fireTableRowsDeleted(row, row);
if (!messages.all().isEmpty()) {
int next = Math.min(row, messages.all().size() - 1);
table.setRowSelectionInterval(next, next);
}
persist();
}
private void closeDialog() {
stopEditing();
dispose();
}
/** Flushes a cell that is still being edited so its text is not lost. */
private void stopEditing() {
if (table.isEditing()) table.getCellEditor().stopCellEditing();
}
private void persist() {
messages.save();
if (onChanged != null) onChanged.run();
}
private final class Model extends AbstractTableModel {
@Override
public int getRowCount() {
return messages.all().size();
}
@Override
public int getColumnCount() {
return 1;
}
@Override
public Object getValueAt(int row, int column) {
return messages.all().get(row);
}
@Override
public boolean isCellEditable(int row, int column) {
return true;
}
@Override
public void setValueAt(Object value, int row, int column) {
String text = value == null ? "" : value.toString().trim();
if (text.isEmpty()) return; // an emptied entry keeps its old text
messages.set(row, text);
persist();
}
}
}

View File

@@ -0,0 +1,113 @@
package com.ts3client.ui;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JDialog;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.JTextField;
import javax.swing.SpinnerNumberModel;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Frame;
/**
* Asks for a ban's reason and duration, like the official client's ban dialog:
* either a number of seconds, minutes, hours or days, or permanent.
*/
final class BanDialog extends JDialog {
/** The longest reason the protocol carries with a ban. */
private static final int REASON_LIMIT = 80;
/**
* The duration units the dialog offers, with the seconds each one is worth.
* The last entry is the permanent ban, which has no length and so takes no amount.
*/
private static final String[] UNIT_NAMES = {"Seconds", "Minutes", "Hours", "Days", "Permanent"};
private static final long[] UNIT_SECONDS = {1, 60, 3600, 86400, 0};
private final JTextField reasonField = ReasonDialog.reasonField(REASON_LIMIT);
private final JSpinner amount = new JSpinner(new SpinnerNumberModel(30, 1, 999999, 1));
private final JComboBox<String> unit = new JComboBox<>(UNIT_NAMES);
private boolean confirmed;
BanDialog(Frame owner, String nickname) {
super(owner, "Ban Client", true);
unit.setSelectedIndex(1); // minutes
unit.addActionListener(e -> amount.setEnabled(!isPermanent()));
JPanel form = new JPanel();
form.setLayout(new BoxLayout(form, BoxLayout.Y_AXIS));
form.setBorder(BorderFactory.createEmptyBorder(12, 12, 8, 12));
form.add(ReasonDialog.label("Ban " + nickname + " from the server."));
form.add(Box.createVerticalStrut(8));
form.add(ReasonDialog.label("Reason:"));
form.add(reasonField);
form.add(Box.createVerticalStrut(8));
form.add(ReasonDialog.label("Duration:"));
form.add(durationRow());
getContentPane().setLayout(new BorderLayout());
getContentPane().add(form, BorderLayout.CENTER);
getContentPane().add(buttons(), BorderLayout.SOUTH);
ReasonDialog.focusWhenShown(reasonField);
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
pack();
setResizable(false);
setLocationRelativeTo(owner);
}
private JPanel durationRow() {
JPanel row = new JPanel();
row.setLayout(new BoxLayout(row, BoxLayout.X_AXIS));
row.setAlignmentX(Component.LEFT_ALIGNMENT);
row.add(amount);
row.add(Box.createHorizontalStrut(4));
row.add(unit);
row.add(Box.createHorizontalGlue());
ReasonDialog.fixHeight(row);
return row;
}
private JPanel buttons() {
JPanel panel = new JPanel(new BorderLayout());
JPanel right = new JPanel();
JButton ok = new JButton("Ban");
JButton cancel = new JButton("Cancel");
ok.addActionListener(e -> {
confirmed = true;
dispose();
});
cancel.addActionListener(e -> dispose());
right.add(ok);
right.add(cancel);
panel.add(right, BorderLayout.EAST);
getRootPane().setDefaultButton(ok);
return panel;
}
private boolean isPermanent() {
return UNIT_SECONDS[unit.getSelectedIndex()] == 0;
}
boolean isConfirmed() {
return confirmed;
}
String getReason() {
return reasonField.getText().trim();
}
/** The ban's length in seconds, or 0 for a permanent ban. */
long getSeconds() {
return ((Number) amount.getValue()).longValue() * UNIT_SECONDS[unit.getSelectedIndex()];
}
}

View File

@@ -88,6 +88,7 @@ public final class BookmarksDialog extends JDialog {
if (!listModel.isEmpty()) list.setSelectedIndex(0); if (!listModel.isEmpty()) list.setSelectedIndex(0);
else showBookmark(null); else showBookmark(null);
Dialogs.closeOnEscape(this, this::closeDialog);
setDefaultCloseOperation(DISPOSE_ON_CLOSE); setDefaultCloseOperation(DISPOSE_ON_CLOSE);
addWindowListener(new java.awt.event.WindowAdapter() { addWindowListener(new java.awt.event.WindowAdapter() {
@Override @Override

View File

@@ -16,13 +16,51 @@ final class ChannelMenu {
static JPopupMenu build(ChannelNode channel, ServerTreePanel.Actions actions) { static JPopupMenu build(ChannelNode channel, ServerTreePanel.Actions actions) {
JPopupMenu menu = new JPopupMenu(); 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)); join.addActionListener(a -> actions.joinChannel(channel.id));
menu.add(join); menu.add(join);
menu.addSeparator(); menu.addSeparator();
JMenuItem files = new JMenuItem("Browse files"); addSubscriptionItems(menu, channel, actions);
JMenuItem files = new JMenuItem("Browse files", Icons.of("FILETRANSFER"));
files.addActionListener(a -> actions.browseFiles(channel)); files.addActionListener(a -> actions.browseFiles(channel));
menu.add(files); menu.add(files);
return menu; return menu;
} }
/**
* The subscription entries, offered the way the official client does: the channel
* itself toggles, and the family entries appear only where they would do something.
*/
private static void addSubscriptionItems(JPopupMenu menu, ChannelNode channel,
ServerTreePanel.Actions actions) {
if (channel.subscribed) {
add(menu, "Unsubscribe from Channel",
() -> actions.setChannelSubscribed(channel, false, false),
"UNSUBSCRIBE_FROM_CHANNEL");
} else {
add(menu, "Subscribe to Channel",
() -> actions.setChannelSubscribed(channel, false, true),
"SUBSCRIBE_TO_CHANNEL");
}
if (!channel.children.isEmpty()) {
if (!channel.allSubscribed()) {
add(menu, "Subscribe to Channel Family",
() -> actions.setChannelSubscribed(channel, true, true),
"SUBSCRIBE_TO_CHANNEL_FAMILY", "SUBSCRIBE_TO_ALL_CHANNELS");
}
if (channel.anySubscribed()) {
add(menu, "Unsubscribe from Channel Family",
() -> actions.setChannelSubscribed(channel, true, false),
"UNSUBSCRIBE_FROM_CHANNEL_FAMILY", "UNSUBSCRIBE_FROM_ALL_CHANNELS");
}
}
menu.addSeparator();
}
/** Adds an item, icon-less when the pack has none of the given keys. */
private static void add(JPopupMenu menu, String text, Runnable action, String... iconKeys) {
JMenuItem item = new JMenuItem(text, Icons.ofAny(iconKeys));
item.addActionListener(a -> action.run());
menu.add(item);
}
} }

View File

@@ -17,26 +17,42 @@ final class ClientMenu {
static JPopupMenu build(ClientEntry client, boolean self, ServerTreePanel.Actions actions) { static JPopupMenu build(ClientEntry client, boolean self, ServerTreePanel.Actions actions) {
JPopupMenu menu = new JPopupMenu(); JPopupMenu menu = new JPopupMenu();
if (!self) { 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)); pm.addActionListener(a -> actions.openPrivateChat(client));
menu.add(pm); menu.add(pm);
JMenuItem poke = new JMenuItem("Poke"); JMenuItem poke = new JMenuItem("Poke", Icons.of("POKE"));
poke.addActionListener(a -> actions.pokeClient(client)); poke.addActionListener(a -> actions.pokeClient(client));
menu.add(poke); menu.add(poke);
menu.addSeparator(); menu.addSeparator();
JMenuItem kickChannel = new JMenuItem("Kick Client from Channel", Icons.of("KICK_FROM_CHANNEL"));
kickChannel.addActionListener(a -> actions.kickClientFromChannel(client));
menu.add(kickChannel);
JMenuItem kickServer = new JMenuItem("Kick Client from Server", Icons.of("KICK_FROM_SERVER"));
kickServer.addActionListener(a -> actions.kickClientFromServer(client));
menu.add(kickServer);
JMenuItem ban = new JMenuItem("Ban Client", Icons.of("BAN_CLIENT"));
ban.addActionListener(a -> actions.banClient(client));
menu.add(ban);
boolean muted = actions.isClientLocallyMuted(client.id); 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)); mute.addActionListener(a -> actions.toggleClientMute(client));
menu.add(mute); menu.add(mute);
} else { } else {
JMenuItem me = new JMenuItem("This is you"); JMenuItem me = new JMenuItem("This is you", Icons.of("PLAYER_OFF"));
me.setEnabled(false); me.setEnabled(false);
menu.add(me); menu.add(me);
} }
menu.addSeparator(); menu.addSeparator();
JMenuItem info = new JMenuItem("Connection Info"); JMenuItem info = new JMenuItem("Connection Info", Icons.of("INFO"));
info.addActionListener(a -> actions.showConnectionInfo(client)); info.addActionListener(a -> actions.showConnectionInfo(client));
menu.add(info); menu.add(info);
if (!self) {
menu.addSeparator();
JMenuItem moveHere = new JMenuItem("Move Client to own Channel", Icons.of("MOVE_CLIENT_TO_OWN_CHANNEL"));
moveHere.addActionListener(a -> actions.moveClientToOwnChannel(client));
menu.add(moveHere);
}
return menu; return menu;
} }
} }

View File

@@ -80,6 +80,7 @@ public final class ConnectDialog extends JDialog {
getContentPane().add(form, BorderLayout.CENTER); getContentPane().add(form, BorderLayout.CENTER);
getContentPane().add(buttons, BorderLayout.SOUTH); getContentPane().add(buttons, BorderLayout.SOUTH);
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE); setDefaultCloseOperation(DISPOSE_ON_CLOSE);
pack(); pack();
setMinimumSize(new Dimension(340, getHeight())); setMinimumSize(new Dimension(340, getHeight()));

View File

@@ -70,6 +70,9 @@ public final class ConnectionInfoDialog extends JDialog {
content.add(buildButtons(), BorderLayout.SOUTH); content.add(buildButtons(), BorderLayout.SOUTH);
setContentPane(content); setContentPane(content);
Dialogs.closeOnEscape(this);
// Disposing is what stops the refresh timer, so the window button must not merely hide.
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
pack(); pack();
setLocationRelativeTo(owner); setLocationRelativeTo(owner);

View File

@@ -0,0 +1,44 @@
package com.ts3client.ui;
import javax.swing.AbstractAction;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JRootPane;
import javax.swing.KeyStroke;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
/** Behaviour shared by the client's dialogs. */
final class Dialogs {
private Dialogs() {
}
/**
* Makes Escape close a dialog, the way Swing's own option panes do.
*
* <p>The binding lives on the root pane's window-wide input map, so it fires
* wherever the focus sits — but only where nothing nearer to the focused
* component claims Escape first, which is what leaves a table's or combo
* box's own "cancel the edit" behaviour intact.
*/
static void closeOnEscape(JDialog dialog) {
closeOnEscape(dialog, dialog::dispose);
}
/**
* Binds Escape to the dialog's own way of closing, for dialogs that have to
* tidy up (or discard edits) on the way out.
*/
static void closeOnEscape(JDialog dialog, Runnable close) {
JRootPane root = dialog.getRootPane();
root.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "ts3-close");
root.getActionMap().put("ts3-close", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
close.run();
}
});
}
}

View File

@@ -0,0 +1,115 @@
package com.ts3client.ui;
import javax.swing.BorderFactory;
import javax.swing.Icon;
import javax.swing.JButton;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JToggleButton;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Insets;
import java.awt.RenderingHints;
import java.awt.event.ActionListener;
import java.util.function.Supplier;
/**
* A toolbar toggle button with a small arrow next to it that opens a menu:
* clicking the button toggles, clicking the arrow offers the related actions.
*
* <p>The menu is built on every click by the supplier, so it can reflect the
* current state (checked items, the presets that exist right now).
*/
public final class DropDownToggleButton extends JPanel {
private final JToggleButton button = new JToggleButton();
private final JButton arrow = new JButton(new ArrowIcon());
public DropDownToggleButton(Icon icon, String tooltip, Supplier<JPopupMenu> menu) {
super(new BorderLayout());
setOpaque(false);
button.setIcon(icon);
button.setToolTipText(tooltip);
arrow.setToolTipText(tooltip);
arrow.setMargin(new Insets(0, 2, 0, 2));
arrow.setFocusable(false);
arrow.addActionListener(e -> {
JPopupMenu popup = menu.get();
if (popup != null) popup.show(this, 0, getHeight());
});
// Keep the whole control the size of a plain toolbar button: the arrow's
// width comes out of the toggle half instead of being added to it.
Dimension plain = button.getPreferredSize();
button.setPreferredSize(new Dimension(
Math.max(icon.getIconWidth() + 8, plain.width - arrow.getPreferredSize().width),
plain.height));
add(button, BorderLayout.CENTER);
add(arrow, BorderLayout.EAST);
}
/** Listener for the toggle half only; the arrow half opens the menu instead. */
public void addActionListener(ActionListener l) {
button.addActionListener(l);
}
public boolean isSelected() {
return button.isSelected();
}
public void setSelected(boolean selected) {
button.setSelected(selected);
}
public void setIcon(Icon icon) {
button.setIcon(icon);
}
@Override
public void setEnabled(boolean enabled) {
super.setEnabled(enabled);
button.setEnabled(enabled);
arrow.setEnabled(enabled);
}
/** Disables the toggle half on its own, leaving the menu reachable. */
public void setToggleEnabled(boolean enabled) {
button.setEnabled(enabled);
}
@Override
public Dimension getMaximumSize() {
return getPreferredSize(); // keep the toolbar from stretching us
}
/** The downward triangle on the menu half of the button. */
private static final class ArrowIcon implements Icon {
@Override
public void paintIcon(Component c, Graphics g0, int x, int y) {
Graphics2D g = (Graphics2D) g0.create();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setColor(c.isEnabled() ? Color.DARK_GRAY : Color.GRAY);
int[] xs = {x, x + getIconWidth(), x + getIconWidth() / 2};
int[] ys = {y, y, y + getIconHeight()};
g.fillPolygon(xs, ys, 3);
g.dispose();
}
@Override
public int getIconWidth() {
return 7;
}
@Override
public int getIconHeight() {
return 4;
}
}
}

View File

@@ -52,9 +52,9 @@ public final class FileBrowserDialog extends JDialog {
private final FileTableModel tableModel = new FileTableModel(); private final FileTableModel tableModel = new FileTableModel();
private final JTable table = new JTable(tableModel); private final JTable table = new JTable(tableModel);
private final JLabel pathLabel = new JLabel("/"); private final JLabel pathLabel = new JLabel("/");
private final JButton upButton = new JButton("Up"); private final JButton upButton = new JButton("Up", Icons.of("FILE_UP"));
private final JButton downloadButton = new JButton("Download"); private final JButton downloadButton = new JButton("Download", Icons.of("DOWNLOAD"));
private final JButton deleteButton = new JButton("Delete"); private final JButton deleteButton = new JButton("Delete", Icons.of("DELETE"));
private final JPanel transfersPanel = new JPanel(); private final JPanel transfersPanel = new JPanel();
private volatile String currentPath = "/"; private volatile String currentPath = "/";
@@ -88,11 +88,11 @@ public final class FileBrowserDialog extends JDialog {
bar.setBackground(Theme.WINDOW_BG); bar.setBackground(Theme.WINDOW_BG);
upButton.addActionListener(e -> navigateUp()); upButton.addActionListener(e -> navigateUp());
JButton refresh = new JButton("Refresh"); JButton refresh = new JButton("Refresh", Icons.of("FILE_REFRESH"));
refresh.addActionListener(e -> 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()); mkdir.addActionListener(e -> createDirectory());
JButton upload = new JButton("Upload…"); JButton upload = new JButton("Upload…", Icons.of("UPLOAD"));
upload.addActionListener(e -> chooseUpload()); upload.addActionListener(e -> chooseUpload());
downloadButton.addActionListener(e -> downloadSelected()); downloadButton.addActionListener(e -> downloadSelected());
deleteButton.addActionListener(e -> deleteSelected()); deleteButton.addActionListener(e -> deleteSelected());
@@ -122,6 +122,7 @@ public final class FileBrowserDialog extends JDialog {
table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
table.setFillsViewportHeight(true); table.setFillsViewportHeight(true);
table.getColumnModel().getColumn(0).setPreferredWidth(300); table.getColumnModel().getColumn(0).setPreferredWidth(300);
table.getColumnModel().getColumn(0).setCellRenderer(new NameRenderer());
table.getColumnModel().getColumn(1).setPreferredWidth(90); table.getColumnModel().getColumn(1).setPreferredWidth(90);
table.getColumnModel().getColumn(2).setPreferredWidth(70); table.getColumnModel().getColumn(2).setPreferredWidth(70);
table.getColumnModel().getColumn(3).setPreferredWidth(150); 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 ---- // ---- formatting ----
private static String formatBytes(long bytes) { private static String formatBytes(long bytes) {

View File

@@ -37,6 +37,15 @@ public final class GroupIcons {
} }
public ImageIcon icon(long iconId) { 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); ImageIcon cached = decoded.get(iconId);
if (cached != null) return cached; if (cached != null) return cached;

View File

@@ -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);
}
}

View 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&times;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;
}
}
}

View File

@@ -8,13 +8,17 @@ import java.awt.RenderingHints;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
/** /**
* Programmatically drawn vector icons (no external image assets), so the client * The user interface's icons, taken from the active {@link IconTheme} pack and
* is fully self-contained. All icons are rendered at 16&times;16 with a small * named by TeamSpeak's icon keys.
* cache. *
* <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 { public final class Icons {
private static final int SZ = 16; private static final int SZ = IconTheme.SIZE;
private Icons() { private Icons() {
} }
@@ -23,6 +27,49 @@ public final class Icons {
void paint(Graphics2D g); 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 for the first key it has, or {@code null} when it has none
* of them — packs differ in which variants they ship
*/
public static ImageIcon ofAny(String... keys) {
for (String key : keys) {
ImageIcon icon = IconTheme.icon(key);
if (icon != null) return icon;
}
return null;
}
/** @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);
}
/**
* Like {@link #themed(String, Painter)}, but tries the keys in turn — packs need not
* ship every variant (none of TeamSpeak's own has a subscribed password channel).
*/
private static ImageIcon themed(String[] keys, Painter fallback) {
for (String key : keys) {
ImageIcon icon = IconTheme.icon(key, SZ);
if (icon != null) return icon;
}
return make(fallback);
}
private static ImageIcon make(Painter p) { private static ImageIcon make(Painter p) {
BufferedImage img = new BufferedImage(SZ, SZ, BufferedImage.TYPE_INT_ARGB); BufferedImage img = new BufferedImage(SZ, SZ, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics(); Graphics2D g = img.createGraphics();
@@ -36,68 +83,160 @@ public final class Icons {
// ---- tree icons ---- // ---- tree icons ----
public static ImageIcon server() { public static ImageIcon server() {
return make(g -> { return themed("SERVER_GREEN", Icons::paintServer);
}
public static ImageIcon channel(boolean subscribed) {
return themed(keys("CHANNEL_GREEN", subscribed),
g -> paintChannel(g, new Color(0x3E7CB1), false, subscribed));
}
/** A channel nobody can join without its password. */
public static ImageIcon channelLocked(boolean subscribed) {
return themed(keys("CHANNEL_PRIVATE", subscribed),
g -> paintChannel(g, new Color(0x8A6D3B), true, subscribed));
}
/** A channel that has reached its client limit. */
public static ImageIcon channelFull(boolean subscribed) {
return themed(keys("CHANNEL_RED", subscribed),
g -> paintChannel(g, new Color(0xA53F3F), false, subscribed));
}
/** The pack keys to try for a channel icon, most specific first. */
private static String[] keys(String base, boolean subscribed) {
return subscribed ? new String[]{base + "_SUBSCRIBED", base} : new String[]{base};
}
// ---- client status icons ----
public static ImageIcon clientIdle() {
return themed("PLAYER_OFF", g -> paintPerson(g, Theme.IDLE_CLIENT));
}
public static ImageIcon clientTalking() {
return themed("PLAYER_ON", g -> paintPerson(g, Theme.TALKING));
}
public static ImageIcon clientAway() {
return themed("AWAY", g -> paintPerson(g, Theme.AWAY));
}
/** 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 themed("INPUT_MUTED", Icons::paintMicMuted);
}
public static ImageIcon speakerMuted() {
return themed("OUTPUT_MUTED", Icons::paintSpeakerMuted);
}
// ---- toolbar / action icons ----
public static ImageIcon connect() {
return themed("CONNECT", IconTheme.TOOLBAR_SIZE, Icons::paintConnect);
}
public static ImageIcon disconnect() {
return themed("DISCONNECT", IconTheme.TOOLBAR_SIZE, Icons::paintDisconnect);
}
public static ImageIcon mic() {
return themed("CAPTURE", IconTheme.TOOLBAR_SIZE, Icons::paintMic);
}
public static ImageIcon speaker() {
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 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);
}
/** The toolbar's away marker. */
public static ImageIcon away() {
return themed("AWAY", IconTheme.TOOLBAR_SIZE, g -> paintPerson(g, Theme.AWAY));
}
public static ImageIcon settings() {
return themed("SETTINGS", IconTheme.TOOLBAR_SIZE, Icons::paintSettings);
}
public static ImageIcon app() {
return make(Icons::paintApp);
}
// ---- built-in painters ----
private static void paintServer(Graphics2D g) {
g.setColor(new Color(0x2C6EA5)); g.setColor(new Color(0x2C6EA5));
g.fillRoundRect(2, 3, 12, 4, 2, 2); g.fillRoundRect(2, 3, 12, 4, 2, 2);
g.fillRoundRect(2, 9, 12, 4, 2, 2); g.fillRoundRect(2, 9, 12, 4, 2, 2);
g.setColor(new Color(0x9FD0F0)); g.setColor(new Color(0x9FD0F0));
g.fillOval(4, 4, 2, 2); g.fillOval(4, 4, 2, 2);
g.fillOval(4, 10, 2, 2); g.fillOval(4, 10, 2, 2);
});
} }
public static ImageIcon channel() { /**
return channelPainted(new Color(0x3E7CB1), false); * The speaker-cone glyph. An unsubscribed channel is drawn hollow and without the
} * sound waves, so the two states stay apart even without an icon pack.
*/
public static ImageIcon channelLocked() { private static void paintChannel(Graphics2D g, Color c, boolean lock, boolean subscribed) {
return channelPainted(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[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10}; int[] ys = {6, 3, 13, 10};
g.setColor(c);
g.setStroke(new BasicStroke(subscribed ? 1.6f : 1.2f));
if (subscribed) {
g.fillRect(2, 6, 3, 4);
g.fillPolygon(xs, ys, 4); g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(1.4f)); g.setStroke(new BasicStroke(1.4f));
g.drawArc(9, 5, 4, 6, -60, 120); g.drawArc(9, 5, 4, 6, -60, 120);
} else {
g.drawRect(2, 6, 3, 4);
g.drawPolygon(xs, ys, 4);
}
if (lock) { if (lock) {
g.setColor(new Color(0xB8860B)); g.setColor(new Color(0xB8860B));
g.fillRoundRect(10, 9, 5, 5, 1, 1); g.fillRoundRect(10, 9, 5, 5, 1, 1);
g.setColor(Color.WHITE); g.setColor(Color.WHITE);
g.fillRect(12, 10, 1, 2); g.fillRect(12, 10, 1, 2);
} }
});
} }
// ---- client status icons ---- private static void paintPerson(Graphics2D g, Color c) {
public static ImageIcon clientIdle() {
return person(Theme.IDLE_CLIENT);
}
public static ImageIcon clientTalking() {
return person(Theme.TALKING);
}
public static ImageIcon clientAway() {
return person(Theme.AWAY);
}
private static ImageIcon person(Color c) {
return make(g -> {
g.setColor(c); g.setColor(c);
g.fillOval(5, 2, 6, 6); // head g.fillOval(5, 2, 6, 6); // head
g.fillRoundRect(3, 9, 10, 6, 4, 4); // shoulders g.fillRoundRect(3, 9, 10, 6, 4, 4); // shoulders
});
} }
public static ImageIcon micMuted() { private static void paintMicMuted(Graphics2D g) {
return make(g -> {
g.setColor(Theme.MUTED); g.setColor(Theme.MUTED);
g.fillRoundRect(6, 2, 4, 7, 2, 2); g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f)); g.setStroke(new BasicStroke(1.4f));
@@ -105,11 +244,9 @@ public final class Icons {
g.drawLine(8, 12, 8, 14); g.drawLine(8, 12, 8, 14);
g.setStroke(new BasicStroke(2f)); g.setStroke(new BasicStroke(2f));
g.drawLine(2, 2, 14, 14); // slash g.drawLine(2, 2, 14, 14); // slash
});
} }
public static ImageIcon speakerMuted() { private static void paintSpeakerMuted(Graphics2D g) {
return make(g -> {
g.setColor(Theme.MUTED); g.setColor(Theme.MUTED);
g.fillRect(2, 6, 3, 4); g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5}; int[] xs = {5, 9, 9, 5};
@@ -117,45 +254,35 @@ public final class Icons {
g.fillPolygon(xs, ys, 4); g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(2f)); g.setStroke(new BasicStroke(2f));
g.drawLine(2, 2, 14, 14); g.drawLine(2, 2, 14, 14);
});
} }
// ---- toolbar / action icons ---- private static void paintConnect(Graphics2D g) {
public static ImageIcon connect() {
return make(g -> {
g.setColor(new Color(0x2E8B57)); g.setColor(new Color(0x2E8B57));
g.setStroke(new BasicStroke(2f)); g.setStroke(new BasicStroke(2f));
g.drawLine(3, 8, 8, 8); g.drawLine(3, 8, 8, 8);
g.fillRoundRect(8, 5, 5, 6, 2, 2); g.fillRoundRect(8, 5, 5, 6, 2, 2);
g.drawLine(10, 3, 10, 5); g.drawLine(10, 3, 10, 5);
g.drawLine(12, 3, 12, 5); g.drawLine(12, 3, 12, 5);
});
} }
public static ImageIcon disconnect() { private static void paintDisconnect(Graphics2D g) {
return make(g -> {
g.setColor(Theme.MUTED); g.setColor(Theme.MUTED);
g.setStroke(new BasicStroke(2f)); g.setStroke(new BasicStroke(2f));
g.drawLine(3, 8, 8, 8); g.drawLine(3, 8, 8, 8);
g.fillRoundRect(8, 5, 5, 6, 2, 2); g.fillRoundRect(8, 5, 5, 6, 2, 2);
g.drawLine(2, 3, 6, 13); g.drawLine(2, 3, 6, 13);
});
} }
public static ImageIcon mic() { private static void paintMic(Graphics2D g) {
return make(g -> {
g.setColor(new Color(0x37474F)); g.setColor(new Color(0x37474F));
g.fillRoundRect(6, 2, 4, 7, 2, 2); g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f)); g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140); g.drawArc(4, 6, 8, 6, 200, 140);
g.drawLine(8, 12, 8, 14); g.drawLine(8, 12, 8, 14);
g.drawLine(6, 14, 10, 14); g.drawLine(6, 14, 10, 14);
});
} }
public static ImageIcon speaker() { private static void paintSpeaker(Graphics2D g) {
return make(g -> {
g.setColor(new Color(0x37474F)); g.setColor(new Color(0x37474F));
g.fillRect(2, 6, 3, 4); g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5}; int[] xs = {5, 9, 9, 5};
@@ -163,12 +290,9 @@ public final class Icons {
g.fillPolygon(xs, ys, 4); g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(1.4f)); g.setStroke(new BasicStroke(1.4f));
g.drawArc(9, 5, 4, 6, -60, 120); g.drawArc(9, 5, 4, 6, -60, 120);
});
} }
/** Marks the server that currently owns the microphone. */ private static void paintMicActive(Graphics2D g) {
public static ImageIcon micActive() {
return make(g -> {
g.setColor(Theme.TALKING); g.setColor(Theme.TALKING);
g.fillOval(1, 1, 14, 14); g.fillOval(1, 1, 14, 14);
g.setColor(Color.WHITE); g.setColor(Color.WHITE);
@@ -176,11 +300,9 @@ public final class Icons {
g.setStroke(new BasicStroke(1.4f)); g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140); g.drawArc(4, 6, 8, 6, 200, 140);
g.drawLine(8, 11, 8, 13); g.drawLine(8, 11, 8, 13);
});
} }
public static ImageIcon settings() { private static void paintSettings(Graphics2D g) {
return make(g -> {
g.setColor(new Color(0x37474F)); g.setColor(new Color(0x37474F));
g.setStroke(new BasicStroke(2f)); g.setStroke(new BasicStroke(2f));
g.drawOval(5, 5, 6, 6); g.drawOval(5, 5, 6, 6);
@@ -192,11 +314,9 @@ public final class Icons {
int y2 = (int) (8 + Math.sin(r) * 7); int y2 = (int) (8 + Math.sin(r) * 7);
g.drawLine(x1, y1, x2, y2); g.drawLine(x1, y1, x2, y2);
} }
});
} }
public static ImageIcon app() { private static void paintApp(Graphics2D g) {
return make(g -> {
g.setColor(Theme.ACCENT); g.setColor(Theme.ACCENT);
g.fillRoundRect(1, 1, 14, 14, 4, 4); g.fillRoundRect(1, 1, 14, 14, 4, 4);
g.setColor(Color.WHITE); g.setColor(Color.WHITE);
@@ -204,6 +324,5 @@ public final class Icons {
g.drawArc(4, 5, 8, 8, 30, 120); g.drawArc(4, 5, 8, 8, 30, 120);
g.drawArc(2, 3, 12, 12, 30, 120); g.drawArc(2, 3, 12, 12, 30, 120);
g.fillOval(7, 9, 2, 2); g.fillOval(7, 9, 2, 2);
});
} }
} }

View File

@@ -95,6 +95,7 @@ public final class IdentitiesDialog extends JDialog {
reload(); reload();
if (!listModel.isEmpty()) list.setSelectedIndex(0); if (!listModel.isEmpty()) list.setSelectedIndex(0);
Dialogs.closeOnEscape(this);
setDefaultCloseOperation(DISPOSE_ON_CLOSE); setDefaultCloseOperation(DISPOSE_ON_CLOSE);
pack(); pack();
setMinimumSize(new Dimension(640, 320)); setMinimumSize(new Dimension(640, 320));

View File

@@ -2,6 +2,7 @@ package com.ts3client.ui;
import com.ts3client.audio.AudioBackend; import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.desktop.DesktopAudioBackend; import com.ts3client.audio.desktop.DesktopAudioBackend;
import com.ts3client.config.AwayMessages;
import com.ts3client.config.Bookmark; import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks; import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityStore; import com.ts3client.config.IdentityStore;
@@ -21,6 +22,7 @@ import javax.swing.JMenuBar;
import javax.swing.JMenuItem; import javax.swing.JMenuItem;
import javax.swing.JOptionPane; import javax.swing.JOptionPane;
import javax.swing.JPanel; import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JTextField; import javax.swing.JTextField;
import javax.swing.JToggleButton; import javax.swing.JToggleButton;
import javax.swing.JToolBar; import javax.swing.JToolBar;
@@ -50,6 +52,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private final Settings settings; private final Settings settings;
private final Bookmarks bookmarks = Bookmarks.load(); private final Bookmarks bookmarks = Bookmarks.load();
private final AwayMessages awayMessages = AwayMessages.load();
private final IdentityStore identities; private final IdentityStore identities;
private final AudioBackend audio = new DesktopAudioBackend(); private final AudioBackend audio = new DesktopAudioBackend();
/** Sound pack playback, shared by every connection. */ /** Sound pack playback, shared by every connection. */
@@ -66,17 +69,20 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private JMenu bookmarksMenu; private JMenu bookmarksMenu;
private JCheckBoxMenuItem awayItem; private JCheckBoxMenuItem awayItem;
private JMenuItem awayStatusItem;
private JCheckBoxMenuItem commanderItem; private JCheckBoxMenuItem commanderItem;
private javax.swing.Timer statusTimer; private javax.swing.Timer statusTimer;
private final JLabel statusLabel = new JLabel("Not connected"); private final JLabel statusLabel = new JLabel("Not connected");
private final JLabel codecLabel = new JLabel(); private final JLabel codecLabel = new JLabel();
private JToolBar toolbar;
private JButton connectButton; private JButton connectButton;
private JButton disconnectButton; private JButton disconnectButton;
private JToggleButton activeButton; private JToggleButton activeButton;
private JToggleButton micButton; private JToggleButton micButton;
private JToggleButton speakerButton; private JToggleButton speakerButton;
private DropDownToggleButton awayButton;
private boolean pttPressed; private boolean pttPressed;
@@ -108,7 +114,10 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
setJMenuBar(buildMenuBar()); 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(tabPane, BorderLayout.CENTER);
add(buildStatusBar(), BorderLayout.SOUTH); add(buildStatusBar(), BorderLayout.SOUTH);
@@ -140,15 +149,15 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
JMenuBar bar = new JMenuBar(); JMenuBar bar = new JMenuBar();
JMenu connections = new JMenu("Connections"); 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.setAccelerator(KeyStroke.getKeyStroke("control S"));
connect.addActionListener(e -> showConnectDialog()); connect.addActionListener(e -> showConnectDialog());
JMenuItem disconnect = new JMenuItem("Disconnect"); JMenuItem disconnect = new JMenuItem("Disconnect", Icons.of("DISCONNECT"));
disconnect.addActionListener(e -> doDisconnect()); 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.setAccelerator(KeyStroke.getKeyStroke("control W"));
closeTab.addActionListener(e -> closeTab(selected)); closeTab.addActionListener(e -> closeTab(selected));
JMenuItem quit = new JMenuItem("Quit"); JMenuItem quit = new JMenuItem("Quit", Icons.of("QUIT"));
quit.addActionListener(e -> { quit.addActionListener(e -> {
shutdown(); shutdown();
System.exit(0); System.exit(0);
@@ -163,37 +172,40 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
rebuildBookmarksMenu(); rebuildBookmarksMenu();
JMenu self = new JMenu("Self"); 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()); 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()); deaf.addActionListener(e -> speakerButton.doClick());
awayItem = new JCheckBoxMenuItem("Away"); awayItem = new JCheckBoxMenuItem("Away", Icons.of("AWAY"), false);
awayItem.addActionListener(e -> toggleAway()); awayItem.addActionListener(e -> toggleAway());
commanderItem = new JCheckBoxMenuItem("Channel commander"); awayStatusItem = new JMenuItem("Set away status…", Icons.of("EDIT"));
awayStatusItem.addActionListener(e -> setAwayStatus());
commanderItem = new JCheckBoxMenuItem("Channel commander", Icons.of("CHANNEL_COMMANDER"), false);
commanderItem.addActionListener(e -> { commanderItem.addActionListener(e -> {
if (selected != null) selected.setCommander(commanderItem.isSelected()); 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()); nick.addActionListener(e -> changeNickname());
self.add(mute); self.add(mute);
self.add(deaf); self.add(deaf);
self.addSeparator(); self.addSeparator();
self.add(awayItem); self.add(awayItem);
self.add(awayStatusItem);
self.add(commanderItem); self.add(commanderItem);
self.addSeparator(); self.addSeparator();
self.add(nick); self.add(nick);
JMenu tools = new JMenu("Tools"); JMenu tools = new JMenu("Tools");
JMenuItem identitiesItem = new JMenuItem("Identities…"); JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER"));
identitiesItem.addActionListener(e -> showIdentities()); identitiesItem.addActionListener(e -> showIdentities());
JMenuItem options = new JMenuItem("Options…"); JMenuItem options = new JMenuItem("Options…", Icons.of("SETTINGS"));
options.addActionListener(e -> showSettings()); options.addActionListener(e -> showSettings());
tools.add(identitiesItem); tools.add(identitiesItem);
tools.addSeparator(); tools.addSeparator();
tools.add(options); tools.add(options);
JMenu help = new JMenu("Help"); JMenu help = new JMenu("Help");
JMenuItem about = new JMenuItem("About"); JMenuItem about = new JMenuItem("About", Icons.of("ABOUT"));
about.addActionListener(e -> showAbout()); about.addActionListener(e -> showAbout());
help.add(about); help.add(about);
@@ -208,14 +220,14 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private void rebuildBookmarksMenu() { private void rebuildBookmarksMenu() {
bookmarksMenu.removeAll(); bookmarksMenu.removeAll();
for (Bookmark b : bookmarks.all()) { 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)); item.addActionListener(e -> connectToBookmark(b));
bookmarksMenu.add(item); bookmarksMenu.add(item);
} }
if (!bookmarks.all().isEmpty()) bookmarksMenu.addSeparator(); 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()); 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, manage.addActionListener(e -> new BookmarksDialog(this, bookmarks, identities,
this::connectToBookmark, this::rebuildBookmarksMenu).setVisible(true)); this::connectToBookmark, this::rebuildBookmarksMenu).setVisible(true));
bookmarksMenu.add(addCurrent); bookmarksMenu.add(addCurrent);
@@ -259,6 +271,16 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
updateToolbar(); updateToolbar();
}); });
awayButton = new DropDownToggleButton(Icons.away(),
"Away on this server (the arrow offers the global actions and presets)",
this::buildAwayMenu);
awayButton.addActionListener(e -> {
if (selected == null) return;
// The plain toggle carries no message; the menu is where messages are chosen.
selected.setAway(awayButton.isSelected(), "");
updateToolbar();
});
JButton settingsButton = new JButton(Icons.settings()); JButton settingsButton = new JButton(Icons.settings());
settingsButton.setToolTipText("Options"); settingsButton.setToolTipText("Options");
settingsButton.addActionListener(e -> showSettings()); settingsButton.addActionListener(e -> showSettings());
@@ -269,12 +291,26 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
tb.add(activeButton); tb.add(activeButton);
tb.add(micButton); tb.add(micButton);
tb.add(speakerButton); tb.add(speakerButton);
tb.add(awayButton);
tb.addSeparator(); tb.addSeparator();
tb.add(settingsButton); tb.add(settingsButton);
tb.add(Box.createHorizontalGlue()); tb.add(Box.createHorizontalGlue());
return tb; 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() { private JPanel buildStatusBar() {
JPanel bar = new JPanel(new BorderLayout()); JPanel bar = new JPanel(new BorderLayout());
bar.setBackground(Theme.STATUS_BG); bar.setBackground(Theme.STATUS_BG);
@@ -478,18 +514,86 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
rebuildBookmarksMenu(); rebuildBookmarksMenu();
} }
/** The away button's drop-down: the global actions, the presets and their editor. */
private JPopupMenu buildAwayMenu() {
JPopupMenu menu = new JPopupMenu();
boolean anyConnected = tabs.stream().anyMatch(ServerTab::isConnected);
JCheckBoxMenuItem globally = new JCheckBoxMenuItem("Set Globally Away", Icons.of("AWAY"), isGloballyAway());
globally.setEnabled(anyConnected);
globally.addActionListener(e -> setAwayEverywhere(globally.isSelected(), null));
JMenuItem globalStatus = new JMenuItem("Set Globally Away Status…", Icons.of("EDIT"));
globalStatus.setEnabled(anyConnected);
globalStatus.addActionListener(e -> {
String message = askAwayMessage(currentAwayMessage());
if (message != null) setAwayEverywhere(true, message);
});
menu.add(globally);
menu.add(globalStatus);
menu.addSeparator();
for (String preset : awayMessages.all()) {
JMenuItem item = new JMenuItem(preset);
item.setEnabled(anyConnected);
item.addActionListener(e -> setAwayEverywhere(true, preset));
menu.add(item);
}
if (!awayMessages.all().isEmpty()) menu.addSeparator();
JMenuItem manage = new JMenuItem("Manage away messages…", Icons.of("EDIT"));
manage.addActionListener(e -> new AwayMessagesDialog(this, awayMessages, null).setVisible(true));
menu.add(manage);
return menu;
}
private void toggleAway() { private void toggleAway() {
if (selected == null) return; if (selected == null) return;
boolean away = awayItem.isSelected(); selected.setAway(awayItem.isSelected(), "");
String message = null; updateToolbar();
if (away) {
message = JOptionPane.showInputDialog(this, "Away message (optional):", "");
if (message == null) { // cancelled
awayItem.setSelected(false);
return;
} }
/** Sets the away message on the selected server only. */
private void setAwayStatus() {
if (selected == null) return;
String message = askAwayMessage(selected.awayMessage());
if (message == null) return;
selected.setAway(true, message);
updateToolbar();
} }
selected.setAway(away, message);
private void setAwayEverywhere(boolean away, String message) {
for (ServerTab tab : tabs) {
if (tab.isConnected()) tab.setAway(away, message);
}
updateToolbar();
}
/** @return true when every connected server is marked away (and there is one) */
private boolean isGloballyAway() {
boolean any = false;
for (ServerTab tab : tabs) {
if (!tab.isConnected()) continue;
any = true;
if (!tab.isAway()) return false;
}
return any;
}
/** The message to preload the prompt with: the selected tab's, else any set one. */
private String currentAwayMessage() {
if (selected != null && !selected.awayMessage().isEmpty()) return selected.awayMessage();
for (ServerTab tab : tabs) {
if (tab.isConnected() && !tab.awayMessage().isEmpty()) return tab.awayMessage();
}
return "";
}
/** @return the entered message (possibly empty), or null when cancelled */
private String askAwayMessage(String initial) {
return (String) JOptionPane.showInputDialog(this, "Away message (optional):", "Away status",
JOptionPane.PLAIN_MESSAGE, Icons.of("AWAY"), null, initial);
} }
private void doDisconnect() { private void doDisconnect() {
@@ -569,16 +673,22 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
speakerButton.setEnabled(connected); speakerButton.setEnabled(connected);
activeButton.setEnabled(connected); activeButton.setEnabled(connected);
awayItem.setEnabled(connected); awayItem.setEnabled(connected);
awayStatusItem.setEnabled(connected);
// The menu's actions are global, so the arrow stays live while any server is up.
awayButton.setEnabled(tabs.stream().anyMatch(ServerTab::isConnected));
awayButton.setToggleEnabled(connected);
commanderItem.setEnabled(connected); commanderItem.setEnabled(connected);
boolean micMuted = connected && selected.isMicMuted(); boolean micMuted = connected && selected.isMicMuted();
boolean deaf = connected && selected.isDeafened(); boolean deaf = connected && selected.isDeafened();
micButton.setSelected(micMuted); micButton.setSelected(micMuted);
micButton.setIcon(micMuted ? Icons.micMuted() : Icons.mic()); micButton.setIcon(micMuted ? Icons.micMutedLarge() : Icons.mic());
speakerButton.setSelected(deaf); speakerButton.setSelected(deaf);
speakerButton.setIcon(deaf ? Icons.speakerMuted() : Icons.speaker()); speakerButton.setIcon(deaf ? Icons.speakerMutedLarge() : Icons.speaker());
activeButton.setSelected(selected != null && selected == micTab); activeButton.setSelected(selected != null && selected == micTab);
awayItem.setSelected(connected && selected.isAway()); boolean away = connected && selected.isAway();
awayItem.setSelected(away);
awayButton.setSelected(away);
commanderItem.setSelected(connected && selected.isCommander()); commanderItem.setSelected(connected && selected.isCommander());
} }

View File

@@ -0,0 +1,101 @@
package com.ts3client.ui;
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JTextField;
import javax.swing.event.AncestorEvent;
import javax.swing.event.AncestorListener;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.PlainDocument;
import java.awt.Component;
import java.awt.Dimension;
/**
* Asks for the reason text that goes with a moderation action. The server caps
* such messages, so the field refuses anything longer.
*/
final class ReasonDialog {
/** The longest reason the protocol carries with a kick. */
static final int KICK_REASON_LIMIT = 40;
private ReasonDialog() {
}
/** @return the reason (possibly empty), or {@code null} when the dialog was cancelled */
static String prompt(Component owner, String title, String message, int maxLength) {
JTextField field = reasonField(maxLength);
focusWhenShown(field);
JPanel panel = new JPanel();
panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
panel.add(label(message));
panel.add(field);
int result = JOptionPane.showConfirmDialog(owner, panel, title,
JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE);
return result == JOptionPane.OK_OPTION ? field.getText().trim() : null;
}
static JTextField reasonField(int maxLength) {
JTextField field = new JTextField(24);
field.setDocument(new LimitedDocument(maxLength));
field.setAlignmentX(Component.LEFT_ALIGNMENT);
fixHeight(field);
return field;
}
/** A left-aligned label with a little air below it, for use in a box layout. */
static JLabel label(String text) {
JLabel label = new JLabel(text);
label.setAlignmentX(Component.LEFT_ALIGNMENT);
label.setBorder(BorderFactory.createEmptyBorder(0, 0, 4, 0));
return label;
}
/** Keeps a component from stretching taller than it needs in a box layout. */
static void fixHeight(Component c) {
c.setMaximumSize(new Dimension(Integer.MAX_VALUE, c.getPreferredSize().height));
}
/** Puts the caret in a field as soon as its dialog appears. */
static void focusWhenShown(JComponent field) {
field.addAncestorListener(new AncestorListener() {
@Override
public void ancestorAdded(AncestorEvent event) {
field.requestFocusInWindow();
}
@Override
public void ancestorRemoved(AncestorEvent event) {
}
@Override
public void ancestorMoved(AncestorEvent event) {
}
});
}
/** A document that silently drops anything past its length limit. */
private static final class LimitedDocument extends PlainDocument {
private final int limit;
LimitedDocument(int limit) {
this.limit = limit;
}
@Override
public void insertString(int offset, String text, AttributeSet attrs) throws BadLocationException {
if (text == null) return;
int room = limit - getLength();
if (room <= 0) return;
super.insertString(offset, text.length() > room ? text.substring(0, room) : text, attrs);
}
}
}

View File

@@ -49,6 +49,8 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
private boolean micMuted; private boolean micMuted;
private boolean deafened; private boolean deafened;
private boolean away; private boolean away;
/** Away message currently published, empty when away carries no message. */
private String awayMessage = "";
private boolean commander; private boolean commander;
private Object currentSelection; private Object currentSelection;
@@ -143,6 +145,10 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
return away; return away;
} }
String awayMessage() {
return awayMessage;
}
boolean isCommander() { boolean isCommander() {
return commander; return commander;
} }
@@ -223,9 +229,16 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
conn.setMicrophoneActive(active); conn.setMicrophoneActive(active);
} }
/**
* @param message the away message, or null to keep the one already set — the
* message outlives coming back, so toggling away again restores it
*/
void setAway(boolean away, String message) { void setAway(boolean away, String message) {
this.away = away; this.away = away;
conn.setAway(away, message); if (message != null) this.awayMessage = message;
conn.setAway(away, awayMessage);
chatPanel.appendSystem(!away ? "No longer away."
: awayMessage.isEmpty() ? "Away." : "Away: " + awayMessage);
} }
void setCommander(boolean commander) { void setCommander(boolean commander) {
@@ -327,6 +340,32 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
if (msg != null) conn.poke(client.id, msg); if (msg != null) conn.poke(client.id, msg);
} }
@Override
public void kickClientFromChannel(ClientEntry client) {
String reason = kickReason("Kick Client from Channel", client);
if (reason != null) conn.kickFromChannel(client.id, reason);
}
@Override
public void kickClientFromServer(ClientEntry client) {
String reason = kickReason("Kick Client from Server", client);
if (reason != null) conn.kickFromServer(client.id, reason);
}
private String kickReason(String title, ClientEntry client) {
if (!conn.isConnected()) return null;
return ReasonDialog.prompt(host, title, "Reason for kicking " + client.nickname + ":",
ReasonDialog.KICK_REASON_LIMIT);
}
@Override
public void banClient(ClientEntry client) {
if (!conn.isConnected()) return;
BanDialog dialog = new BanDialog(host, client.nickname);
dialog.setVisible(true);
if (dialog.isConfirmed()) conn.banClient(client.id, dialog.getSeconds(), dialog.getReason());
}
@Override @Override
public void toggleClientMute(ClientEntry client) { public void toggleClientMute(ClientEntry client) {
if (conn.getPlayback() == null) return; if (conn.getPlayback() == null) return;
@@ -341,6 +380,18 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
new ConnectionInfoDialog(host, conn, client.id, client.nickname).setVisible(true); new ConnectionInfoDialog(host, conn, client.id, client.nickname).setVisible(true);
} }
@Override
public void moveClientToOwnChannel(ClientEntry client) {
if (!conn.isConnected() || client.id == conn.getSelfClientId()) return;
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
if (self != null) conn.moveClient(client.id, self.channelId, null);
}
@Override
public void setChannelSubscribed(ChannelNode channel, boolean family, boolean subscribed) {
if (conn.isConnected()) conn.setChannelsSubscribed(channel.familyIds(family), subscribed);
}
@Override @Override
public void browseFiles(ChannelNode channel) { public void browseFiles(ChannelNode channel) {
if (!conn.canTransferFiles()) return; if (!conn.canTransferFiles()) return;
@@ -394,6 +445,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
micMuted = false; micMuted = false;
deafened = false; deafened = false;
away = false; away = false;
awayMessage = "";
commander = false; commander = false;
chatPanel.setInputEnabled(true); chatPanel.setInputEnabled(true);
chatPanel.appendSystem("Connected."); chatPanel.appendSystem("Connected.");

View File

@@ -132,7 +132,7 @@ final class ServerTabPane extends JPanel {
cell.setOpaque(false); cell.setOpaque(false);
cell.setBorder(BorderFactory.createEmptyBorder(1, 0, 1, 0)); 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.setFont(Theme.UI_FONT);
label.setToolTipText(hasMic ? "Speaking on this server" : tab.status()); label.setToolTipText(hasMic ? "Speaking on this server" : tab.status());
// A label with a tooltip swallows mouse events, so select the tab explicitly. // A label with a tooltip swallows mouse events, so select the tab explicitly.

View File

@@ -14,6 +14,7 @@ import javax.swing.JTree;
import javax.swing.JViewport; import javax.swing.JViewport;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import javax.swing.TransferHandler; import javax.swing.TransferHandler;
import javax.swing.plaf.basic.BasicTreeUI;
import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.DefaultTreeModel; import javax.swing.tree.DefaultTreeModel;
import javax.swing.tree.DefaultTreeCellRenderer; import javax.swing.tree.DefaultTreeCellRenderer;
@@ -46,13 +47,29 @@ public final class ServerTreePanel extends JScrollPane {
void pokeClient(ClientEntry client); void pokeClient(ClientEntry client);
void kickClientFromChannel(ClientEntry client);
void kickClientFromServer(ClientEntry client);
void banClient(ClientEntry client);
void toggleClientMute(ClientEntry client); void toggleClientMute(ClientEntry client);
void showConnectionInfo(ClientEntry client); void showConnectionInfo(ClientEntry client);
/** Moves a client into the channel we are currently in. */
void moveClientToOwnChannel(ClientEntry client);
/** Open the file repository browser for a channel. */ /** Open the file repository browser for a channel. */
void browseFiles(ChannelNode channel); void browseFiles(ChannelNode channel);
/**
* Changes a channel's subscription.
*
* @param family whether the channels below it are included
*/
void setChannelSubscribed(ChannelNode channel, boolean family, boolean subscribed);
boolean isClientLocallyMuted(int clientId); boolean isClientLocallyMuted(int clientId);
/** A channel or client node was selected (or {@code null} when cleared). */ /** A channel or client node was selected (or {@code null} when cleared). */
@@ -90,7 +107,15 @@ public final class ServerTreePanel extends JScrollPane {
root.setUserObject("Not connected"); root.setUserObject("Not connected");
this.tree = new DropIndicatorTree(treeModel); this.tree = new DropIndicatorTree(treeModel);
tree.setRootVisible(true); tree.setRootVisible(true);
tree.setShowsRootHandles(true); // The server node is the only top-level row and always stays open, so it gets
// no expand control. Nesting is tightened too: horizontal space in this view
// is scarce and channel names are long.
tree.setShowsRootHandles(false);
if (tree.getUI() instanceof BasicTreeUI) {
BasicTreeUI ui = (BasicTreeUI) tree.getUI();
ui.setLeftChildIndent(4);
ui.setRightChildIndent(10);
}
tree.setRowHeight(20); tree.setRowHeight(20);
tree.setBackground(Theme.TREE_BG); tree.setBackground(Theme.TREE_BG);
tree.setFont(Theme.UI_FONT); tree.setFont(Theme.UI_FONT);
@@ -129,11 +154,15 @@ public final class ServerTreePanel extends JScrollPane {
@Override @Override
public void mouseClicked(MouseEvent e) { public void mouseClicked(MouseEvent e) {
if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2) {
Object obj = nodeAt(e); Object obj = nodeAt(e);
if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2) {
if (obj instanceof ChannelNode && !Spacers.isSpacer(((ChannelNode) obj).name)) { if (obj instanceof ChannelNode && !Spacers.isSpacer(((ChannelNode) obj).name)) {
actions.joinChannel(((ChannelNode) obj).id); actions.joinChannel(((ChannelNode) obj).id);
} else if (obj instanceof ClientEntry) {
actions.openPrivateChat((ClientEntry) obj);
} }
} else if (SwingUtilities.isMiddleMouseButton(e) && obj instanceof ClientEntry) {
actions.showConnectionInfo((ClientEntry) obj);
} }
} }
}); });
@@ -430,6 +459,13 @@ public final class ServerTreePanel extends JScrollPane {
super(model); super(model);
} }
/** Keeps the server row permanently open; collapsing it would hide everything. */
@Override
public void setExpandedState(TreePath path, boolean state) {
if (!state && path.getPathCount() == 1) return;
super.setExpandedState(path, state);
}
/** /**
* Widens every repaint request to the full visible width. Swing only asks for * Widens every repaint request to the full visible width. Swing only asks for
* the row rectangle, which stops short of the right-aligned icon strip and would * the row rectangle, which stops short of the right-aligned icon strip and would
@@ -583,18 +619,14 @@ public final class ServerTreePanel extends JScrollPane {
setFont(Theme.UI_FONT); setFont(Theme.UI_FONT);
} else { } else {
setText(c.name); setText(c.name);
setIcon(c.hasPassword ? Icons.channelLocked() : Icons.channel()); setIcon(iconFor(c));
setForeground(Theme.CHANNEL_TEXT); setForeground(Theme.CHANNEL_TEXT);
setFont(Theme.UI_BOLD); setFont(Theme.UI_BOLD);
} }
} else if (obj instanceof ClientEntry) { } else if (obj instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) obj; ClientEntry cl = (ClientEntry) obj;
String label = cl.nickname; String label = cl.nickname;
if (badgesOf(cl).isEmpty()) { if (cl.away && !cl.awayMessage.isEmpty()) label += " [" + cl.awayMessage + "]";
// No icons (yet): fall back to naming the primary group inline.
String primaryGroup = model.primaryServerGroupName(cl.serverGroupIds);
if (primaryGroup != null) label += " [" + primaryGroup + "]";
}
setText(label); setText(label);
setIcon(iconFor(cl)); setIcon(iconFor(cl));
setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT); setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT);
@@ -609,10 +641,21 @@ public final class ServerTreePanel extends JScrollPane {
return this; return this;
} }
private ImageIcon iconFor(ChannelNode c) {
if (c.hasPassword) return Icons.channelLocked(c.subscribed);
if (c.maxClients >= 0 && c.clients.size() >= c.maxClients) return Icons.channelFull(c.subscribed);
return Icons.channel(c.subscribed);
}
/** The client's state, in the order the official client gives them priority. */
private ImageIcon iconFor(ClientEntry cl) { private ImageIcon iconFor(ClientEntry cl) {
if (cl.isQuery()) return Icons.clientQuery();
if (cl.outputMuted) return Icons.speakerMuted(); if (cl.outputMuted) return Icons.speakerMuted();
if (cl.inputMuted) return Icons.micMuted(); if (cl.inputMuted) return Icons.micMuted();
if (cl.away) return Icons.clientAway(); if (cl.away) return Icons.clientAway();
if (cl.channelCommander) {
return cl.talking ? Icons.clientCommanderTalking() : Icons.clientCommander();
}
if (cl.talking) return Icons.clientTalking(); if (cl.talking) return Icons.clientTalking();
return Icons.clientIdle(); return Icons.clientIdle();
} }

View File

@@ -56,6 +56,7 @@ public final class SettingsDialog extends JDialog {
private final Runnable onApply; private final Runnable onApply;
private NotificationsPanel notificationsPanel; private NotificationsPanel notificationsPanel;
private IconPackPanel iconPackPanel;
private JComboBox<AudioDevices.Device> inputCombo; private JComboBox<AudioDevices.Device> inputCombo;
private JComboBox<AudioDevices.Device> outputCombo; private JComboBox<AudioDevices.Device> outputCombo;
@@ -104,6 +105,8 @@ public final class SettingsDialog extends JDialog {
tabs.addTab("Voice Activation", scrollable(buildVoiceTab())); tabs.addTab("Voice Activation", scrollable(buildVoiceTab()));
notificationsPanel = new NotificationsPanel(settings, sounds); notificationsPanel = new NotificationsPanel(settings, sounds);
tabs.addTab("Notifications", notificationsPanel); tabs.addTab("Notifications", notificationsPanel);
iconPackPanel = new IconPackPanel(settings);
tabs.addTab("Design", iconPackPanel);
JPanel buttons = new JPanel(new BorderLayout()); JPanel buttons = new JPanel(new BorderLayout());
JPanel right = new JPanel(); JPanel right = new JPanel();
@@ -113,10 +116,7 @@ public final class SettingsDialog extends JDialog {
apply(); apply();
close(); close();
}); });
cancel.addActionListener(e -> { cancel.addActionListener(e -> cancel());
notificationsPanel.revert();
close();
});
right.add(ok); right.add(ok);
right.add(cancel); right.add(cancel);
buttons.add(right, BorderLayout.EAST); buttons.add(right, BorderLayout.EAST);
@@ -125,6 +125,7 @@ public final class SettingsDialog extends JDialog {
getContentPane().add(tabs, BorderLayout.CENTER); getContentPane().add(tabs, BorderLayout.CENTER);
getContentPane().add(buttons, BorderLayout.SOUTH); getContentPane().add(buttons, BorderLayout.SOUTH);
Dialogs.closeOnEscape(this, this::cancel);
setDefaultCloseOperation(DISPOSE_ON_CLOSE); setDefaultCloseOperation(DISPOSE_ON_CLOSE);
addWindowListener(new java.awt.event.WindowAdapter() { addWindowListener(new java.awt.event.WindowAdapter() {
@Override @Override
@@ -549,6 +550,7 @@ public final class SettingsDialog extends JDialog {
settings.fec = fecCheck.isSelected(); settings.fec = fecCheck.isSelected();
settings.music = musicCheck.isSelected(); settings.music = musicCheck.isSelected();
notificationsPanel.apply(); notificationsPanel.apply();
iconPackPanel.apply();
settings.save(); settings.save();
if (liveMic != null) { if (liveMic != null) {
@@ -571,6 +573,13 @@ public final class SettingsDialog extends JDialog {
if (onApply != null) onApply.run(); if (onApply != null) onApply.run();
} }
/** Leaves without applying, putting back the settings that preview themselves live. */
private void cancel() {
notificationsPanel.revert();
iconPackPanel.revert();
close();
}
private void close() { private void close() {
stopMeter(); stopMeter();
dispose(); dispose();

2
ts3j

Submodule ts3j updated: dce4e68150...0e5724b877