Compare commits
10 Commits
e1f62ab50d
...
97148cfe9a
| Author | SHA1 | Date | |
|---|---|---|---|
|
97148cfe9a
|
|||
| 6dee5106c7 | |||
| 9bb1ec7060 | |||
| 69003ced9a | |||
| 0f8de796ea | |||
| d37c6c9ba9 | |||
| 46206e3047 | |||
| 245ae5ecc5 | |||
| 25a78f52fe | |||
| 676e95023e |
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.ts3client.config;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locates the folders add-ons (sound packs, icon packs) are loaded from: the
|
||||||
|
* client's own folder, a folder the user configured, and the matching folder of
|
||||||
|
* an installed TeamSpeak 3 client, whose add-ons are usable as they are.
|
||||||
|
*/
|
||||||
|
public final class InstallDirs {
|
||||||
|
|
||||||
|
/** Overrides the TeamSpeak install location the add-ons are borrowed from. */
|
||||||
|
private static final String CLIENT_DIR_PROPERTY = "ts3.client.dir";
|
||||||
|
private static final String CLIENT_DIR_ENV = "TS3_CLIENT_DIR";
|
||||||
|
|
||||||
|
private InstallDirs() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param subfolder the add-on folder inside a TeamSpeak installation ({@code sound}, {@code gfx})
|
||||||
|
* @param userDirectory the client's own add-on folder (may not exist yet)
|
||||||
|
* @param extraDirectory an additional folder chosen by the user, or {@code null}
|
||||||
|
* @return the folders that exist, most specific first
|
||||||
|
*/
|
||||||
|
public static List<File> searchRoots(String subfolder, File userDirectory, String extraDirectory) {
|
||||||
|
List<File> roots = new ArrayList<>();
|
||||||
|
if (userDirectory != null) roots.add(userDirectory);
|
||||||
|
if (extraDirectory != null && !extraDirectory.isBlank()) roots.add(new File(extraDirectory.trim()));
|
||||||
|
|
||||||
|
String configured = System.getProperty(CLIENT_DIR_PROPERTY, System.getenv(CLIENT_DIR_ENV));
|
||||||
|
if (configured != null && !configured.isBlank()) roots.add(new File(configured.trim(), subfolder));
|
||||||
|
|
||||||
|
String home = System.getProperty("user.home", ".");
|
||||||
|
for (String candidate : new String[]{
|
||||||
|
"TeamSpeak3-Client-linux_amd64", "TeamSpeak3-Client-linux_x86",
|
||||||
|
".local/share/TeamSpeak3-Client-linux_amd64", "Applications/TeamSpeak3-Client-linux_amd64"}) {
|
||||||
|
roots.add(new File(new File(home, candidate), subfolder));
|
||||||
|
}
|
||||||
|
for (String candidate : new String[]{
|
||||||
|
"/opt/teamspeak3-client", "/opt/teamspeak3", "/usr/share/teamspeak3",
|
||||||
|
"/usr/share/teamspeak3-client"}) {
|
||||||
|
roots.add(new File(candidate, subfolder));
|
||||||
|
}
|
||||||
|
String programFiles = System.getenv("ProgramFiles");
|
||||||
|
if (programFiles != null) {
|
||||||
|
roots.add(new File(programFiles, "TeamSpeak 3 Client" + File.separator + subfolder));
|
||||||
|
}
|
||||||
|
// Running from a checkout that has the official client unpacked next to it.
|
||||||
|
roots.add(new File("TeamSpeak3-Client-linux_amd64/" + subfolder));
|
||||||
|
roots.add(new File("../TeamSpeak3-Client-linux_amd64/" + subfolder));
|
||||||
|
|
||||||
|
List<File> existing = new ArrayList<>();
|
||||||
|
for (File root : roots) {
|
||||||
|
if (root.isDirectory()) existing.add(root);
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,6 +99,12 @@ public final class Settings {
|
|||||||
public double soundVolume = 1.0;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
314
ts3-client/core/src/main/java/com/ts3client/gfx/IconPack.java
Normal file
314
ts3-client/core/src/main/java/com/ts3client/gfx/IconPack.java
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
package com.ts3client.gfx;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.TreeSet;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A TeamSpeak icon pack: a zip (or unpacked directory) of images plus an optional
|
||||||
|
* {@code settings.ini} naming the file behind every icon of the user interface.
|
||||||
|
* The format is TeamSpeak's, so the packs shipped with the official client and the
|
||||||
|
* ones from its add-on site can be used as they are:
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* [options]
|
||||||
|
* FALLBACK=true
|
||||||
|
*
|
||||||
|
* [gfxfiles]
|
||||||
|
* CHANNEL_GREEN = channel_green.svg
|
||||||
|
* CONNECT = connect.svg
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>Packs without a {@code settings.ini} are the legacy ones (TeamSpeak's own
|
||||||
|
* {@code default.zip}), whose files are named after the icon key directly, each
|
||||||
|
* prefixed with the size it was drawn for: {@code 16x16_connect.png}. Those are
|
||||||
|
* resolved by convention, picking the size closest to the one that is asked for.
|
||||||
|
*
|
||||||
|
* <p>Icon data is returned as bytes; rasterising it (the packs from 2014 on are
|
||||||
|
* vector art) is the frontend's job.
|
||||||
|
*/
|
||||||
|
public final class IconPack {
|
||||||
|
|
||||||
|
private static final String INI = "settings.ini";
|
||||||
|
/** Legacy file names: {@code <width>x<height>_<key>.<ext>}. */
|
||||||
|
private static final Pattern SIZED = Pattern.compile("(\\d+)x(\\d+)_(.+)");
|
||||||
|
|
||||||
|
/** One image inside a pack, with the size it was authored at (0 when unknown). */
|
||||||
|
private record Entry(String path, int size) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final String id;
|
||||||
|
private final File source;
|
||||||
|
private final String name;
|
||||||
|
private final boolean zipped;
|
||||||
|
private final boolean fallback;
|
||||||
|
/** Icon key to the images that can serve it, smallest first. */
|
||||||
|
private final Map<String, List<Entry>> entries;
|
||||||
|
|
||||||
|
private IconPack(String id, File source, String name, boolean zipped, boolean fallback,
|
||||||
|
Map<String, List<Entry>> entries) {
|
||||||
|
this.id = id;
|
||||||
|
this.source = source;
|
||||||
|
this.name = name;
|
||||||
|
this.zipped = zipped;
|
||||||
|
this.fallback = fallback;
|
||||||
|
this.entries = entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable identifier used in the settings: the file (or directory) name. */
|
||||||
|
public String id() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String name() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public File source() {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether missing icons should be looked up in the default pack, as the pack asks. */
|
||||||
|
public boolean fallsBack() {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All icon keys this pack can draw, sorted. */
|
||||||
|
public List<String> keys() {
|
||||||
|
return new ArrayList<>(new TreeSet<>(entries.keySet()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean has(String key) {
|
||||||
|
return entries.containsKey(normalizeKey(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the image for an icon.
|
||||||
|
*
|
||||||
|
* @param key a TeamSpeak icon key, e.g. {@code CHANNEL_GREEN}
|
||||||
|
* @param preferredSize the size it will be drawn at, so packs holding several
|
||||||
|
* resolutions can pick the closest one
|
||||||
|
* @return the image bytes (PNG or SVG), or {@code null} if the pack has no such icon
|
||||||
|
*/
|
||||||
|
public byte[] icon(String key, int preferredSize) {
|
||||||
|
Entry entry = select(entries.get(normalizeKey(key)), preferredSize);
|
||||||
|
if (entry == null) return null;
|
||||||
|
try {
|
||||||
|
return zipped ? readFromZip(entry.path()) : Files.readAllBytes(new File(source, entry.path()).toPath());
|
||||||
|
} catch (IOException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return whether the icon's image is vector art the frontend has to rasterise */
|
||||||
|
public boolean isVector(String key, int preferredSize) {
|
||||||
|
Entry entry = select(entries.get(normalizeKey(key)), preferredSize);
|
||||||
|
return entry != null && entry.path().toLowerCase(Locale.ROOT).endsWith(".svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The smallest image that still covers the wanted size, else the largest one. */
|
||||||
|
private static Entry select(List<Entry> candidates, int preferredSize) {
|
||||||
|
if (candidates == null || candidates.isEmpty()) return null;
|
||||||
|
for (Entry e : candidates) {
|
||||||
|
if (e.size() == 0 || e.size() >= preferredSize) return e;
|
||||||
|
}
|
||||||
|
return candidates.get(candidates.size() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] readFromZip(String path) throws IOException {
|
||||||
|
try (ZipFile zip = new ZipFile(source)) {
|
||||||
|
ZipEntry entry = zip.getEntry(path);
|
||||||
|
if (entry == null) return null;
|
||||||
|
try (InputStream in = zip.getInputStream(entry)) {
|
||||||
|
return in.readAllBytes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeKey(String key) {
|
||||||
|
return key == null ? "" : key.trim().toUpperCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- loading ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a pack from a {@code .zip} file or from a directory holding its contents.
|
||||||
|
*
|
||||||
|
* @return the pack, or {@code null} when it holds no icons
|
||||||
|
*/
|
||||||
|
public static IconPack load(File source) {
|
||||||
|
if (source == null) return null;
|
||||||
|
boolean zipped = source.isFile();
|
||||||
|
if (!zipped && !source.isDirectory()) return null;
|
||||||
|
|
||||||
|
Map<String, Long> files = zipped ? listZip(source) : listDirectory(source);
|
||||||
|
if (files == null || files.isEmpty()) return null;
|
||||||
|
|
||||||
|
String iniPath = files.keySet().stream()
|
||||||
|
.filter(p -> p.equalsIgnoreCase(INI))
|
||||||
|
.findFirst().orElse(null);
|
||||||
|
Map<String, String> mapping = new LinkedHashMap<>();
|
||||||
|
Map<String, String> info = new LinkedHashMap<>();
|
||||||
|
boolean fallback = true;
|
||||||
|
if (iniPath != null) {
|
||||||
|
byte[] ini = read(source, zipped, iniPath);
|
||||||
|
if (ini != null) fallback = parseIni(ini, mapping, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, List<Entry>> entries = iniPath != null
|
||||||
|
? fromMapping(mapping, files)
|
||||||
|
: byConvention(files);
|
||||||
|
if (entries.isEmpty()) return null;
|
||||||
|
|
||||||
|
String id = source.getName();
|
||||||
|
String name = info.getOrDefault("name", displayName(id));
|
||||||
|
return new IconPack(id, source, name, zipped, fallback, entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turns {@code default_colored_2014.zip} into {@code Default Colored 2014}. */
|
||||||
|
private static String displayName(String fileName) {
|
||||||
|
String base = fileName.toLowerCase(Locale.ROOT).endsWith(".zip")
|
||||||
|
? fileName.substring(0, fileName.length() - 4) : fileName;
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
for (String word : base.split("[_\\-\\s]+")) {
|
||||||
|
if (word.isEmpty()) continue;
|
||||||
|
if (out.length() > 0) out.append(' ');
|
||||||
|
out.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1));
|
||||||
|
}
|
||||||
|
return out.length() == 0 ? fileName : out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the pack's FALLBACK option */
|
||||||
|
private static boolean parseIni(byte[] data, Map<String, String> mapping, Map<String, String> info) {
|
||||||
|
boolean fallback = true;
|
||||||
|
try (BufferedReader in = new BufferedReader(
|
||||||
|
new InputStreamReader(new java.io.ByteArrayInputStream(data), StandardCharsets.UTF_8))) {
|
||||||
|
String section = "";
|
||||||
|
String line;
|
||||||
|
while ((line = in.readLine()) != null) {
|
||||||
|
String trimmed = line.trim();
|
||||||
|
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith(";")) continue;
|
||||||
|
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||||
|
section = trimmed.substring(1, trimmed.length() - 1).trim().toLowerCase(Locale.ROOT);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int eq = trimmed.indexOf('=');
|
||||||
|
if (eq < 0) continue;
|
||||||
|
String key = trimmed.substring(0, eq).trim();
|
||||||
|
String value = trimmed.substring(eq + 1).trim();
|
||||||
|
switch (section) {
|
||||||
|
case "gfxfiles" -> {
|
||||||
|
if (!value.isEmpty()) mapping.put(normalizeKey(key), value);
|
||||||
|
}
|
||||||
|
case "info" -> info.put(key.toLowerCase(Locale.ROOT), value);
|
||||||
|
case "options" -> {
|
||||||
|
if (key.equalsIgnoreCase("FALLBACK")) fallback = !value.equalsIgnoreCase("false");
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, List<Entry>> fromMapping(Map<String, String> mapping, Map<String, Long> files) {
|
||||||
|
Map<String, List<Entry>> entries = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, String> e : mapping.entrySet()) {
|
||||||
|
String path = resolve(e.getValue(), files);
|
||||||
|
if (path != null) entries.put(e.getKey(), List.of(new Entry(path, 0)));
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy packs (and the extra files of newer ones) are keyed by their name:
|
||||||
|
* {@code 16x16_connect.png} serves {@code CONNECT}, as does {@code 32x32_connect.png}
|
||||||
|
* when no smaller one exists.
|
||||||
|
*/
|
||||||
|
private static Map<String, List<Entry>> byConvention(Map<String, Long> files) {
|
||||||
|
Map<String, List<Entry>> entries = new LinkedHashMap<>();
|
||||||
|
for (String path : files.keySet()) {
|
||||||
|
int dot = path.lastIndexOf('.');
|
||||||
|
if (dot < 0 || path.indexOf('/') >= 0) continue;
|
||||||
|
String ext = path.substring(dot + 1).toLowerCase(Locale.ROOT);
|
||||||
|
if (!ext.equals("png") && !ext.equals("svg")) continue;
|
||||||
|
|
||||||
|
String base = path.substring(0, dot);
|
||||||
|
int size = 0;
|
||||||
|
Matcher m = SIZED.matcher(base);
|
||||||
|
if (m.matches()) {
|
||||||
|
size = Integer.parseInt(m.group(1));
|
||||||
|
base = m.group(3);
|
||||||
|
}
|
||||||
|
entries.computeIfAbsent(normalizeKey(base), k -> new ArrayList<>()).add(new Entry(path, size));
|
||||||
|
}
|
||||||
|
for (List<Entry> candidates : entries.values()) {
|
||||||
|
candidates.sort((a, b) -> Integer.compare(a.size(), b.size()));
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pack authors write file names as they see them, so a mismatched case is retried. */
|
||||||
|
private static String resolve(String fileName, Map<String, Long> files) {
|
||||||
|
if (files.containsKey(fileName)) return fileName;
|
||||||
|
for (String path : files.keySet()) {
|
||||||
|
if (path.equalsIgnoreCase(fileName)) return path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] read(File source, boolean zipped, String path) {
|
||||||
|
try {
|
||||||
|
if (!zipped) return Files.readAllBytes(new File(source, path).toPath());
|
||||||
|
try (ZipFile zip = new ZipFile(source)) {
|
||||||
|
ZipEntry entry = zip.getEntry(path);
|
||||||
|
if (entry == null) return null;
|
||||||
|
try (InputStream in = zip.getInputStream(entry)) {
|
||||||
|
return in.readAllBytes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Long> listZip(File file) {
|
||||||
|
if (!file.getName().toLowerCase(Locale.ROOT).endsWith(".zip")) return null;
|
||||||
|
Map<String, Long> files = new LinkedHashMap<>();
|
||||||
|
try (ZipFile zip = new ZipFile(file)) {
|
||||||
|
zip.stream().filter(e -> !e.isDirectory()).forEach(e -> files.put(e.getName(), e.getSize()));
|
||||||
|
} catch (IOException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Long> listDirectory(File dir) {
|
||||||
|
Map<String, Long> files = new LinkedHashMap<>();
|
||||||
|
File[] children = dir.listFiles(File::isFile);
|
||||||
|
if (children == null) return files;
|
||||||
|
for (File f : children) files.put(f.getName(), f.length());
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.ts3client.gfx;
|
||||||
|
|
||||||
|
import com.ts3client.config.InstallDirs;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the icon packs installed on this machine: the client's own {@code gfx/}
|
||||||
|
* folder, an extra folder the user configured, and the {@code gfx/} folder of an
|
||||||
|
* installed TeamSpeak 3 client (whose packs are usable as they are, see
|
||||||
|
* {@link IconPack}).
|
||||||
|
*/
|
||||||
|
public final class IconPacks {
|
||||||
|
|
||||||
|
/** The pack every other one falls back to, as TeamSpeak does. */
|
||||||
|
public static final String DEFAULT_ID = "default.zip";
|
||||||
|
|
||||||
|
private IconPacks() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads every pack found under {@code extraDirectory} and the well-known
|
||||||
|
* locations, the first search root winning on a name clash.
|
||||||
|
*
|
||||||
|
* @param userDirectory the client's own pack folder (may not exist yet)
|
||||||
|
* @param extraDirectory an additional folder chosen by the user, or {@code null}
|
||||||
|
* @return the packs, ordered by display name
|
||||||
|
*/
|
||||||
|
public static List<IconPack> findAll(File userDirectory, String extraDirectory) {
|
||||||
|
Map<String, IconPack> byId = new LinkedHashMap<>();
|
||||||
|
for (File root : InstallDirs.searchRoots("gfx", userDirectory, extraDirectory)) {
|
||||||
|
File[] children = root.listFiles();
|
||||||
|
if (children == null) continue;
|
||||||
|
for (File child : children) {
|
||||||
|
if (child.isFile() && !child.getName().toLowerCase(Locale.ROOT).endsWith(".zip")) continue;
|
||||||
|
if (byId.containsKey(child.getName())) continue;
|
||||||
|
IconPack pack = IconPack.load(child);
|
||||||
|
if (pack != null) byId.put(pack.id(), pack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<IconPack> packs = new ArrayList<>(byId.values());
|
||||||
|
packs.sort(Comparator.comparing(p -> p.name().toLowerCase(Locale.ROOT)));
|
||||||
|
return packs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the pack with this id, or {@code null} when it is not installed */
|
||||||
|
public static IconPack select(List<IconPack> packs, String id) {
|
||||||
|
for (IconPack p : packs) {
|
||||||
|
if (p.id().equals(id)) return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the pack unresolved icons are taken from, or {@code null} */
|
||||||
|
public static IconPack fallback(List<IconPack> packs) {
|
||||||
|
return select(packs, DEFAULT_ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package com.ts3client.gfx;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipOutputStream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class IconPackTest {
|
||||||
|
|
||||||
|
private static final String INI = """
|
||||||
|
[info]
|
||||||
|
name = Test Icons
|
||||||
|
|
||||||
|
[options]
|
||||||
|
FALLBACK = false
|
||||||
|
|
||||||
|
[gfxfiles]
|
||||||
|
# a comment
|
||||||
|
CONNECT = connect.svg
|
||||||
|
DISCONNECT = Disconnect.SVG
|
||||||
|
CHANNEL_GREEN= channel_green.png
|
||||||
|
MISSING = not_shipped.svg
|
||||||
|
EMPTY =
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static File zip(Path dir, String name, Map<String, String> entries) throws Exception {
|
||||||
|
File file = dir.resolve(name).toFile();
|
||||||
|
try (OutputStream out = Files.newOutputStream(file.toPath());
|
||||||
|
ZipOutputStream zip = new ZipOutputStream(out)) {
|
||||||
|
for (Map.Entry<String, String> e : entries.entrySet()) {
|
||||||
|
zip.putNextEntry(new ZipEntry(e.getKey()));
|
||||||
|
zip.write(e.getValue().getBytes(StandardCharsets.UTF_8));
|
||||||
|
zip.closeEntry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsTheMappingOfAModernPack(@TempDir Path dir) throws Exception {
|
||||||
|
IconPack pack = IconPack.load(zip(dir, "test_pack.zip", Map.of(
|
||||||
|
"settings.ini", INI,
|
||||||
|
"connect.svg", "<svg/>",
|
||||||
|
"disconnect.svg", "<svg>disconnect</svg>",
|
||||||
|
"channel_green.png", "png bytes",
|
||||||
|
"emoticons/smile.svg", "<svg/>")));
|
||||||
|
assertNotNull(pack);
|
||||||
|
|
||||||
|
assertEquals("test_pack.zip", pack.id());
|
||||||
|
assertEquals("Test Icons", pack.name());
|
||||||
|
assertFalse(pack.fallsBack());
|
||||||
|
|
||||||
|
assertArrayEquals("<svg/>".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 16));
|
||||||
|
assertTrue(pack.isVector("CONNECT", 16));
|
||||||
|
assertFalse(pack.isVector("CHANNEL_GREEN", 16));
|
||||||
|
// Keys are case-insensitive, as are the file names pack authors write.
|
||||||
|
assertArrayEquals("<svg>disconnect</svg>".getBytes(StandardCharsets.UTF_8),
|
||||||
|
pack.icon("disconnect", 16));
|
||||||
|
|
||||||
|
assertNull(pack.icon("MISSING", 16), "mapped to a file the pack does not ship");
|
||||||
|
assertNull(pack.icon("EMPTY", 16));
|
||||||
|
assertNull(pack.icon("NOT_IN_THE_PACK", 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fallsBackByDefault(@TempDir Path dir) throws Exception {
|
||||||
|
IconPack pack = IconPack.load(zip(dir, "p.zip", Map.of(
|
||||||
|
"settings.ini", "[gfxfiles]\nCONNECT = connect.svg\n",
|
||||||
|
"connect.svg", "<svg/>")));
|
||||||
|
assertNotNull(pack);
|
||||||
|
assertTrue(pack.fallsBack());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesLegacyPacksByFileName(@TempDir Path dir) throws Exception {
|
||||||
|
IconPack pack = IconPack.load(zip(dir, "default.zip", Map.of(
|
||||||
|
"16x16_connect.png", "small",
|
||||||
|
"32x32_connect.png", "large",
|
||||||
|
"24x24_upload_avatar.png", "avatar",
|
||||||
|
"group_100.png", "group",
|
||||||
|
"emoticons/smile.png", "ignored")));
|
||||||
|
assertNotNull(pack);
|
||||||
|
assertEquals("Default", pack.name());
|
||||||
|
|
||||||
|
// The smallest image that still covers the requested size wins.
|
||||||
|
assertArrayEquals("small".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 16));
|
||||||
|
assertArrayEquals("large".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 32));
|
||||||
|
assertArrayEquals("large".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 64));
|
||||||
|
|
||||||
|
assertArrayEquals("avatar".getBytes(StandardCharsets.UTF_8), pack.icon("UPLOAD_AVATAR", 16));
|
||||||
|
assertArrayEquals("group".getBytes(StandardCharsets.UTF_8), pack.icon("GROUP_100", 16));
|
||||||
|
assertFalse(pack.has("SMILE"), "emoticons are not icons of the user interface");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsUnpackedDirectories(@TempDir Path dir) throws Exception {
|
||||||
|
Path unpacked = Files.createDirectory(dir.resolve("my_icons"));
|
||||||
|
Files.writeString(unpacked.resolve("settings.ini"), "[gfxfiles]\nCONNECT = connect.svg\n");
|
||||||
|
Files.writeString(unpacked.resolve("connect.svg"), "<svg/>");
|
||||||
|
|
||||||
|
IconPack pack = IconPack.load(unpacked.toFile());
|
||||||
|
assertNotNull(pack);
|
||||||
|
assertEquals("My Icons", pack.name());
|
||||||
|
assertArrayEquals("<svg/>".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ignoresWhatIsNotAPack(@TempDir Path dir) throws Exception {
|
||||||
|
assertNull(IconPack.load(dir.resolve("nope.zip").toFile()));
|
||||||
|
assertNull(IconPack.load(Files.createDirectory(dir.resolve("empty")).toFile()));
|
||||||
|
assertNull(IconPack.load(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
<maven.compiler.release>26</maven.compiler.release>
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
ts3-client/swing/src/main/java/com/ts3client/ui/BanDialog.java
Normal file
113
ts3-client/swing/src/main/java/com/ts3client/ui/BanDialog.java
Normal 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()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
44
ts3-client/swing/src/main/java/com/ts3client/ui/Dialogs.java
Normal file
44
ts3-client/swing/src/main/java/com/ts3client/ui/Dialogs.java
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.ts3client.config.Settings;
|
||||||
|
import com.ts3client.gfx.IconPack;
|
||||||
|
|
||||||
|
import javax.swing.BorderFactory;
|
||||||
|
import javax.swing.DefaultListCellRenderer;
|
||||||
|
import javax.swing.DefaultListModel;
|
||||||
|
import javax.swing.ImageIcon;
|
||||||
|
import javax.swing.JButton;
|
||||||
|
import javax.swing.JComboBox;
|
||||||
|
import javax.swing.JComponent;
|
||||||
|
import javax.swing.JFileChooser;
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.JList;
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JScrollPane;
|
||||||
|
import javax.swing.JTextField;
|
||||||
|
import javax.swing.SwingConstants;
|
||||||
|
import java.awt.BorderLayout;
|
||||||
|
import java.awt.Component;
|
||||||
|
import java.awt.Dimension;
|
||||||
|
import java.awt.GridBagConstraints;
|
||||||
|
import java.awt.GridBagLayout;
|
||||||
|
import java.awt.Insets;
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options page for icon packs: which pack the user interface is drawn with, where
|
||||||
|
* to look for more of them, and a viewer showing everything the pack contains.
|
||||||
|
*
|
||||||
|
* <p>Picking a pack applies it right away so the change can be seen in the window
|
||||||
|
* behind the dialog; cancelling puts the previous one back.
|
||||||
|
*/
|
||||||
|
final class IconPackPanel extends JPanel {
|
||||||
|
|
||||||
|
/** Size of the tiles in the icon viewer. */
|
||||||
|
private static final int PREVIEW_SIZE = 32;
|
||||||
|
private static final int TILE = 44;
|
||||||
|
|
||||||
|
/** Stands for "no pack": the icons the client draws itself. */
|
||||||
|
private static final String BUILT_IN = "Built-in icons";
|
||||||
|
|
||||||
|
private final Settings settings;
|
||||||
|
private final String originalPackId;
|
||||||
|
private final String originalPackDir;
|
||||||
|
|
||||||
|
private final JComboBox<Object> packCombo = new JComboBox<>();
|
||||||
|
private final JLabel packInfo = new JLabel();
|
||||||
|
private final JTextField packDirField;
|
||||||
|
private final DefaultListModel<String> previewModel = new DefaultListModel<>();
|
||||||
|
private final JList<String> preview = new JList<>(previewModel);
|
||||||
|
|
||||||
|
IconPackPanel(Settings settings) {
|
||||||
|
super(new BorderLayout(0, 8));
|
||||||
|
this.settings = settings;
|
||||||
|
this.originalPackId = settings.iconPack;
|
||||||
|
this.originalPackDir = settings.iconPackDir;
|
||||||
|
this.packDirField = new JTextField(settings.iconPackDir, 18);
|
||||||
|
|
||||||
|
setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
|
||||||
|
add(buildHeader(), BorderLayout.NORTH);
|
||||||
|
add(buildPreview(), BorderLayout.CENTER);
|
||||||
|
|
||||||
|
reloadPacks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies the chosen pack into the settings; the caller saves them. */
|
||||||
|
void apply() {
|
||||||
|
settings.iconPackDir = packDirField.getText().trim();
|
||||||
|
Object selected = packCombo.getSelectedItem();
|
||||||
|
settings.iconPack = selected instanceof IconPack pack ? pack.id() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puts back the pack the dialog started with, for a cancelled edit. */
|
||||||
|
void revert() {
|
||||||
|
settings.iconPack = originalPackId;
|
||||||
|
settings.iconPackDir = originalPackDir;
|
||||||
|
IconTheme.get().reload(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- layout ----
|
||||||
|
|
||||||
|
private JComponent buildHeader() {
|
||||||
|
JPanel p = new JPanel(new GridBagLayout());
|
||||||
|
GridBagConstraints c = new GridBagConstraints();
|
||||||
|
c.insets = new Insets(2, 2, 2, 2);
|
||||||
|
c.anchor = GridBagConstraints.WEST;
|
||||||
|
c.fill = GridBagConstraints.HORIZONTAL;
|
||||||
|
|
||||||
|
packCombo.setToolTipText("Icon packs installed here or in a TeamSpeak 3 client");
|
||||||
|
packCombo.addActionListener(e -> onPackSelected());
|
||||||
|
packCombo.setRenderer(new DefaultListCellRenderer() {
|
||||||
|
@Override
|
||||||
|
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
|
||||||
|
boolean selected, boolean focus) {
|
||||||
|
super.getListCellRendererComponent(list, value, index, selected, focus);
|
||||||
|
setText(value instanceof IconPack pack ? pack.name() : String.valueOf(value));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
int row = 0;
|
||||||
|
addRow(p, c, row++, new JLabel("Icon pack:"), packCombo);
|
||||||
|
c.gridx = 1;
|
||||||
|
c.gridy = row++;
|
||||||
|
packInfo.setEnabled(false);
|
||||||
|
p.add(packInfo, c);
|
||||||
|
|
||||||
|
JPanel dirRow = new JPanel(new BorderLayout(6, 0));
|
||||||
|
JButton browse = new JButton("Browse…");
|
||||||
|
browse.addActionListener(e -> browseForPackFolder());
|
||||||
|
dirRow.add(packDirField, BorderLayout.CENTER);
|
||||||
|
dirRow.add(browse, BorderLayout.EAST);
|
||||||
|
packDirField.setToolTipText("Extra folder to look for icon packs in");
|
||||||
|
addRow(p, c, row, new JLabel("Extra pack folder:"), dirRow);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JComponent buildPreview() {
|
||||||
|
preview.setLayoutOrientation(JList.HORIZONTAL_WRAP);
|
||||||
|
preview.setVisibleRowCount(-1);
|
||||||
|
preview.setFixedCellWidth(TILE);
|
||||||
|
preview.setFixedCellHeight(TILE);
|
||||||
|
preview.setCellRenderer(new DefaultListCellRenderer() {
|
||||||
|
@Override
|
||||||
|
public Component getListCellRendererComponent(JList<?> list, Object value, int index,
|
||||||
|
boolean selected, boolean focus) {
|
||||||
|
super.getListCellRendererComponent(list, value, index, selected, focus);
|
||||||
|
String key = String.valueOf(value);
|
||||||
|
ImageIcon icon = Icons.of(key, PREVIEW_SIZE);
|
||||||
|
setText("");
|
||||||
|
setIcon(icon);
|
||||||
|
setToolTipText(key);
|
||||||
|
setHorizontalAlignment(SwingConstants.CENTER);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
JScrollPane scroll = new JScrollPane(preview);
|
||||||
|
scroll.setPreferredSize(new Dimension(360, 200));
|
||||||
|
scroll.setBorder(BorderFactory.createTitledBorder("Icons in this pack"));
|
||||||
|
return scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- behaviour ----
|
||||||
|
|
||||||
|
private void reloadPacks() {
|
||||||
|
IconTheme theme = IconTheme.get();
|
||||||
|
theme.reload(settings);
|
||||||
|
// Filling the combo box changes its selection, and with it the active pack,
|
||||||
|
// so remember the one the settings asked for before touching it.
|
||||||
|
IconPack selected = theme.activePack();
|
||||||
|
|
||||||
|
packCombo.removeAllItems();
|
||||||
|
packCombo.addItem(BUILT_IN);
|
||||||
|
for (IconPack pack : theme.packs()) packCombo.addItem(pack);
|
||||||
|
packCombo.setSelectedItem(selected == null ? BUILT_IN : selected);
|
||||||
|
onPackSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onPackSelected() {
|
||||||
|
Object selected = packCombo.getSelectedItem();
|
||||||
|
IconPack pack = selected instanceof IconPack p ? p : null;
|
||||||
|
IconTheme.get().setPack(pack);
|
||||||
|
|
||||||
|
previewModel.clear();
|
||||||
|
if (pack == null) {
|
||||||
|
packInfo.setText("Drawn by the client itself");
|
||||||
|
} else {
|
||||||
|
for (String key : pack.keys()) previewModel.addElement(key);
|
||||||
|
packInfo.setText(pack.keys().size() + " icons"
|
||||||
|
+ (pack.fallsBack() ? ", missing ones from the default pack" : ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void browseForPackFolder() {
|
||||||
|
JFileChooser chooser = new JFileChooser();
|
||||||
|
chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
|
||||||
|
chooser.setDialogTitle("Folder containing icon packs");
|
||||||
|
String current = packDirField.getText().trim();
|
||||||
|
if (!current.isEmpty()) chooser.setCurrentDirectory(new File(current));
|
||||||
|
if (chooser.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) return;
|
||||||
|
packDirField.setText(chooser.getSelectedFile().getAbsolutePath());
|
||||||
|
settings.iconPackDir = packDirField.getText();
|
||||||
|
reloadPacks();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, Component field) {
|
||||||
|
c.gridx = 0;
|
||||||
|
c.gridy = row;
|
||||||
|
c.weightx = 0;
|
||||||
|
p.add(label, c);
|
||||||
|
c.gridx = 1;
|
||||||
|
c.weightx = 1;
|
||||||
|
p.add(field, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
ts3-client/swing/src/main/java/com/ts3client/ui/IconTheme.java
Normal file
167
ts3-client/swing/src/main/java/com/ts3client/ui/IconTheme.java
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package com.ts3client.ui;
|
||||||
|
|
||||||
|
import com.github.weisj.jsvg.SVGDocument;
|
||||||
|
import com.github.weisj.jsvg.parser.LoaderContext;
|
||||||
|
import com.github.weisj.jsvg.parser.SVGLoader;
|
||||||
|
import com.github.weisj.jsvg.view.ViewBox;
|
||||||
|
import com.ts3client.config.Settings;
|
||||||
|
import com.ts3client.gfx.IconPack;
|
||||||
|
import com.ts3client.gfx.IconPacks;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import javax.swing.ImageIcon;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.Image;
|
||||||
|
import java.awt.RenderingHints;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The icons the user interface draws itself with, taken from the active
|
||||||
|
* {@link IconPack}.
|
||||||
|
*
|
||||||
|
* <p>Packs are TeamSpeak's, so an installed client's {@code gfx/} folder (and the
|
||||||
|
* packs from its add-on site) can be used as they are. A pack's own
|
||||||
|
* {@code FALLBACK} option decides whether icons it does not define are looked up
|
||||||
|
* in {@code default.zip}; whatever is still missing is drawn by {@link Icons}
|
||||||
|
* instead, so the client always has a complete set.
|
||||||
|
*
|
||||||
|
* <p>Rendering is cached per key and size: vector art is rasterised at the size it
|
||||||
|
* is asked for, bitmaps are scaled from the closest resolution the pack ships.
|
||||||
|
*/
|
||||||
|
public final class IconTheme {
|
||||||
|
|
||||||
|
/** The size of a tree row / menu item icon, as in the official client. */
|
||||||
|
public static final int SIZE = 16;
|
||||||
|
/** The size of the toolbar's buttons. */
|
||||||
|
public static final int TOOLBAR_SIZE = 24;
|
||||||
|
|
||||||
|
private static final IconTheme INSTANCE = new IconTheme();
|
||||||
|
|
||||||
|
private final Map<String, ImageIcon> cache = new ConcurrentHashMap<>();
|
||||||
|
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
|
private volatile List<IconPack> packs = List.of();
|
||||||
|
private volatile IconPack active;
|
||||||
|
private volatile IconPack fallback;
|
||||||
|
|
||||||
|
private IconTheme() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IconTheme get() {
|
||||||
|
return INSTANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The packs found on this machine, in display order. */
|
||||||
|
public List<IconPack> packs() {
|
||||||
|
return packs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IconPack activePack() {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-scans the pack folders and activates the one the settings name. */
|
||||||
|
public void reload(Settings settings) {
|
||||||
|
packs = IconPacks.findAll(new File(Settings.configDir(), "gfx"), settings.iconPackDir);
|
||||||
|
fallback = IconPacks.fallback(packs);
|
||||||
|
setPack(IconPacks.select(packs, settings.iconPack));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switches to a pack (which may be {@code null} for the built-in icons). */
|
||||||
|
public void setPack(IconPack pack) {
|
||||||
|
if (active == pack) return;
|
||||||
|
active = pack;
|
||||||
|
cache.clear();
|
||||||
|
for (Runnable listener : listeners) listener.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registers a repaint to run whenever the icons change. */
|
||||||
|
public void addListener(Runnable listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the icon for a TeamSpeak icon key at 16×16, or {@code null} if no pack has it */
|
||||||
|
public static ImageIcon icon(String key) {
|
||||||
|
return icon(key, SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the icon for a TeamSpeak icon key, or {@code null} if no pack has it */
|
||||||
|
public static ImageIcon icon(String key, int size) {
|
||||||
|
return INSTANCE.lookup(key, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImageIcon lookup(String key, int size) {
|
||||||
|
if (key == null) return null;
|
||||||
|
String cacheKey = key + '@' + size;
|
||||||
|
ImageIcon cached = cache.get(cacheKey);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
ImageIcon icon = render(active, key, size);
|
||||||
|
if (icon == null && active != null && active.fallsBack() && fallback != active) {
|
||||||
|
icon = render(fallback, key, size);
|
||||||
|
}
|
||||||
|
if (icon != null) cache.put(cacheKey, icon);
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImageIcon render(IconPack pack, String key, int size) {
|
||||||
|
if (pack == null) return null;
|
||||||
|
byte[] data = pack.icon(key, size);
|
||||||
|
if (data == null || data.length == 0) return null;
|
||||||
|
BufferedImage image = pack.isVector(key, size) ? rasterize(data, size) : decode(data, size);
|
||||||
|
return image == null ? null : new ImageIcon(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BufferedImage rasterize(byte[] data, int size) {
|
||||||
|
try {
|
||||||
|
SVGDocument document = new SVGLoader().load(
|
||||||
|
new ByteArrayInputStream(data), null, LoaderContext.createDefault());
|
||||||
|
if (document == null) return null;
|
||||||
|
BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
|
||||||
|
Graphics2D g = image.createGraphics();
|
||||||
|
try {
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
|
||||||
|
document.render(null, g, new ViewBox(size, size));
|
||||||
|
} finally {
|
||||||
|
g.dispose();
|
||||||
|
}
|
||||||
|
return image;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BufferedImage decode(byte[] data, int size) {
|
||||||
|
try {
|
||||||
|
BufferedImage source = ImageIO.read(new ByteArrayInputStream(data));
|
||||||
|
if (source == null) return null;
|
||||||
|
if (source.getWidth() == size && source.getHeight() == size) return source;
|
||||||
|
|
||||||
|
BufferedImage scaled = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
|
||||||
|
Graphics2D g = scaled.createGraphics();
|
||||||
|
try {
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
|
||||||
|
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||||
|
// Keep the aspect ratio of packs whose art is not square.
|
||||||
|
double factor = Math.min((double) size / source.getWidth(), (double) size / source.getHeight());
|
||||||
|
int w = Math.max(1, (int) Math.round(source.getWidth() * factor));
|
||||||
|
int h = Math.max(1, (int) Math.round(source.getHeight() * factor));
|
||||||
|
g.drawImage(source.getScaledInstance(w, h, Image.SCALE_SMOOTH),
|
||||||
|
(size - w) / 2, (size - h) / 2, null);
|
||||||
|
} finally {
|
||||||
|
g.dispose();
|
||||||
|
}
|
||||||
|
return scaled;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,13 +8,17 @@ import java.awt.RenderingHints;
|
|||||||
import java.awt.image.BufferedImage;
|
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×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);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
2
ts3j
Submodule ts3j updated: dce4e68150...0e5724b877
Reference in New Issue
Block a user