serverVars() {
+ return SoundNotifier.vars("servername", model.getServerName());
+ }
+
+ /** Reports a failure to the UI and plays the matching server sound. */
+ private void error(String message) {
+ sound(isPermissionError(message)
+ ? SoundEvent.SERVER_INSUFFICIENT_PERMISSIONS
+ : SoundEvent.SERVER_ERROR, serverVars());
+ ui.onError(message);
+ }
+
+ private static boolean isPermissionError(String message) {
+ if (message == null) return false;
+ String m = message.toLowerCase(java.util.Locale.ROOT);
+ return m.contains("insufficient") || m.contains("permission");
+ }
+
+ /** Whether the client is in the channel we are in ourselves. */
+ private boolean inOwnChannel(int channelId) {
+ ClientEntry self = model.getClient(selfClientId);
+ return self != null && self.channelId == channelId;
+ }
+
// ---- helpers ----
private static long safeLong(BaseEvent e, String key) {
@@ -1069,6 +1457,17 @@ public final class TeamspeakConnection implements TS3Listener {
}
}
+ /**
+ * Whether the event actually carries a field. ts3j answers a missing key with an
+ * empty string rather than null, so a plain null check is always true — and a
+ * partial update (say, someone muting) would otherwise look like it reported
+ * every other field as well.
+ */
+ private static boolean has(BaseEvent e, String key) {
+ String value = e.get(key);
+ return value != null && !value.isEmpty();
+ }
+
private static int safeInt(BaseEvent e, String key) {
try {
String v = e.get(key);
diff --git a/ts3-client/core/src/main/java/com/ts3client/sound/NotificationSettings.java b/ts3-client/core/src/main/java/com/ts3client/sound/NotificationSettings.java
new file mode 100644
index 0000000..1755fa9
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/sound/NotificationSettings.java
@@ -0,0 +1,91 @@
+package com.ts3client.sound;
+
+import java.util.EnumSet;
+import java.util.Properties;
+import java.util.Set;
+
+/**
+ * Per-action notification configuration: whether an action makes a sound at all,
+ * and whether it is important — important actions are the only ones still
+ * heard while the speakers are muted.
+ *
+ * Only deviations from the defaults are persisted, so a pack gaining new events
+ * (or a default changing) does not need a migration.
+ */
+public final class NotificationSettings {
+
+ private static final String PREFIX = "sound.event.";
+ private static final String ENABLED = ".enabled";
+ private static final String IMPORTANT = ".important";
+
+ private final Set disabled = EnumSet.noneOf(SoundEvent.class);
+ private final Set important = EnumSet.noneOf(SoundEvent.class);
+
+ public NotificationSettings() {
+ resetToDefaults();
+ }
+
+ public boolean isEnabled(SoundEvent event) {
+ return !disabled.contains(event);
+ }
+
+ public void setEnabled(SoundEvent event, boolean enabled) {
+ if (enabled) disabled.remove(event);
+ else disabled.add(event);
+ }
+
+ public boolean isImportant(SoundEvent event) {
+ return important.contains(event);
+ }
+
+ public void setImportant(SoundEvent event, boolean value) {
+ if (value) important.add(event);
+ else important.remove(event);
+ }
+
+ public void resetToDefaults() {
+ disabled.clear();
+ important.clear();
+ for (SoundEvent e : SoundEvent.values()) {
+ if (e.importantByDefault()) important.add(e);
+ if (!e.enabledByDefault()) disabled.add(e);
+ }
+ }
+
+ public NotificationSettings copy() {
+ NotificationSettings copy = new NotificationSettings();
+ copy.copyFrom(this);
+ return copy;
+ }
+
+ public void copyFrom(NotificationSettings other) {
+ disabled.clear();
+ disabled.addAll(other.disabled);
+ important.clear();
+ important.addAll(other.important);
+ }
+
+ public void load(Properties props) {
+ resetToDefaults();
+ for (SoundEvent e : SoundEvent.values()) {
+ String enabled = props.getProperty(PREFIX + e.id() + ENABLED);
+ if (enabled != null) setEnabled(e, Boolean.parseBoolean(enabled));
+ String flag = props.getProperty(PREFIX + e.id() + IMPORTANT);
+ if (flag != null) setImportant(e, Boolean.parseBoolean(flag));
+ }
+ }
+
+ public void store(Properties props) {
+ for (SoundEvent e : SoundEvent.values()) {
+ put(props, PREFIX + e.id() + ENABLED, isEnabled(e) != e.enabledByDefault(),
+ Boolean.toString(isEnabled(e)));
+ put(props, PREFIX + e.id() + IMPORTANT, isImportant(e) != e.importantByDefault(),
+ Boolean.toString(isImportant(e)));
+ }
+ }
+
+ private static void put(Properties props, String key, boolean deviates, String value) {
+ if (deviates) props.setProperty(key, value);
+ else props.remove(key);
+ }
+}
diff --git a/ts3-client/core/src/main/java/com/ts3client/sound/SoundEvent.java b/ts3-client/core/src/main/java/com/ts3client/sound/SoundEvent.java
new file mode 100644
index 0000000..19006a9
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/sound/SoundEvent.java
@@ -0,0 +1,220 @@
+package com.ts3client.sound;
+
+/**
+ * The catalogue of actions a sound pack can react to, using TeamSpeak's own event
+ * ids so the packs shipped with the official client (and third-party ones) load
+ * unchanged — see {@link SoundPack}.
+ *
+ * Events are grouped for display and each carries a default "important" flag:
+ * important sounds are the only ones still played while the speakers are muted
+ * (see {@link SoundNotifier}). The user can change both the flag and whether an
+ * event makes a sound at all; that lives in {@link NotificationSettings}.
+ */
+public enum SoundEvent {
+
+ // ---- connection ----
+ CONNECTION_CONNECTED(Category.CONNECTION, "Connection established"),
+ CONNECTION_DISCONNECTED(Category.CONNECTION, "Disconnected"),
+ CONNECTION_LOST_CONNECTION(Category.CONNECTION, "Connection lost", true),
+
+ // ---- notifications aimed at us ----
+ OTHER_RECEIVED_POKE(Category.OTHER, "You were poked", true),
+ OTHER_SENT_POKE(Category.OTHER, "You poked someone"),
+ OTHER_FILETRANSFER_COMPLETE(Category.OTHER, "File transfer complete"),
+ OTHER_FILETRANSFER_FAILED(Category.OTHER, "File transfer failed", true),
+ OTHER_WHISPERLIST_EMPTY(Category.OTHER, "Whisper list is empty"),
+ OTHER_WHISPERTARGET_NOT_FOUND(Category.OTHER, "Whisper target not found"),
+ OTHER_WHISPERTARGET_TOO_MANY(Category.OTHER, "Too many whisper targets"),
+ SPECIAL_WHISPER_NOTIFY(Category.OTHER, "Whisper received", true),
+
+ // ---- microphone / test sounds ----
+ SPECIAL_MIC_CLICK_SELF_ON(Category.SPECIAL, "Mic clicks self on", false, false),
+ SPECIAL_MIC_CLICK_SELF_OFF(Category.SPECIAL, "Mic clicks self off", false, false),
+ SPECIAL_MIC_CLICK_OTHER_ON(Category.SPECIAL, "Mic clicks other on", false, false),
+ SPECIAL_MIC_CLICK_OTHER_OFF(Category.SPECIAL, "Mic clicks other off", false, false),
+ SPECIAL_TALKING_WHILE_MUTED(Category.SPECIAL, "Talking while muted", true),
+ SPECIAL_SOUND_TEST(Category.SPECIAL, "Playback test sound"),
+ SPECIAL_3D_TEST(Category.SPECIAL, "3D sound test"),
+
+ // ---- channels ----
+ CHANNEL_CREATED_BY_YOU(Category.CHANNEL, "Created by you"),
+ CHANNEL_CREATED_BY_OTHER(Category.CHANNEL, "Created by other"),
+ CHANNEL_DELETED_BY_YOU(Category.CHANNEL, "Deleted by you"),
+ CHANNEL_DELETED_BY_OTHER(Category.CHANNEL, "Deleted by other"),
+ CHANNEL_DELETED_BY_SERVER(Category.CHANNEL, "Deleted by the server"),
+ CHANNEL_EDITED_CURRENT_BY_YOU(Category.CHANNEL, "Your channel edited by you"),
+ CHANNEL_EDITED_CURRENT_BY_OTHER(Category.CHANNEL, "Your channel edited by other"),
+ CHANNEL_EDITED_OTHER_BY_YOU(Category.CHANNEL, "Other channel edited by you"),
+ CHANNEL_EDITED_OTHER_BY_OTHER(Category.CHANNEL, "Other channel edited by other"),
+ CHANNEL_EDITED_OTHER_BY_SERVER(Category.CHANNEL, "Other channel edited by the server"),
+ CHANNEL_MOVED_BY_YOU(Category.CHANNEL, "Moved by you"),
+ CHANNEL_MOVED_BY_OTHER(Category.CHANNEL, "Moved by other"),
+
+ // ---- server ----
+ SERVER_EDITED_BY_YOU(Category.SERVER, "Edited by you"),
+ SERVER_EDITED_BY_OTHER(Category.SERVER, "Edited by other"),
+ SERVER_INSUFFICIENT_PERMISSIONS(Category.SERVER, "Insufficient permissions", true),
+ SERVER_ERROR(Category.SERVER, "Server error", true),
+
+ // ---- other clients ----
+ CLIENT_CONNECTION_CONNECTED_SERVER(Category.CLIENT, "Connected to server"),
+ CLIENT_CONNECTION_CONNECTED_CURRENT_CHANNEL(Category.CLIENT, "Connected to current channel"),
+ CLIENT_CONNECTION_DISCONNECTED_SERVER(Category.CLIENT, "Disconnected from server"),
+ CLIENT_CONNECTION_DISCONNECTED_CURRENT_CHANNEL(Category.CLIENT, "Disconnected from current channel"),
+ CLIENT_CONNECTION_LOST_CONNECTION_SERVER(Category.CLIENT, "Lost connection to server"),
+ CLIENT_CONNECTION_LOST_CONNECTION_CURRENT_CHANNEL(Category.CLIENT, "Lost connection in current channel"),
+ CLIENT_SWITCHED_TO_CURRENT_CHANNEL_APPEARS(Category.CLIENT, "Switched to current channel, appears"),
+ CLIENT_SWITCHED_TO_CURRENT_CHANNEL_STAYS(Category.CLIENT, "Switched to current channel, stays"),
+ CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_DISAPPEARS(Category.CLIENT, "Switched away from current channel, disappears"),
+ CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_STAYS(Category.CLIENT, "Switched away from current channel, stays"),
+ CLIENT_SWITCHED_TO_OTHER_CHANNEL_APPEARS(Category.CLIENT, "Switched to different channel, appears"),
+ CLIENT_SWITCHED_TO_OTHER_CHANNEL_DISAPPEARS(Category.CLIENT, "Switched to different channel, disappears"),
+ CLIENT_SWITCHED_TO_OTHER_CHANNEL_STAYS(Category.CLIENT, "Switched to different channel, stays"),
+ CLIENT_MOVED_TO_CURRENT_CHANNEL_APPEARS(Category.CLIENT, "Moved to current channel, appears"),
+ CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS(Category.CLIENT, "Moved to current channel, stays"),
+ CLIENT_MOVED_FROM_CURRENT_CHANNEL_DISAPPEARS(Category.CLIENT, "Moved away from current channel, disappears"),
+ CLIENT_MOVED_FROM_CURRENT_CHANNEL_STAYS(Category.CLIENT, "Moved away from current channel, stays"),
+ CLIENT_MOVED_TO_OTHER_CHANNEL_APPEARS(Category.CLIENT, "Moved to different channel, appears"),
+ CLIENT_MOVED_TO_OTHER_CHANNEL_DISAPPEARS(Category.CLIENT, "Moved to different channel, disappears"),
+ CLIENT_MOVED_TO_OTHER_CHANNEL_STAYS(Category.CLIENT, "Moved to different channel, stays"),
+ CLIENT_RENAMED_BY_YOU(Category.CLIENT, "Renamed by you"),
+ CLIENT_RENAMED_BY_OTHER(Category.CLIENT, "Renamed by other"),
+ CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_APPEARS(Category.CLIENT,
+ "Kicked from channel to current channel, appears"),
+ CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_STAYS(Category.CLIENT,
+ "Kicked from channel to current channel, stays"),
+ CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_DISAPPEARS(Category.CLIENT,
+ "Kicked away from current channel, disappears"),
+ CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_STAYS(Category.CLIENT,
+ "Kicked away from current channel, stays"),
+ CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_APPEARS(Category.CLIENT,
+ "Kicked from channel to different channel, appears"),
+ CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_DISAPPEARS(Category.CLIENT,
+ "Kicked from channel to different channel, disappears"),
+ CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_STAYS(Category.CLIENT,
+ "Kicked from channel to different channel, stays"),
+ CLIENT_WAS_KICKED_FROM_SERVER_SERVER(Category.CLIENT, "Kicked from server"),
+ CLIENT_WAS_KICKED_FROM_SERVER_CURRENT_CHANNEL(Category.CLIENT, "Kicked from server, from current channel"),
+ CLIENT_WAS_BANNED_SERVER(Category.CLIENT, "Banned from server"),
+ CLIENT_WAS_BANNED_CURRENT_CHANNEL(Category.CLIENT, "Banned from server, from current channel"),
+ CLIENT_SERVERGROUP_ADDED_BY_SERVER(Category.CLIENT, "Server group assigned by the server"),
+ CLIENT_SERVERGROUP_ADDED_BY_USER(Category.CLIENT, "Server group assigned by other"),
+ CLIENT_SERVERGROUP_REMOVED_BY_SERVER(Category.CLIENT, "Server group revoked by the server"),
+ CLIENT_SERVERGROUP_REMOVED_BY_USER(Category.CLIENT, "Server group revoked by other"),
+ CLIENT_CHANNELGROUP_CHANGED_BY_SERVER(Category.CLIENT, "Channel group changed by the server"),
+ CLIENT_CHANNELGROUP_CHANGED_BY_USER(Category.CLIENT, "Channel group changed by other"),
+ CLIENT_RECORDING_START(Category.CLIENT, "Starts recording"),
+ CLIENT_RECORDING_STOP(Category.CLIENT, "Stops recording"),
+ CLIENT_RECORDING_IN_CHANNEL(Category.CLIENT, "Is recording in current channel"),
+ CLIENT_REQUESTED_TALK_POWER(Category.CLIENT, "Talk power requested"),
+
+ // ---- ourselves ----
+ YOU_SWITCHED_CHANNEL(Category.SELF, "You switched channel"),
+ YOU_WERE_MOVED_TO_DIFFERENT_CHANNEL(Category.SELF, "You were moved", true),
+ YOU_WERE_KICKED_FROM_CHANNEL(Category.SELF, "You were kicked from channel", true),
+ YOU_WERE_KICKED_FROM_SERVER(Category.SELF, "You were kicked from server", true),
+ YOU_WERE_BANNED(Category.SELF, "You were banned", true),
+ YOU_WERE_GRANTED_TALK_POWER(Category.SELF, "Talk power granted", true),
+ YOU_WERE_REVOKED_TALK_POWER(Category.SELF, "Talk power revoked", true),
+ YOU_SERVERGROUP_ADDED_BY_SERVER(Category.SELF, "Server group assigned by the server"),
+ YOU_SERVERGROUP_ADDED_BY_USER(Category.SELF, "Server group assigned by other"),
+ YOU_SERVERGROUP_REMOVED_BY_SERVER(Category.SELF, "Server group revoked by the server"),
+ YOU_SERVERGROUP_REMOVED_BY_USER(Category.SELF, "Server group revoked by other"),
+ YOU_CHANNELGROUP_CHANGED_BY_SERVER(Category.SELF, "Channel group changed by the server"),
+ YOU_CHANNELGROUP_CHANGED_BY_USER(Category.SELF, "Channel group changed by other"),
+
+ // ---- chat ----
+ CHAT_SENT_MESSAGE_CLIENT(Category.CHAT, "You sent a client message"),
+ CHAT_SENT_MESSAGE_CHANNEL(Category.CHAT, "You sent a channel message"),
+ CHAT_SENT_MESSAGE_SERVER(Category.CHAT, "You sent a server message"),
+ CHAT_RECEIVED_MESSAGE_CLIENT(Category.CHAT, "You received a client message", true),
+ CHAT_RECEIVED_MESSAGE_CHANNEL(Category.CHAT, "You received a channel message"),
+ CHAT_RECEIVED_MESSAGE_SERVER(Category.CHAT, "You received a server message"),
+
+ // ---- status ----
+ SOUND_CAPTURE_MUTED(Category.STATUS, "Microphone muted", true),
+ SOUND_CAPTURE_UNMUTED(Category.STATUS, "Microphone activated", true),
+ SOUND_PLAYBACK_MUTED(Category.STATUS, "Sound muted", true),
+ SOUND_PLAYBACK_UNMUTED(Category.STATUS, "Sound resumed", true),
+ STATUS_SET_AWAY(Category.STATUS, "Set to away"),
+ STATUS_SET_PRESENT(Category.STATUS, "Set to present");
+
+ /** Display grouping, mirroring the sections of TeamSpeak's notification list. */
+ public enum Category {
+ CONNECTION("Connection"),
+ OTHER("Notifications"),
+ SPECIAL("Microphone & tests"),
+ CHANNEL("Channel"),
+ SERVER("Server"),
+ CLIENT("Clients"),
+ SELF("Yourself"),
+ CHAT("Chat"),
+ STATUS("Status");
+
+ private final String label;
+
+ Category(String label) {
+ this.label = label;
+ }
+
+ public String label() {
+ return label;
+ }
+ }
+
+ private final Category category;
+ private final String label;
+ private final boolean importantByDefault;
+ private final boolean enabledByDefault;
+
+ SoundEvent(Category category, String label) {
+ this(category, label, false, true);
+ }
+
+ SoundEvent(Category category, String label, boolean importantByDefault) {
+ this(category, label, importantByDefault, true);
+ }
+
+ SoundEvent(Category category, String label, boolean importantByDefault, boolean enabledByDefault) {
+ this.category = category;
+ this.label = label;
+ this.importantByDefault = importantByDefault;
+ this.enabledByDefault = enabledByDefault;
+ }
+
+ /** The key used in a sound pack's {@code settings.ini}; identical to {@link #name()}. */
+ public String id() {
+ return name();
+ }
+
+ public Category category() {
+ return category;
+ }
+
+ public String label() {
+ return label;
+ }
+
+ /** Whether this action counts as important until the user says otherwise. */
+ public boolean importantByDefault() {
+ return importantByDefault;
+ }
+
+ /**
+ * Whether the action makes a sound out of the box. The mic clicks are mapped by
+ * most packs but off by default, as they are in the official client.
+ */
+ public boolean enabledByDefault() {
+ return enabledByDefault;
+ }
+
+ /** @return the event with this pack key, or {@code null} for an unknown one */
+ public static SoundEvent byId(String id) {
+ if (id == null) return null;
+ try {
+ return valueOf(id.trim().toUpperCase(java.util.Locale.ROOT));
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+}
diff --git a/ts3-client/core/src/main/java/com/ts3client/sound/SoundNotifier.java b/ts3-client/core/src/main/java/com/ts3client/sound/SoundNotifier.java
new file mode 100644
index 0000000..6730942
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/sound/SoundNotifier.java
@@ -0,0 +1,126 @@
+package com.ts3client.sound;
+
+import com.ts3client.config.Settings;
+
+import java.io.File;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Plays the sound a {@link SoundPack} assigns to an action, honouring the user's
+ * per-action configuration.
+ *
+ *
An action is heard when the pack maps it, the user left it enabled and either
+ * the speakers are on or the action is marked important — the muted case
+ * is what the important flag exists for.
+ */
+public final class SoundNotifier {
+
+ /** Contact class in a pack's {@code ${clientType}} placeholder: friend, neutral or blocked. */
+ private static final String DEFAULT_CLIENT_TYPE = "neutral";
+
+ private final Settings settings;
+ private volatile SoundPlayer player;
+ private volatile SoundPack pack;
+ private volatile List available = List.of();
+
+ public SoundNotifier(Settings settings) {
+ this.settings = settings;
+ reload();
+ }
+
+ /** Attaches the platform's player; without one the notifier stays silent. */
+ public void setPlayer(SoundPlayer player) {
+ this.player = player;
+ }
+
+ /** Re-scans the installed packs and selects the configured one. */
+ public void reload() {
+ available = SoundPacks.findAll(new File(Settings.configDir(), "sound"), settings.soundPackDir);
+ pack = SoundPacks.select(available, settings.soundPack);
+ }
+
+ public List availablePacks() {
+ return available;
+ }
+
+ public SoundPack pack() {
+ return pack;
+ }
+
+ /** Switches packs and remembers the choice in the settings. */
+ public void setPack(SoundPack pack) {
+ this.pack = pack;
+ settings.soundPack = pack == null ? "" : pack.id();
+ }
+
+ /**
+ * Plays the sound for an action.
+ *
+ * @param muted whether playback is currently muted, in which case only actions
+ * marked important are heard
+ */
+ public void fire(SoundEvent event, boolean muted) {
+ fire(event, muted, null);
+ }
+
+ /**
+ * @param variables values for the pack's {@code ${...}} placeholders, e.g.
+ * {@code clientname} or {@code channelname}
+ */
+ public void fire(SoundEvent event, boolean muted, Map variables) {
+ NotificationSettings notifications = settings.notifications;
+ if (!notifications.isEnabled(event)) return;
+ if (muted && !notifications.isImportant(event)) return;
+ render(event, variables);
+ }
+
+ /** Plays an action's sound regardless of its configuration, for the options dialog. */
+ public void preview(SoundEvent event) {
+ render(event, null);
+ }
+
+ /** Whether the current pack would make any sound for this action. */
+ public boolean hasSound(SoundEvent event) {
+ SoundPack p = pack;
+ if (p == null) return false;
+ SoundScript script = p.script(event);
+ if (script == null) return false;
+ return script.kind() != SoundScript.Kind.SAY || (player != null && player.canSpeak());
+ }
+
+ private void render(SoundEvent event, Map variables) {
+ SoundPack p = pack;
+ SoundPlayer out = player;
+ if (p == null || out == null) return;
+ SoundScript script = p.script(event);
+ if (script == null) return;
+
+ String resolved = script.resolve(withDefaults(variables));
+ double volume = Math.max(0, Math.min(1.0, settings.soundVolume));
+ if (volume <= 0) return;
+
+ if (script.kind() == SoundScript.Kind.SAY) {
+ out.say(resolved, volume);
+ return;
+ }
+ File file = p.file(resolved);
+ if (file != null) out.play(file, volume);
+ }
+
+ private static Map withDefaults(Map variables) {
+ Map vars = variables == null ? new HashMap<>() : new HashMap<>(variables);
+ vars.putIfAbsent("clientType", DEFAULT_CLIENT_TYPE);
+ return vars;
+ }
+
+ /** Builds a variable map from alternating name/value pairs. */
+ public static Map vars(String... nameValuePairs) {
+ Map vars = new HashMap<>();
+ for (int i = 0; i + 1 < nameValuePairs.length; i += 2) {
+ vars.put(nameValuePairs[i], nameValuePairs[i + 1] == null ? "" : nameValuePairs[i + 1]);
+ }
+ return vars;
+ }
+}
diff --git a/ts3-client/core/src/main/java/com/ts3client/sound/SoundPack.java b/ts3-client/core/src/main/java/com/ts3client/sound/SoundPack.java
new file mode 100644
index 0000000..02c4278
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/sound/SoundPack.java
@@ -0,0 +1,158 @@
+package com.ts3client.sound;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.EnumMap;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * A TeamSpeak sound pack: a directory of wave files plus a {@code settings.ini}
+ * mapping {@link SoundEvent}s to {@link SoundScript}s.
+ *
+ * The format is TeamSpeak's, so the packs shipped with the official client
+ * (and the ones on its add-on site) can be used as they are:
+ *
+ *
+ * [info]
+ * name = Default Sound Pack (Female)
+ * version = 1.0
+ * author = TeamSpeak Systems GmbH
+ *
+ * [soundfiles]
+ * CONNECTION_CONNECTED = play("connected.wav")
+ * CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS = play("${clientType}_moved_tocurrentchannel.wav")
+ * CHANNEL_CREATED_BY_OTHER =
+ *
+ */
+public final class SoundPack {
+
+ private static final String INI = "settings.ini";
+
+ private final String id;
+ private final File directory;
+ private final String name;
+ private final String version;
+ private final String author;
+ private final Map scripts;
+
+ private SoundPack(String id, File directory, String name, String version, String author,
+ Map scripts) {
+ this.id = id;
+ this.directory = directory;
+ this.name = name;
+ this.version = version;
+ this.author = author;
+ this.scripts = scripts;
+ }
+
+ /** Stable identifier used in the settings: the pack's directory name. */
+ public String id() {
+ return id;
+ }
+
+ public File directory() {
+ return directory;
+ }
+
+ public String name() {
+ return name;
+ }
+
+ public String version() {
+ return version;
+ }
+
+ public String author() {
+ return author;
+ }
+
+ /** @return what this pack does for the event, or {@code null} when it stays silent */
+ public SoundScript script(SoundEvent event) {
+ return scripts.get(event);
+ }
+
+ /**
+ * Resolves the wave file for a {@code play(...)} script. Pack authors write the
+ * names as they appear on their (often case-insensitive) file system, so a
+ * mismatched case is retried against the directory listing.
+ *
+ * @return the file, or {@code null} if the pack does not contain it
+ */
+ public File file(String fileName) {
+ if (fileName == null || fileName.isEmpty()) return null;
+ File direct = new File(directory, fileName);
+ if (direct.isFile()) return direct;
+
+ File[] entries = directory.listFiles();
+ if (entries != null) {
+ for (File f : entries) {
+ if (f.isFile() && f.getName().equalsIgnoreCase(fileName)) return f;
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+
+ /**
+ * Reads a pack from a directory holding a {@code settings.ini}.
+ *
+ * @return the pack, or {@code null} when the directory is not one
+ */
+ public static SoundPack load(File directory) {
+ if (directory == null || !directory.isDirectory()) return null;
+ File ini = new File(directory, INI);
+ if (!ini.isFile()) {
+ File[] entries = directory.listFiles();
+ if (entries != null) {
+ for (File f : entries) {
+ if (f.isFile() && f.getName().equalsIgnoreCase(INI)) ini = f;
+ }
+ }
+ if (!ini.isFile()) return null;
+ }
+
+ Map scripts = new EnumMap<>(SoundEvent.class);
+ Map info = new java.util.HashMap<>();
+ try (BufferedReader in = new BufferedReader(
+ new InputStreamReader(Files.newInputStream(ini.toPath()), 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();
+
+ if ("info".equals(section)) {
+ info.put(key.toLowerCase(Locale.ROOT), value);
+ continue;
+ }
+ SoundEvent event = SoundEvent.byId(key);
+ SoundScript script = SoundScript.parse(value);
+ if (event != null && script != null) scripts.put(event, script);
+ }
+ } catch (IOException e) {
+ return null;
+ }
+
+ String id = directory.getName();
+ String name = info.getOrDefault("name", id);
+ return new SoundPack(id, directory, name,
+ info.getOrDefault("version", ""), info.getOrDefault("author", ""), scripts);
+ }
+}
diff --git a/ts3-client/core/src/main/java/com/ts3client/sound/SoundPacks.java b/ts3-client/core/src/main/java/com/ts3client/sound/SoundPacks.java
new file mode 100644
index 0000000..b82242b
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/sound/SoundPacks.java
@@ -0,0 +1,89 @@
+package com.ts3client.sound;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Finds the sound packs installed on this machine: the client's own
+ * {@code sound/} folder, an extra folder the user configured, and the
+ * {@code sound/} folder of an installed TeamSpeak 3 client (whose packs are
+ * usable as they are, see {@link SoundPack}).
+ */
+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() {
+ }
+
+ /**
+ * Loads every pack found under {@code extraDirectory} and the well-known
+ * locations, newest search root winning on a name clash.
+ *
+ * @param userDirectory the client's own pack folder (may not exist yet)
+ * @param extraDirectory an additional folder chosen by the user, or {@code null}
+ * @return the packs, ordered by display name
+ */
+ public static List findAll(File userDirectory, String extraDirectory) {
+ Map byId = new LinkedHashMap<>();
+ for (File root : searchRoots(userDirectory, extraDirectory)) {
+ File[] entries = root.listFiles(File::isDirectory);
+ if (entries == null) continue;
+ for (File dir : entries) {
+ SoundPack pack = SoundPack.load(dir);
+ if (pack != null) byId.putIfAbsent(pack.id(), pack);
+ }
+ }
+ List packs = new ArrayList<>(byId.values());
+ packs.sort(Comparator.comparing(p -> p.name().toLowerCase(java.util.Locale.ROOT)));
+ return packs;
+ }
+
+ /** @return the pack with this id, or the first one available, or {@code null} */
+ public static SoundPack select(List packs, String id) {
+ for (SoundPack p : packs) {
+ if (p.id().equals(id)) return p;
+ }
+ return packs.isEmpty() ? null : packs.get(0);
+ }
+
+ private static List searchRoots(File userDirectory, String extraDirectory) {
+ List roots = new ArrayList<>();
+ if (userDirectory != null) roots.add(userDirectory);
+ if (extraDirectory != null && !extraDirectory.isBlank()) roots.add(new File(extraDirectory.trim()));
+
+ String configured = System.getProperty(CLIENT_DIR_PROPERTY, System.getenv(CLIENT_DIR_ENV));
+ if (configured != null && !configured.isBlank()) roots.add(new File(configured.trim(), "sound"));
+
+ String home = System.getProperty("user.home", ".");
+ for (String candidate : new String[]{
+ "TeamSpeak3-Client-linux_amd64", "TeamSpeak3-Client-linux_x86",
+ ".local/share/TeamSpeak3-Client-linux_amd64", "Applications/TeamSpeak3-Client-linux_amd64"}) {
+ roots.add(new File(new File(home, candidate), "sound"));
+ }
+ for (String candidate : new String[]{
+ "/opt/teamspeak3-client", "/opt/teamspeak3", "/usr/share/teamspeak3",
+ "/usr/share/teamspeak3-client"}) {
+ roots.add(new File(candidate, "sound"));
+ }
+ String programFiles = System.getenv("ProgramFiles");
+ if (programFiles != null) {
+ roots.add(new File(programFiles, "TeamSpeak 3 Client" + File.separator + "sound"));
+ }
+ // Running from a checkout that has the official client unpacked next to it.
+ roots.add(new File("TeamSpeak3-Client-linux_amd64/sound"));
+ roots.add(new File("../TeamSpeak3-Client-linux_amd64/sound"));
+
+ List existing = new ArrayList<>();
+ for (File root : roots) {
+ if (root.isDirectory()) existing.add(root);
+ }
+ return existing;
+ }
+}
diff --git a/ts3-client/core/src/main/java/com/ts3client/sound/SoundPlayer.java b/ts3-client/core/src/main/java/com/ts3client/sound/SoundPlayer.java
new file mode 100644
index 0000000..11e3e14
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/sound/SoundPlayer.java
@@ -0,0 +1,33 @@
+package com.ts3client.sound;
+
+import java.io.File;
+
+/**
+ * Renders notification sounds. Kept separate from the voice pipeline so the core
+ * stays free of any concrete audio stack; the desktop frontend supplies an
+ * implementation that mixes onto the configured playback device.
+ */
+public interface SoundPlayer {
+
+ /**
+ * Plays a wave file, mixing it with anything already playing.
+ *
+ * @param volume linear gain, 0..1
+ */
+ void play(File file, double volume);
+
+ /** Speaks a sentence from a text-to-speech pack; a no-op where TTS is unavailable. */
+ default void say(String text, double volume) {
+ }
+
+ /** Whether {@link #say} actually produces sound on this platform. */
+ default boolean canSpeak() {
+ return false;
+ }
+
+ /** Follows the playback device selected in the options. */
+ default void setOutputDevice(String device) {
+ }
+
+ void shutdown();
+}
diff --git a/ts3-client/core/src/main/java/com/ts3client/sound/SoundScript.java b/ts3-client/core/src/main/java/com/ts3client/sound/SoundScript.java
new file mode 100644
index 0000000..b418d8d
--- /dev/null
+++ b/ts3-client/core/src/main/java/com/ts3client/sound/SoundScript.java
@@ -0,0 +1,60 @@
+package com.ts3client.sound;
+
+import java.util.Map;
+
+/**
+ * One action from a sound pack: either {@code play("file.wav")} or
+ * {@code say("some text")}, with TeamSpeak's {@code ${variable}} placeholders
+ * still in place until {@link #resolve(Map)} fills them in.
+ */
+public record SoundScript(Kind kind, String argument) {
+
+ public enum Kind {
+ /** Play a wave file from the pack's directory. */
+ PLAY,
+ /** Speak a sentence (text-to-speech packs). */
+ SAY
+ }
+
+ /**
+ * Parses the right-hand side of a {@code settings.ini} entry.
+ *
+ * @return the script, or {@code null} for an empty value (the pack stays silent
+ * for that event) or one whose function we don't know
+ */
+ public static SoundScript parse(String value) {
+ if (value == null) return null;
+ String v = value.trim();
+ if (v.isEmpty()) return null;
+
+ Kind kind = v.startsWith("play(") ? Kind.PLAY : v.startsWith("say(") ? Kind.SAY : null;
+ if (kind == null) return null;
+
+ int open = v.indexOf('(');
+ int close = v.lastIndexOf(')');
+ if (close <= open) return null;
+ String arg = v.substring(open + 1, close).trim();
+ if (arg.length() >= 2 && arg.startsWith("\"") && arg.endsWith("\"")) {
+ arg = arg.substring(1, arg.length() - 1);
+ }
+ return arg.isEmpty() ? null : new SoundScript(kind, arg);
+ }
+
+ /** Substitutes {@code ${name}} placeholders; unknown ones resolve to an empty string. */
+ public String resolve(Map variables) {
+ String text = argument;
+ int from = 0;
+ StringBuilder out = new StringBuilder();
+ while (true) {
+ int start = text.indexOf("${", from);
+ if (start < 0) break;
+ int end = text.indexOf('}', start);
+ if (end < 0) break;
+ String name = text.substring(start + 2, end);
+ String value = variables == null ? null : variables.get(name);
+ out.append(text, from, start).append(value == null ? "" : value);
+ from = end + 1;
+ }
+ return out.append(text.substring(from)).toString();
+ }
+}
diff --git a/ts3-client/core/src/test/java/com/ts3client/net/EventFieldsTest.java b/ts3-client/core/src/test/java/com/ts3client/net/EventFieldsTest.java
new file mode 100644
index 0000000..9503a1f
--- /dev/null
+++ b/ts3-client/core/src/test/java/com/ts3client/net/EventFieldsTest.java
@@ -0,0 +1,25 @@
+package com.ts3client.net;
+
+import com.github.manevolent.ts3j.event.ClientUpdatedEvent;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * Pins the ts3j behaviour {@code TeamspeakConnection.has(...)} exists for: a field
+ * the event never carried reads back as an empty string, so a null check would treat
+ * every partial update (someone muting, say) as if it reported every other field too.
+ */
+class EventFieldsTest {
+
+ @Test
+ void missingFieldsReadBackAsEmptyStringsRatherThanNull() {
+ ClientUpdatedEvent e = new ClientUpdatedEvent(Map.of("clid", "7", "client_input_muted", "1"));
+ assertNotNull(e.get("client_is_recording"));
+ assertEquals("", e.get("client_is_recording"));
+ assertEquals("", e.get("client_nickname"));
+ }
+}
diff --git a/ts3-client/core/src/test/java/com/ts3client/sound/NotificationSettingsTest.java b/ts3-client/core/src/test/java/com/ts3client/sound/NotificationSettingsTest.java
new file mode 100644
index 0000000..0ce3e73
--- /dev/null
+++ b/ts3-client/core/src/test/java/com/ts3client/sound/NotificationSettingsTest.java
@@ -0,0 +1,51 @@
+package com.ts3client.sound;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Properties;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class NotificationSettingsTest {
+
+ @Test
+ void defaultsFollowTheEventCatalogue() {
+ NotificationSettings s = new NotificationSettings();
+ assertTrue(s.isImportant(SoundEvent.OTHER_RECEIVED_POKE));
+ assertFalse(s.isImportant(SoundEvent.YOU_SWITCHED_CHANNEL));
+ assertTrue(s.isEnabled(SoundEvent.YOU_SWITCHED_CHANNEL));
+ assertFalse(s.isEnabled(SoundEvent.SPECIAL_MIC_CLICK_SELF_ON));
+ }
+
+ @Test
+ void onlyDeviationsArePersisted() {
+ NotificationSettings s = new NotificationSettings();
+ s.setImportant(SoundEvent.YOU_SWITCHED_CHANNEL, true);
+ s.setEnabled(SoundEvent.CHAT_RECEIVED_MESSAGE_CHANNEL, false);
+
+ Properties props = new Properties();
+ s.store(props);
+ assertEquals(2, props.size());
+
+ NotificationSettings loaded = new NotificationSettings();
+ loaded.load(props);
+ assertTrue(loaded.isImportant(SoundEvent.YOU_SWITCHED_CHANNEL));
+ assertFalse(loaded.isEnabled(SoundEvent.CHAT_RECEIVED_MESSAGE_CHANNEL));
+ assertTrue(loaded.isImportant(SoundEvent.OTHER_RECEIVED_POKE));
+ }
+
+ @Test
+ void storeClearsStaleEntries() {
+ Properties props = new Properties();
+ NotificationSettings s = new NotificationSettings();
+ s.setEnabled(SoundEvent.SERVER_ERROR, false);
+ s.store(props);
+ assertEquals(1, props.size());
+
+ s.setEnabled(SoundEvent.SERVER_ERROR, true);
+ s.store(props);
+ assertTrue(props.isEmpty());
+ }
+}
diff --git a/ts3-client/core/src/test/java/com/ts3client/sound/SoundPackTest.java b/ts3-client/core/src/test/java/com/ts3client/sound/SoundPackTest.java
new file mode 100644
index 0000000..c794460
--- /dev/null
+++ b/ts3-client/core/src/test/java/com/ts3client/sound/SoundPackTest.java
@@ -0,0 +1,89 @@
+package com.ts3client.sound;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class SoundPackTest {
+
+ private static final String INI = """
+ [info]
+ # a comment
+ name = Test Pack
+ version = 1.0
+ author = Somebody
+
+ [soundfiles]
+ CONNECTION_CONNECTED = play("connected.wav")
+ CONNECTION_DISCONNECTED =
+ CHANNEL_CREATED_BY_OTHER = say("Channel ${channelname} was created by ${clientname}")
+ CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS = play("${clientType}_moved_tocurrentchannel.wav")
+ NOT_AN_EVENT = play("nothing.wav")
+ """;
+
+ private SoundPack load(Path dir) throws Exception {
+ Files.writeString(dir.resolve("settings.ini"), INI, StandardCharsets.UTF_8);
+ Files.writeString(dir.resolve("connected.wav"), "not really audio");
+ Files.writeString(dir.resolve("Neutral_Moved_ToCurrentChannel.wav"), "not really audio");
+ SoundPack pack = SoundPack.load(dir.toFile());
+ assertNotNull(pack);
+ return pack;
+ }
+
+ @Test
+ void readsPackInfo(@TempDir Path dir) throws Exception {
+ SoundPack pack = load(dir);
+ assertEquals(dir.getFileName().toString(), pack.id());
+ assertEquals("Test Pack", pack.name());
+ assertEquals("Somebody", pack.author());
+ assertEquals("1.0", pack.version());
+ }
+
+ @Test
+ void mapsEventsAndSkipsEmptyOnes(@TempDir Path dir) throws Exception {
+ SoundPack pack = load(dir);
+ assertEquals(new SoundScript(SoundScript.Kind.PLAY, "connected.wav"),
+ pack.script(SoundEvent.CONNECTION_CONNECTED));
+ assertNull(pack.script(SoundEvent.CONNECTION_DISCONNECTED));
+ assertEquals(SoundScript.Kind.SAY, pack.script(SoundEvent.CHANNEL_CREATED_BY_OTHER).kind());
+ }
+
+ @Test
+ void resolvesVariables(@TempDir Path dir) throws Exception {
+ SoundPack pack = load(dir);
+ String spoken = pack.script(SoundEvent.CHANNEL_CREATED_BY_OTHER)
+ .resolve(Map.of("channelname", "Lobby", "clientname", "Bob"));
+ assertEquals("Channel Lobby was created by Bob", spoken);
+ }
+
+ @Test
+ void findsFilesRegardlessOfCase(@TempDir Path dir) throws Exception {
+ SoundPack pack = load(dir);
+ String name = pack.script(SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS)
+ .resolve(Map.of("clientType", "neutral"));
+ assertEquals("neutral_moved_tocurrentchannel.wav", name);
+ File file = pack.file(name);
+ assertNotNull(file);
+ assertEquals("Neutral_Moved_ToCurrentChannel.wav", file.getName());
+ }
+
+ @Test
+ void unknownEntriesAreIgnored(@TempDir Path dir) throws Exception {
+ SoundPack pack = load(dir);
+ assertNull(pack.file("nothing.wav"));
+ }
+
+ @Test
+ void directoryWithoutIniIsNotAPack(@TempDir Path dir) {
+ assertNull(SoundPack.load(dir.toFile()));
+ }
+}
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopAudioBackend.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopAudioBackend.java
index 4028bdc..f01f494 100644
--- a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopAudioBackend.java
+++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopAudioBackend.java
@@ -5,6 +5,7 @@ import com.ts3client.audio.VoiceInput;
import com.ts3client.audio.VoiceOutput;
import com.ts3client.audio.desktop.pipewire.PipeWire;
import com.ts3client.config.Settings;
+import com.ts3client.sound.SoundPlayer;
/**
* Desktop audio backend: PipeWire for capture/playback where it is running, Java Sound
@@ -22,6 +23,11 @@ public final class DesktopAudioBackend implements AudioBackend {
return new DesktopVoiceOutput(settings.outputDevice);
}
+ @Override
+ public SoundPlayer createSoundPlayer(Settings settings) {
+ return new WavSoundPlayer(settings.outputDevice);
+ }
+
@Override
public String description() {
return codec() + ", " + audioSystem();
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceInput.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceInput.java
index d3db47e..9938522 100644
--- a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceInput.java
+++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/DesktopVoiceInput.java
@@ -49,6 +49,8 @@ public final class DesktopVoiceInput implements VoiceInput {
private volatile Consumer levelListener; // input level in dBFS
private volatile Consumer talkListener; // local talk-state changes
+ private volatile Runnable mutedTalkListener; // speech detected while muted
+ private boolean mutedTalking;
private final String deviceName;
@@ -193,6 +195,11 @@ public final class DesktopVoiceInput implements VoiceInput {
this.talkListener = l;
}
+ @Override
+ public void setMutedTalkListener(Runnable l) {
+ this.mutedTalkListener = l;
+ }
+
public void setPushToTalk(boolean down) {
this.pttDown.set(down);
}
@@ -329,8 +336,10 @@ public final class DesktopVoiceInput implements VoiceInput {
private boolean decideGate(double db, float[] pcm) {
if (muted.get()) {
hangover = 0;
+ detectMutedSpeech(db);
return false;
}
+ mutedTalking = false;
switch (mode) {
case CONTINUOUS:
return true;
@@ -373,6 +382,19 @@ public final class DesktopVoiceInput implements VoiceInput {
return false;
}
+ /**
+ * Reports the start of a talk burst that the mute is swallowing. The volume gate
+ * alone decides here: the speech detector's state is kept for real transmission.
+ */
+ private void detectMutedSpeech(double db) {
+ boolean talking = db >= thresholdDb;
+ if (talking && !mutedTalking) {
+ Runnable listener = mutedTalkListener;
+ if (listener != null) listener.run();
+ }
+ mutedTalking = talking;
+ }
+
private void setTransmitting(boolean t) {
transmitting.set(t);
if (t != lastTransmitting) {
diff --git a/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/WavSoundPlayer.java b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/WavSoundPlayer.java
new file mode 100644
index 0000000..be0479b
--- /dev/null
+++ b/ts3-client/desktop/src/main/java/com/ts3client/audio/desktop/WavSoundPlayer.java
@@ -0,0 +1,226 @@
+package com.ts3client.audio.desktop;
+
+import com.ts3client.sound.SoundPlayer;
+
+import javax.sound.sampled.AudioFormat;
+import javax.sound.sampled.AudioInputStream;
+import javax.sound.sampled.AudioSystem;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Desktop {@link SoundPlayer}: plays sound-pack wave files on the configured
+ * playback device.
+ *
+ * All notification sounds share one playback line and are mixed together, so
+ * two events firing at once never fight over the device and the line only exists
+ * while something is actually playing. Files are decoded once, resampled to the
+ * device's 48 kHz and cached, so repeat events cost nothing but the mix.
+ */
+public final class WavSoundPlayer implements SoundPlayer {
+
+ /** How long the playback line is kept open after the last sound, in mixer frames. */
+ private static final int IDLE_FRAMES = 150; // 3 s of 20 ms frames
+ /** Sound files are small; this caps the decoded cache anyway. */
+ private static final int MAX_CACHED_FILES = 64;
+
+ /** A decoded, 48 kHz mono sound being rendered. */
+ private static final class Voice {
+ final float[] pcm;
+ final double volume;
+ int position;
+
+ Voice(float[] pcm, double volume) {
+ this.pcm = pcm;
+ this.volume = volume;
+ }
+ }
+
+ private final Map cache = new LinkedHashMap<>(16, 0.75f, true) {
+ @Override
+ protected boolean removeEldestEntry(Map.Entry eldest) {
+ return size() > MAX_CACHED_FILES;
+ }
+ };
+
+ private final List voices = new ArrayList<>();
+ private final Object lock = new Object();
+
+ private volatile String outputDevice;
+ private volatile boolean running = true;
+ private Thread mixer;
+
+ public WavSoundPlayer(String outputDevice) {
+ this.outputDevice = outputDevice;
+ }
+
+ @Override
+ public void setOutputDevice(String device) {
+ this.outputDevice = device == null ? "" : device;
+ }
+
+ @Override
+ public void play(File file, double volume) {
+ if (file == null || !file.isFile() || volume <= 0) return;
+ float[] pcm;
+ try {
+ pcm = decode(file);
+ } catch (Exception e) {
+ return; // unplayable file: stay silent rather than break the action
+ }
+ if (pcm.length == 0) return;
+
+ synchronized (lock) {
+ if (!running) return;
+ voices.add(new Voice(pcm, Math.min(1.0, volume)));
+ if (mixer == null) {
+ mixer = new Thread(this::mixLoop, "ts3j-sounds");
+ mixer.setDaemon(true);
+ mixer.start();
+ }
+ lock.notifyAll();
+ }
+ }
+
+ @Override
+ public void shutdown() {
+ synchronized (lock) {
+ running = false;
+ voices.clear();
+ lock.notifyAll();
+ }
+ }
+
+ // ---- mixing ----
+
+ /**
+ * Renders the active sounds onto one line until everything has been quiet for
+ * {@link #IDLE_FRAMES}, then gives the device back.
+ */
+ private void mixLoop() {
+ AudioPlayback line = null;
+ try {
+ int idle = 0;
+ float[] mix = new float[AudioDevices.FRAME_SIZE];
+ while (true) {
+ synchronized (lock) {
+ if (!running) return;
+ if (voices.isEmpty()) {
+ if (++idle > IDLE_FRAMES) {
+ mixer = null;
+ return;
+ }
+ } else {
+ idle = 0;
+ }
+ }
+ if (line == null) {
+ line = AudioDevices.openPlayback(outputDevice, AudioDevices.MAX_CHANNELS);
+ line.start();
+ }
+ // Silence keeps the line running (and paces this loop) until it is closed.
+ renderFrame(mix);
+ line.write(toBytes(mix, line.channels()), 0, mix.length * 2 * line.channels());
+ }
+ } catch (Exception e) {
+ synchronized (lock) {
+ voices.clear();
+ mixer = null;
+ }
+ } finally {
+ if (line != null) line.close();
+ }
+ }
+
+ /** Sums the active sounds into {@code mix}, dropping the ones that ran out. */
+ private void renderFrame(float[] mix) {
+ java.util.Arrays.fill(mix, 0f);
+ synchronized (lock) {
+ for (java.util.Iterator it = voices.iterator(); it.hasNext(); ) {
+ Voice v = it.next();
+ int n = Math.min(mix.length, v.pcm.length - v.position);
+ for (int i = 0; i < n; i++) {
+ mix[i] += v.pcm[v.position + i] * v.volume;
+ }
+ v.position += n;
+ if (v.position >= v.pcm.length) it.remove();
+ }
+ }
+ }
+
+ /** Interleaves the mono mix across the line's channels as signed 16-bit LE. */
+ private static byte[] toBytes(float[] mix, int channels) {
+ byte[] out = new byte[mix.length * 2 * channels];
+ for (int i = 0, k = 0; i < mix.length; i++) {
+ float v = Math.max(-1f, Math.min(1f, mix[i]));
+ short s = (short) Math.round(v * 32767.0);
+ for (int c = 0; c < channels; c++, k += 2) {
+ out[k] = (byte) (s & 0xFF);
+ out[k + 1] = (byte) ((s >> 8) & 0xFF);
+ }
+ }
+ return out;
+ }
+
+ // ---- decoding ----
+
+ private float[] decode(File file) throws Exception {
+ String key = file.getAbsolutePath() + '@' + file.lastModified();
+ synchronized (cache) {
+ float[] cached = cache.get(key);
+ if (cached != null) return cached;
+ }
+ float[] pcm = readMono48k(file);
+ synchronized (cache) {
+ cache.put(key, pcm);
+ }
+ return pcm;
+ }
+
+ /**
+ * Decodes a sound file to mono 48 kHz float samples. Packs ship 44.1 kHz mono
+ * waves, but nothing stops them from using another rate, depth or encoding, so
+ * the conversion goes through Java Sound and a linear resample.
+ */
+ private static float[] readMono48k(File file) throws Exception {
+ try (AudioInputStream in = AudioSystem.getAudioInputStream(file)) {
+ AudioFormat source = in.getFormat();
+ int channels = Math.max(1, source.getChannels());
+ AudioFormat pcmFormat = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
+ source.getSampleRate(), 16, channels, channels * 2, source.getSampleRate(), false);
+ try (AudioInputStream pcm = AudioSystem.getAudioInputStream(pcmFormat, in)) {
+ byte[] data = pcm.readAllBytes();
+ int frames = data.length / (2 * channels);
+ float[] mono = new float[frames];
+ for (int i = 0; i < frames; i++) {
+ float sum = 0;
+ for (int c = 0; c < channels; c++) {
+ int k = 2 * (i * channels + c);
+ sum += (short) ((data[k + 1] << 8) | (data[k] & 0xFF)) / 32768f;
+ }
+ mono[i] = sum / channels;
+ }
+ return resample(mono, source.getSampleRate(), AudioDevices.SAMPLE_RATE);
+ }
+ }
+ }
+
+ private static float[] resample(float[] input, float fromRate, int toRate) {
+ if (fromRate <= 0 || Math.abs(fromRate - toRate) < 0.5f || input.length == 0) return input;
+ double ratio = toRate / (double) fromRate;
+ int length = (int) (input.length * ratio);
+ float[] out = new float[length];
+ for (int i = 0; i < length; i++) {
+ double position = i / ratio;
+ int index = (int) position;
+ double fraction = position - index;
+ float a = input[Math.min(index, input.length - 1)];
+ float b = input[Math.min(index + 1, input.length - 1)];
+ out[i] = (float) (a + (b - a) * fraction);
+ }
+ return out;
+ }
+}
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java
index 827c32d..a1e7b7f 100644
--- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java
@@ -6,6 +6,8 @@ import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityStore;
import com.ts3client.config.Settings;
+import com.ts3client.sound.SoundNotifier;
+import com.ts3client.sound.SoundPlayer;
import javax.swing.BorderFactory;
import javax.swing.Box;
@@ -50,6 +52,9 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private final Bookmarks bookmarks = Bookmarks.load();
private final IdentityStore identities;
private final AudioBackend audio = new DesktopAudioBackend();
+ /** Sound pack playback, shared by every connection. */
+ private final SoundNotifier sounds;
+ private final SoundPlayer soundPlayer;
private final List tabs = new ArrayList<>();
private final ServerTabPane tabPane = new ServerTabPane(this);
@@ -83,6 +88,9 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
super("TS3J — TeamSpeak 3 Java Client");
this.settings = settings;
this.identities = IdentityStore.load(settings);
+ this.sounds = new SoundNotifier(settings);
+ this.soundPlayer = audio.createSoundPlayer(settings);
+ this.sounds.setPlayer(soundPlayer);
setIconImage(Icons.app().getImage());
// We tear the connections down ourselves on close, so don't let Swing kill the JVM.
@@ -282,7 +290,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
// ---- tab management ----
private ServerTab newTab() {
- ServerTab tab = new ServerTab(this, settings, identities, audio);
+ ServerTab tab = new ServerTab(this, settings, identities, audio, sounds);
tabs.add(tab);
tabPane.addTab(tab);
refreshTabs();
@@ -505,6 +513,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
for (ServerTab tab : new ArrayList<>(tabs)) {
tab.shutdown();
}
+ soundPlayer.shutdown();
}
private void showIdentities() {
@@ -517,12 +526,14 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
SettingsDialog dlg = new SettingsDialog(this, settings,
micTab == null ? null : micTab.connection().getMicrophone(),
selected == null ? null : selected.connection().getPlayback(),
+ sounds,
this::applyOutputSettingsToAllTabs);
dlg.setVisible(true);
}
/** Master volume / output device are global, so push them to every open connection. */
private void applyOutputSettingsToAllTabs() {
+ soundPlayer.setOutputDevice(settings.outputDevice);
for (ServerTab tab : tabs) {
if (tab.connection().getPlayback() == null) continue;
tab.connection().getPlayback().setMasterVolume(settings.outputVolume);
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/NotificationsPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/NotificationsPanel.java
new file mode 100644
index 0000000..38e8be7
--- /dev/null
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/NotificationsPanel.java
@@ -0,0 +1,397 @@
+package com.ts3client.ui;
+
+import com.ts3client.config.Settings;
+import com.ts3client.sound.NotificationSettings;
+import com.ts3client.sound.SoundEvent;
+import com.ts3client.sound.SoundNotifier;
+import com.ts3client.sound.SoundPack;
+
+import javax.swing.BorderFactory;
+import javax.swing.JButton;
+import javax.swing.JComboBox;
+import javax.swing.JComponent;
+import javax.swing.JFileChooser;
+import javax.swing.JLabel;
+import javax.swing.JMenuItem;
+import javax.swing.JPanel;
+import javax.swing.JPopupMenu;
+import javax.swing.JScrollPane;
+import javax.swing.JSlider;
+import javax.swing.JTable;
+import javax.swing.JTextField;
+import javax.swing.ListSelectionModel;
+import javax.swing.table.AbstractTableModel;
+import javax.swing.table.DefaultTableCellRenderer;
+import java.awt.BorderLayout;
+import java.awt.Component;
+import java.awt.Dimension;
+import java.awt.Font;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Options page for sound packs: which pack is active, how loud it is, and what
+ * each action does.
+ *
+ * An action can be switched off entirely, or marked important (shown in
+ * bold) — important actions are the only ones still played while the speakers are
+ * muted, which is how the official client behaves. Actions the active pack has no
+ * sound for are greyed out.
+ */
+final class NotificationsPanel extends JPanel {
+
+ private static final int TOGGLE_COLUMN_WIDTH = 34;
+
+ /** A table row: either a category heading or one action. */
+ private static final class Row {
+ final SoundEvent.Category heading;
+ final SoundEvent event;
+
+ Row(SoundEvent.Category heading, SoundEvent event) {
+ this.heading = heading;
+ this.event = event;
+ }
+
+ boolean isHeading() {
+ return event == null;
+ }
+ }
+
+ private final Settings settings;
+ private final SoundNotifier sounds;
+ /** Edited copy, applied to the settings only when the dialog is confirmed. */
+ private final NotificationSettings working;
+ private final List rows = new ArrayList<>();
+ private final String originalPackId;
+ private final String originalPackDir;
+
+ private final JComboBox packCombo = new JComboBox<>();
+ private final JLabel packInfo = new JLabel();
+ private final JSlider volume;
+ private final JTextField packDirField;
+ private final JTable table;
+ private final EventTableModel tableModel = new EventTableModel();
+
+ NotificationsPanel(Settings settings, SoundNotifier sounds) {
+ super(new BorderLayout(0, 8));
+ this.settings = settings;
+ this.sounds = sounds;
+ this.working = settings.notifications.copy();
+ this.originalPackId = settings.soundPack;
+ this.originalPackDir = settings.soundPackDir;
+ this.volume = new JSlider(0, 100, (int) Math.round(settings.soundVolume * 100));
+ this.packDirField = new JTextField(settings.soundPackDir, 18);
+
+ buildRows();
+ this.table = buildTable();
+
+ setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
+ add(buildHeader(), BorderLayout.NORTH);
+ add(new JScrollPane(table), BorderLayout.CENTER);
+ add(buildFooter(), BorderLayout.SOUTH);
+
+ reloadPacks();
+ }
+
+ /** Copies the edited state into the settings; the caller saves them. */
+ void apply() {
+ settings.notifications.copyFrom(working);
+ settings.soundVolume = volume.getValue() / 100.0;
+ String directory = packDirField.getText().trim();
+ boolean rescan = !directory.equals(settings.soundPackDir);
+ settings.soundPackDir = directory;
+ sounds.setPack((SoundPack) packCombo.getSelectedItem());
+ if (rescan) sounds.reload();
+ }
+
+ /** Puts back the pack (and folder) the dialog started with, for a cancelled edit. */
+ void revert() {
+ settings.soundPackDir = originalPackDir;
+ settings.soundPack = originalPackId;
+ sounds.reload();
+ }
+
+ // ---- 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("Sound packs installed here or in a TeamSpeak 3 client");
+ packCombo.addActionListener(e -> onPackSelected());
+ JButton test = new JButton("Test");
+ test.setToolTipText("Play this pack's test sound");
+ test.addActionListener(e -> sounds.preview(SoundEvent.SPECIAL_SOUND_TEST));
+
+ JPanel packRow = new JPanel(new BorderLayout(6, 0));
+ packRow.add(packCombo, BorderLayout.CENTER);
+ packRow.add(test, BorderLayout.EAST);
+
+ int row = 0;
+ addRow(p, c, row++, new JLabel("Sound pack:"), packRow);
+ c.gridx = 1;
+ c.gridy = row++;
+ packInfo.setEnabled(false);
+ p.add(packInfo, c);
+
+ volume.setToolTipText("Volume of the notification sounds");
+ addRow(p, c, row++, new JLabel("Sound volume:"), volume);
+
+ 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 sound packs in");
+ addRow(p, c, row, new JLabel("Extra pack folder:"), dirRow);
+ return p;
+ }
+
+ private JComponent buildFooter() {
+ JPanel p = new JPanel(new BorderLayout(6, 0));
+ JLabel hint = new JLabel("Bold actions are important: they are still played "
+ + "while your speakers are muted.");
+ hint.setBorder(BorderFactory.createEmptyBorder(4, 0, 0, 0));
+
+ JPanel buttons = new JPanel();
+ JButton important = new JButton("Toggle important");
+ important.addActionListener(e -> toggleImportant());
+ JButton play = new JButton("Play");
+ play.addActionListener(e -> previewSelected());
+ JButton defaults = new JButton("Reset");
+ defaults.setToolTipText("Restore the default notification settings");
+ defaults.addActionListener(e -> {
+ working.resetToDefaults();
+ tableModel.fireTableDataChanged();
+ });
+ buttons.add(play);
+ buttons.add(important);
+ buttons.add(defaults);
+
+ p.add(hint, BorderLayout.CENTER);
+ p.add(buttons, BorderLayout.EAST);
+ return p;
+ }
+
+ private JTable buildTable() {
+ JTable t = new JTable(tableModel);
+ t.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
+ t.setShowGrid(false);
+ t.setTableHeader(null);
+ t.setRowHeight(Math.max(t.getRowHeight(), t.getFontMetrics(t.getFont()).getHeight() + 6));
+ t.getColumnModel().getColumn(0).setMaxWidth(TOGGLE_COLUMN_WIDTH);
+ t.getColumnModel().getColumn(0).setMinWidth(TOGGLE_COLUMN_WIDTH);
+ t.getColumnModel().getColumn(0).setCellRenderer(new ToggleRenderer(t.getDefaultRenderer(Boolean.class)));
+ t.getColumnModel().getColumn(1).setCellRenderer(new ActionRenderer());
+ t.setPreferredScrollableViewportSize(new Dimension(360, 260));
+
+ t.addMouseListener(new MouseAdapter() {
+ @Override
+ public void mouseClicked(MouseEvent e) {
+ if (e.getClickCount() == 2 && e.getButton() == MouseEvent.BUTTON1) previewSelected();
+ }
+
+ @Override
+ public void mousePressed(MouseEvent e) {
+ showMenu(e);
+ }
+
+ @Override
+ public void mouseReleased(MouseEvent e) {
+ showMenu(e);
+ }
+
+ private void showMenu(MouseEvent e) {
+ if (!e.isPopupTrigger()) return;
+ int row = t.rowAtPoint(e.getPoint());
+ if (row < 0 || rows.get(row).isHeading()) return;
+ t.setRowSelectionInterval(row, row);
+ contextMenu(rows.get(row).event).show(t, e.getX(), e.getY());
+ }
+ });
+ return t;
+ }
+
+ private JPopupMenu contextMenu(SoundEvent event) {
+ JPopupMenu menu = new JPopupMenu();
+ JMenuItem important = new JMenuItem(working.isImportant(event) ? "Mark as Unimportant" : "Mark as Important");
+ important.addActionListener(e -> toggleImportant());
+ JMenuItem enabled = new JMenuItem(working.isEnabled(event) ? "Turn sound off" : "Turn sound on");
+ enabled.addActionListener(e -> {
+ working.setEnabled(event, !working.isEnabled(event));
+ tableModel.fireTableRowsUpdated(0, rows.size() - 1);
+ });
+ JMenuItem play = new JMenuItem("Play sound");
+ play.addActionListener(e -> sounds.preview(event));
+ menu.add(play);
+ menu.addSeparator();
+ menu.add(enabled);
+ menu.add(important);
+ return menu;
+ }
+
+ // ---- actions ----
+
+ private void onPackSelected() {
+ SoundPack pack = (SoundPack) packCombo.getSelectedItem();
+ sounds.setPack(pack);
+ packInfo.setText(pack == null ? "No sound packs found"
+ : "by " + (pack.author().isEmpty() ? "unknown" : pack.author())
+ + (pack.version().isEmpty() ? "" : ", version " + pack.version()));
+ tableModel.fireTableDataChanged();
+ }
+
+ private void reloadPacks() {
+ sounds.reload();
+ packCombo.removeAllItems();
+ for (SoundPack pack : sounds.availablePacks()) {
+ packCombo.addItem(pack);
+ }
+ packCombo.setSelectedItem(sounds.pack());
+ onPackSelected();
+ }
+
+ private void browseForPackFolder() {
+ JFileChooser chooser = new JFileChooser();
+ chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
+ chooser.setDialogTitle("Folder containing sound 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.soundPackDir = packDirField.getText();
+ reloadPacks();
+ }
+
+ private SoundEvent selectedEvent() {
+ int row = table.getSelectedRow();
+ return row < 0 || rows.get(row).isHeading() ? null : rows.get(row).event;
+ }
+
+ private void previewSelected() {
+ SoundEvent event = selectedEvent();
+ if (event != null) sounds.preview(event);
+ }
+
+ private void toggleImportant() {
+ SoundEvent event = selectedEvent();
+ if (event == null) return;
+ working.setImportant(event, !working.isImportant(event));
+ tableModel.fireTableRowsUpdated(table.getSelectedRow(), table.getSelectedRow());
+ }
+
+ private void buildRows() {
+ for (SoundEvent.Category category : SoundEvent.Category.values()) {
+ rows.add(new Row(category, null));
+ for (SoundEvent event : SoundEvent.values()) {
+ if (event.category() == category) rows.add(new Row(category, event));
+ }
+ }
+ }
+
+ 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);
+ }
+
+ // ---- table plumbing ----
+
+ private final class EventTableModel extends AbstractTableModel {
+
+ @Override
+ public int getRowCount() {
+ return rows.size();
+ }
+
+ @Override
+ public int getColumnCount() {
+ return 2;
+ }
+
+ @Override
+ public Class> getColumnClass(int column) {
+ return column == 0 ? Boolean.class : String.class;
+ }
+
+ @Override
+ public Object getValueAt(int rowIndex, int column) {
+ Row row = rows.get(rowIndex);
+ if (column == 0) return row.isHeading() ? Boolean.FALSE : working.isEnabled(row.event);
+ return row.isHeading() ? row.heading.label() : row.event.label();
+ }
+
+ @Override
+ public boolean isCellEditable(int rowIndex, int column) {
+ return column == 0 && !rows.get(rowIndex).isHeading();
+ }
+
+ @Override
+ public void setValueAt(Object value, int rowIndex, int column) {
+ Row row = rows.get(rowIndex);
+ if (column == 0 && !row.isHeading()) {
+ working.setEnabled(row.event, Boolean.TRUE.equals(value));
+ }
+ }
+ }
+
+ /** Only actions get a checkbox; a category heading spans an empty cell. */
+ private final class ToggleRenderer implements javax.swing.table.TableCellRenderer {
+
+ private final javax.swing.table.TableCellRenderer checkBox;
+ private final DefaultTableCellRenderer blank = new DefaultTableCellRenderer();
+
+ ToggleRenderer(javax.swing.table.TableCellRenderer checkBox) {
+ this.checkBox = checkBox;
+ }
+
+ @Override
+ public Component getTableCellRendererComponent(JTable table, Object value, boolean selected,
+ boolean focused, int rowIndex, int column) {
+ javax.swing.table.TableCellRenderer delegate = rows.get(rowIndex).isHeading() ? blank : checkBox;
+ return delegate.getTableCellRendererComponent(table,
+ delegate == blank ? "" : value, selected, focused, rowIndex, column);
+ }
+ }
+
+ /** Headings stand out, important actions are bold, unmapped ones are greyed. */
+ private final class ActionRenderer extends DefaultTableCellRenderer {
+
+ @Override
+ public Component getTableCellRendererComponent(JTable table, Object value, boolean selected,
+ boolean focused, int rowIndex, int column) {
+ super.getTableCellRendererComponent(table, value, selected, focused, rowIndex, column);
+ Row row = rows.get(rowIndex);
+ Font base = table.getFont();
+ // The foreground is set on every row: this renderer remembers the last
+ // unselected colour it was given, so a greyed row would tint the rest.
+ if (row.isHeading()) {
+ setFont(base.deriveFont(Font.BOLD));
+ setForeground(selected ? table.getSelectionForeground() : table.getForeground());
+ setBorder(BorderFactory.createEmptyBorder(2, 2, 2, 2));
+ setToolTipText(null);
+ return this;
+ }
+ setBorder(BorderFactory.createEmptyBorder(2, 18, 2, 2));
+ setFont(base.deriveFont(working.isImportant(row.event) ? Font.BOLD : Font.PLAIN));
+ boolean silent = !sounds.hasSound(row.event);
+ setForeground(selected ? table.getSelectionForeground()
+ : silent ? java.awt.Color.GRAY : table.getForeground());
+ setToolTipText(silent ? "This sound pack has no sound for this action" : null);
+ return this;
+ }
+ }
+}
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java
index 9d52bbf..7510307 100644
--- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java
@@ -8,6 +8,7 @@ import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry;
import com.ts3client.net.ConnectionListener;
import com.ts3client.net.TeamspeakConnection;
+import com.ts3client.sound.SoundNotifier;
import com.ts3client.text.TsLink;
import javax.swing.JComponent;
@@ -52,11 +53,12 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
private Object currentSelection;
- ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio) {
+ ServerTab(MainFrame host, Settings settings, IdentityStore identities, AudioBackend audio,
+ SoundNotifier sounds) {
this.host = host;
this.settings = settings;
this.identities = identities;
- this.conn = new TeamspeakConnection(settings, audio, this);
+ this.conn = new TeamspeakConnection(settings, audio, this, sounds);
this.groupIcons = new GroupIcons(conn.getIcons());
this.treePanel = new ServerTreePanel(conn.getModel(), groupIcons, this);
this.chatPanel = new ChatPanel();
diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java
index 2d66230..34c9c43 100644
--- a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java
+++ b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java
@@ -6,6 +6,7 @@ import com.ts3client.audio.VoiceOutput;
import com.ts3client.audio.desktop.AudioCapture;
import com.ts3client.audio.desktop.AudioDevices;
import com.ts3client.config.Settings;
+import com.ts3client.sound.SoundNotifier;
import javax.swing.BorderFactory;
import javax.swing.Box;
@@ -51,8 +52,11 @@ public final class SettingsDialog extends JDialog {
private final Settings settings;
private final VoiceInput liveMic;
private final VoiceOutput livePlayback;
+ private final SoundNotifier sounds;
private final Runnable onApply;
+ private NotificationsPanel notificationsPanel;
+
private JComboBox inputCombo;
private JComboBox outputCombo;
private JSlider inputGain;
@@ -86,17 +90,20 @@ public final class SettingsDialog extends JDialog {
public SettingsDialog(Frame owner, Settings settings,
VoiceInput liveMic, VoiceOutput livePlayback,
- Runnable onApply) {
+ SoundNotifier sounds, Runnable onApply) {
super(owner, "Options", true);
this.settings = settings;
this.liveMic = liveMic;
this.livePlayback = livePlayback;
+ this.sounds = sounds;
this.onApply = onApply;
this.pttKey = settings.pushToTalkKey;
JTabbedPane tabs = new JTabbedPane();
tabs.addTab("Playback / Capture", scrollable(buildDevicesTab()));
tabs.addTab("Voice Activation", scrollable(buildVoiceTab()));
+ notificationsPanel = new NotificationsPanel(settings, sounds);
+ tabs.addTab("Notifications", notificationsPanel);
JPanel buttons = new JPanel(new BorderLayout());
JPanel right = new JPanel();
@@ -106,7 +113,10 @@ public final class SettingsDialog extends JDialog {
apply();
close();
});
- cancel.addActionListener(e -> close());
+ cancel.addActionListener(e -> {
+ notificationsPanel.revert();
+ close();
+ });
right.add(ok);
right.add(cancel);
buttons.add(right, BorderLayout.EAST);
@@ -538,6 +548,7 @@ public final class SettingsDialog extends JDialog {
settings.vbr = vbrCheck.isSelected();
settings.fec = fecCheck.isSelected();
settings.music = musicCheck.isSelected();
+ notificationsPanel.apply();
settings.save();
if (liveMic != null) {