diff --git a/ts3-client/README.md b/ts3-client/README.md index 067d8bb..6177ef2 100644 --- a/ts3-client/README.md +++ b/ts3-client/README.md @@ -80,6 +80,19 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged. - **Status bar** shows the server name, user count and live ping. - Change your nickname, mute/deafen from the toolbar. +### Notification sounds +- **Sound packs** in TeamSpeak's own format: a folder of waves plus a `settings.ini` + mapping actions (`CONNECTION_CONNECTED`, `CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS`, …) + to `play("file.wav")` entries, with `${clientType}`/`${channelname}`/… placeholders + resolved per event. Packs are picked up from `~/.ts3jclient/sound`, an installed + TeamSpeak 3 client (`$TS3_CLIENT_DIR` or the usual install paths) and a folder of + your choosing, so the official packs work unchanged. +- **Per-action configuration** (Options → Notifications): switch any action's sound + off, and mark actions as **important** (shown in bold, as in TS3) — important + actions are the only ones still played while the speakers are muted. +- Sounds are mixed onto one playback line, so overlapping events never fight over + the device; pack volume is separate from the voice volume. + ## Requirements - Java 26+ (developed/tested on Temurin 26) - The native Opus library on the system: @@ -120,6 +133,13 @@ core/ com.ts3client │ ├── VoiceOutput voice-packet playback sink │ ├── OpusParameters live-tunable encoder settings │ └── SpeechDetector feature-based speech-probability VAD (Automatic/Hybrid) +├── sound +│ ├── SoundEvent catalogue of actions (TeamSpeak's own event ids) +│ ├── SoundPack a pack folder + its settings.ini mapping +│ ├── SoundPacks discovery of installed packs +│ ├── NotificationSettings per-action enabled / important flags +│ ├── SoundNotifier decides what is heard (pack + config + mute state) +│ └── SoundPlayer platform hook for rendering a sound └── net ├── TeamspeakConnection ties socket + audio backend + model, translates events ├── ServerModel thread-safe channel/client state @@ -132,6 +152,7 @@ desktop/ com.ts3client.audio.desktop ├── AudioDevices device enumeration + line opening (48 kHz/16-bit) ├── JavaSoundVoiceInput capture + VAD/PTT gating + Opus encode ├── JavaSoundVoiceOutput per-client Opus decode + playback + mixing +├── WavSoundPlayer sound-pack playback: decode, resample and mix on one line └── JavaSoundAudioBackend wires the above into the core AudioBackend swing/ com.ts3client @@ -143,6 +164,7 @@ swing/ com.ts3client ├── InfoPanel channel description / client group + details view ├── ChatPanel chat log + input ├── SettingsDialog audio + VAD options with live meter + ├── NotificationsPanel sound pack + per-action sound/important configuration ├── ConnectDialog connect form ├── BookmarksDialog manage saved servers ├── IdentitiesDialog manage identities (new/import/export/improve) diff --git a/ts3-client/core/pom.xml b/ts3-client/core/pom.xml index f11d981..724bef6 100644 --- a/ts3-client/core/pom.xml +++ b/ts3-client/core/pom.xml @@ -19,5 +19,11 @@ com.github.manevolent ts3j + + org.junit.jupiter + junit-jupiter + 5.13.4 + test + diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/AudioBackend.java b/ts3-client/core/src/main/java/com/ts3client/audio/AudioBackend.java index 152e55e..e0dec68 100644 --- a/ts3-client/core/src/main/java/com/ts3client/audio/AudioBackend.java +++ b/ts3-client/core/src/main/java/com/ts3client/audio/AudioBackend.java @@ -1,6 +1,7 @@ package com.ts3client.audio; import com.ts3client.config.Settings; +import com.ts3client.sound.SoundPlayer; /** * Factory for a platform's voice capture and playback. Injected into the @@ -12,6 +13,9 @@ public interface AudioBackend { VoiceOutput createOutput(Settings settings); + /** Player for notification sounds (sound packs); shared by all connections. */ + SoundPlayer createSoundPlayer(Settings settings); + /** Human-readable codec/backend description, e.g. for an "about" line. */ String description(); } diff --git a/ts3-client/core/src/main/java/com/ts3client/audio/VoiceInput.java b/ts3-client/core/src/main/java/com/ts3client/audio/VoiceInput.java index 06bf381..5e18c3f 100644 --- a/ts3-client/core/src/main/java/com/ts3client/audio/VoiceInput.java +++ b/ts3-client/core/src/main/java/com/ts3client/audio/VoiceInput.java @@ -55,4 +55,7 @@ public interface VoiceInput extends Microphone { /** Receives local transmit-state transitions (talking / silent). */ void setTalkListener(Consumer listener); + + /** Called when speech is detected while the microphone is muted. */ + void setMutedTalkListener(Runnable listener); } diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java index 3eed341..9d649d2 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java @@ -1,5 +1,7 @@ package com.ts3client.config; +import com.ts3client.sound.NotificationSettings; + import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; @@ -90,6 +92,16 @@ public final class Settings { /** Automatic gain control: normalise microphone loudness to a target level. */ public boolean agc = true; + // ---- notification sounds ---- + /** Directory name of the active sound pack; empty picks the first one found. */ + public String soundPack = "default"; + /** Notification sound volume, 0..1. */ + public double soundVolume = 1.0; + /** Extra folder to look for sound packs in, on top of the well-known locations. */ + public String soundPackDir = ""; + /** Which actions make a sound, and which ones are important enough to survive muting. */ + public final NotificationSettings notifications = new NotificationSettings(); + public static Settings load() { Settings s = new Settings(); try { @@ -154,6 +166,10 @@ public final class Settings { denoiserLevel = parseD(props.getProperty("denoiserLevel"), denoiserLevel); typingAttenuation = parseB(props.getProperty("typingAttenuation"), typingAttenuation); agc = parseB(props.getProperty("agc"), agc); + soundPack = props.getProperty("soundPack", soundPack); + soundVolume = parseD(props.getProperty("soundVolume"), soundVolume); + soundPackDir = props.getProperty("soundPackDir", soundPackDir); + notifications.load(props); } private void writeToProps() { @@ -182,6 +198,10 @@ public final class Settings { props.setProperty("denoiserLevel", Double.toString(denoiserLevel)); props.setProperty("typingAttenuation", Boolean.toString(typingAttenuation)); props.setProperty("agc", Boolean.toString(agc)); + props.setProperty("soundPack", soundPack); + props.setProperty("soundVolume", Double.toString(soundVolume)); + props.setProperty("soundPackDir", soundPackDir); + notifications.store(props); } private static InputMode parseMode(String v, InputMode def) { diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index 0c73640..9cbc77a 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -19,6 +19,8 @@ import com.ts3client.config.Settings; import com.ts3client.net.filetransfer.FileTransfer; import com.ts3client.net.filetransfer.FileTransferManager; import com.ts3client.net.filetransfer.RemoteFile; +import com.ts3client.sound.SoundEvent; +import com.ts3client.sound.SoundNotifier; import java.io.File; import java.net.InetSocketAddress; @@ -36,11 +38,15 @@ public final class TeamspeakConnection implements TS3Listener { /** Upper bound for a downloaded group icon; anything larger is not an icon. */ private static final int MAX_ICON_BYTES = 1024 * 1024; + /** Shortest gap between two "you are talking while muted" reminders. */ + private static final long MUTED_TALK_COOLDOWN_NANOS = 5_000_000_000L; + private final Settings settings; private final AudioBackend audio; private final ServerModel model = new ServerModel(); private final ConnectionListener ui; private final IconRepository icons; + private final SoundNotifier sounds; private LocalTeamspeakClientSocket client; private VoiceInput microphone; @@ -61,13 +67,23 @@ public final class TeamspeakConnection implements TS3Listener { */ private volatile boolean microphoneActive; + /** Speakers muted: only actions marked important are still heard. */ + private volatile boolean deafened; + /** Set while we are leaving on purpose, to tell a clean disconnect from a drop. */ + private volatile boolean leaving; + /** Guards against announcing the same disconnect from both the event and the teardown. */ + private volatile boolean disconnectAnnounced = true; + private volatile long lastMutedTalkNanos; + /** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */ private final Map pendingConnInfo = new ConcurrentHashMap<>(); - public TeamspeakConnection(Settings settings, AudioBackend audio, ConnectionListener ui) { + public TeamspeakConnection(Settings settings, AudioBackend audio, ConnectionListener ui, + SoundNotifier sounds) { this.settings = settings; this.audio = audio; this.ui = ui; + this.sounds = sounds; this.icons = new IconRepository(this::fetchIcon, ui::onIconsUpdated); } @@ -169,6 +185,10 @@ public final class TeamspeakConnection implements TS3Listener { playback.setTalkListener((clientId, talking) -> { ClientEntry c = model.getClient(clientId); if (c != null) c.talking = talking; + if (c != null && inOwnChannel(c.channelId)) { + sound(talking ? SoundEvent.SPECIAL_MIC_CLICK_OTHER_ON + : SoundEvent.SPECIAL_MIC_CLICK_OTHER_OFF, clientVars(clientId, null)); + } ui.onTalkStateChanged(clientId, talking); }); @@ -177,9 +197,12 @@ public final class TeamspeakConnection implements TS3Listener { if (selfClientId >= 0) { ClientEntry c = model.getClient(selfClientId); if (c != null) c.talking = talking; + sound(talking ? SoundEvent.SPECIAL_MIC_CLICK_SELF_ON + : SoundEvent.SPECIAL_MIC_CLICK_SELF_OFF); ui.onTalkStateChanged(selfClientId, talking); } }); + microphone.setMutedTalkListener(this::onTalkingWhileMuted); client = new LocalTeamspeakClientSocket(); client.setIdentity(identity); @@ -209,6 +232,9 @@ public final class TeamspeakConnection implements TS3Listener { icons.retryFailed(); connected = true; connectedAtMs = System.currentTimeMillis(); + leaving = false; + disconnectAnnounced = false; + deafened = false; ui.onConnected(); ui.onStatus("Retrieving channels…"); @@ -216,6 +242,7 @@ public final class TeamspeakConnection implements TS3Listener { ui.onModelChanged(); joinDefaultChannelIfNeeded(channel, channelPassword); ui.onStatus("Connected to " + model.getServerName()); + sound(SoundEvent.CONNECTION_CONNECTED, serverVars()); // Only captures if this connection holds the microphone. applyMicrophoneState(); @@ -262,7 +289,7 @@ public final class TeamspeakConnection implements TS3Listener { client.joinChannel(target.id, (channelPassword == null || channelPassword.isEmpty()) ? null : channelPassword); } catch (Exception e) { - ui.onError("Could not join channel \"" + target.name + "\": " + rootMessage(e)); + error("Could not join channel \"" + target.name + "\": " + rootMessage(e)); } } @@ -354,17 +381,39 @@ public final class TeamspeakConnection implements TS3Listener { * close) where the JVM must not exit before the leave notification is sent. */ public void disconnectBlocking(String reason) { + leaving = true; try { if (client != null) client.disconnect(reason); } catch (Exception ignored) { } finally { safeCleanup(); connected = false; + announceDisconnect(true); ui.onDisconnected("You disconnected"); ui.onStatus("Disconnected"); } } + /** + * Plays the disconnect sound once per connection: leaving on purpose is a + * different action from having the connection drop underneath us, and both the + * socket event and our own teardown can arrive. + */ + private void announceDisconnect(boolean deliberate) { + if (disconnectAnnounced) return; + disconnectAnnounced = true; + sound(deliberate || leaving ? SoundEvent.CONNECTION_DISCONNECTED + : SoundEvent.CONNECTION_LOST_CONNECTION, serverVars()); + } + + /** Reminds the user, at most every few seconds, that the microphone is muted. */ + private void onTalkingWhileMuted() { + long now = System.nanoTime(); + if (now - lastMutedTalkNanos < MUTED_TALK_COOLDOWN_NANOS) return; + lastMutedTalkNanos = now; + sound(SoundEvent.SPECIAL_TALKING_WHILE_MUTED); + } + private synchronized void safeCleanup() { VoiceInput mic = microphone; VoiceOutput out = playback; @@ -408,12 +457,17 @@ public final class TeamspeakConnection implements TS3Listener { public void setMicMuted(boolean muted) { if (microphone != null) microphone.setMuted(muted); + sound(muted ? SoundEvent.SOUND_CAPTURE_MUTED : SoundEvent.SOUND_CAPTURE_UNMUTED); pushSelfFlags(); } public void setDeafened(boolean deaf) { + // Announce muting while we can still be heard, and unmuting once we can again. + if (deaf) sound(SoundEvent.SOUND_PLAYBACK_MUTED); + deafened = deaf; if (playback != null) playback.setDeafened(deaf); if (microphone != null && deaf) microphone.setMuted(true); + if (!deaf) sound(SoundEvent.SOUND_PLAYBACK_UNMUTED); pushSelfFlags(); } @@ -434,7 +488,7 @@ public final class TeamspeakConnection implements TS3Listener { try { client.joinChannel(channelId, (password == null || password.isEmpty()) ? null : password); } catch (Exception e) { - ui.onError("Could not join channel: " + rootMessage(e)); + error("Could not join channel: " + rootMessage(e)); } }, "ts3j-join").start(); } @@ -446,7 +500,7 @@ public final class TeamspeakConnection implements TS3Listener { client.clientMove(clientId, channelId, (password == null || password.isEmpty()) ? null : password); } catch (Exception e) { - ui.onError("Could not move client: " + rootMessage(e)); + error("Could not move client: " + rootMessage(e)); } }, "ts3j-move-client").start(); } @@ -466,7 +520,7 @@ public final class TeamspeakConnection implements TS3Listener { cmd.add(new CommandSingleParameter("order", Integer.toString(orderPredecessorId))); client.executeCommand(cmd).complete(); } catch (Exception e) { - ui.onError("Could not move channel: " + rootMessage(e)); + error("Could not move channel: " + rootMessage(e)); } }, "ts3j-move-channel").start(); } @@ -477,8 +531,9 @@ public final class TeamspeakConnection implements TS3Listener { ClientEntry self = model.getClient(selfClientId); int cid = self != null ? self.channelId : 0; client.sendChannelMessage(cid, text); + sound(SoundEvent.CHAT_SENT_MESSAGE_CHANNEL, channelVars(cid, null)); } catch (Exception e) { - ui.onError("Message failed: " + rootMessage(e)); + error("Message failed: " + rootMessage(e)); } }, "ts3j-chan-msg").start(); } @@ -487,8 +542,9 @@ public final class TeamspeakConnection implements TS3Listener { new Thread(() -> { try { client.sendServerMessage(text); + sound(SoundEvent.CHAT_SENT_MESSAGE_SERVER, serverVars()); } catch (Exception e) { - ui.onError("Message failed: " + rootMessage(e)); + error("Message failed: " + rootMessage(e)); } }, "ts3j-srv-msg").start(); } @@ -497,8 +553,9 @@ public final class TeamspeakConnection implements TS3Listener { new Thread(() -> { try { client.sendPrivateMessage(clientId, text); + sound(SoundEvent.CHAT_SENT_MESSAGE_CLIENT, clientVars(clientId, null)); } catch (Exception e) { - ui.onError("Message failed: " + rootMessage(e)); + error("Message failed: " + rootMessage(e)); } }, "ts3j-pm").start(); } @@ -507,13 +564,15 @@ public final class TeamspeakConnection implements TS3Listener { new Thread(() -> { try { client.clientPoke(clientId, message); + sound(SoundEvent.OTHER_SENT_POKE, clientVars(clientId, null)); } catch (Exception e) { - ui.onError("Poke failed: " + rootMessage(e)); + error("Poke failed: " + rootMessage(e)); } }, "ts3j-poke").start(); } public void setAway(boolean away, String message) { + sound(away ? SoundEvent.STATUS_SET_AWAY : SoundEvent.STATUS_SET_PRESENT); selfUpdate(cmd -> { cmd.add(new CommandSingleParameter("client_away", away ? "1" : "0")); cmd.add(new CommandSingleParameter("client_away_message", away && message != null ? message : "")); @@ -534,7 +593,7 @@ public final class TeamspeakConnection implements TS3Listener { fill.accept(cmd); client.executeCommand(cmd).complete(); } catch (Exception e) { - ui.onError(errorLabel + ": " + rootMessage(e)); + error(errorLabel + ": " + rootMessage(e)); } }, "ts3j-selfupdate").start(); } @@ -552,7 +611,7 @@ public final class TeamspeakConnection implements TS3Listener { try { client.setNickname(nickname); } catch (Exception e) { - ui.onError("Rename failed: " + rootMessage(e)); + error("Rename failed: " + rootMessage(e)); } }, "ts3j-rename").start(); } @@ -571,11 +630,14 @@ public final class TeamspeakConnection implements TS3Listener { c.serverGroupIds = parseIntList(e.getClientServerGroups()); c.channelGroupId = e.getClientChannelGroupId(); c.self = (e.getClientId() == selfClientId); + if (e.getClientId() != selfClientId) announceClientEntered(e); ui.onModelChanged(); } @Override public void onClientLeave(ClientLeaveEvent e) { + if (e.getClientId() == selfClientId) announceOwnRemoval(safeInt(e, "reasonid"), e); + else announceClientLeft(e); model.removeClient(e.getClientId()); if (playback != null) playback.removeClient(e.getClientId()); ui.onModelChanged(); @@ -585,26 +647,187 @@ public final class TeamspeakConnection implements TS3Listener { public void onClientMoved(ClientMovedEvent e) { ClientEntry c = model.getClient(e.getClientId()); if (c != null) { + int from = c.channelId; c.channelId = e.getTargetChannelId(); + if (e.getClientId() == selfClientId) announceOwnMove(safeInt(e, "reasonid"), e); + else announceClientMoved(safeInt(e, "reasonid"), e.getClientId(), from, e.getTargetChannelId()); ui.onModelChanged(); } } + // ---- who went where: TeamSpeak's reason ids ---- + + /** {@code reasonid} of a client view/move notification. */ + private static final int REASON_SWITCHED = 0; + private static final int REASON_MOVED = 1; + private static final int REASON_TIMEOUT = 3; + private static final int REASON_CHANNEL_KICK = 4; + private static final int REASON_SERVER_KICK = 5; + private static final int REASON_BAN = 6; + + /** + * A client became visible: either they just connected, or they moved in from a + * channel we could not see — TeamSpeak's "appears" case. + */ + private void announceClientEntered(ClientJoinEvent e) { + if (!connected) return; + int clientId = e.getClientId(); + boolean current = inOwnChannel(e.getClientTargetId()); + Map vars = clientVars(clientId, e.getClientNickname()); + switch (safeInt(e, "reasonid")) { + case REASON_MOVED: + sound(current ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_APPEARS + : SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_APPEARS, vars); + break; + case REASON_CHANNEL_KICK: + sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_APPEARS + : SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_APPEARS, vars); + break; + case REASON_SWITCHED: + sound(current ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_APPEARS + : SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_APPEARS, vars); + break; + default: + sound(current ? SoundEvent.CLIENT_CONNECTION_CONNECTED_CURRENT_CHANNEL + : SoundEvent.CLIENT_CONNECTION_CONNECTED_SERVER, vars); + } + } + + /** A client stopped being visible: they left the server, or moved out of sight. */ + private void announceClientLeft(ClientLeaveEvent e) { + if (!connected) return; + int clientId = e.getClientId(); + boolean current = inOwnChannel(e.getClientFromId()); + Map vars = clientVars(clientId, null); + switch (safeInt(e, "reasonid")) { + case REASON_TIMEOUT: + sound(current ? SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_CURRENT_CHANNEL + : SoundEvent.CLIENT_CONNECTION_LOST_CONNECTION_SERVER, vars); + break; + case REASON_SERVER_KICK: + sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_CURRENT_CHANNEL + : SoundEvent.CLIENT_WAS_KICKED_FROM_SERVER_SERVER, vars); + break; + case REASON_BAN: + sound(current ? SoundEvent.CLIENT_WAS_BANNED_CURRENT_CHANNEL + : SoundEvent.CLIENT_WAS_BANNED_SERVER, vars); + break; + case REASON_CHANNEL_KICK: + sound(current ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_DISAPPEARS + : SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_DISAPPEARS, vars); + break; + case REASON_MOVED: + sound(current ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_DISAPPEARS + : SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_DISAPPEARS, vars); + break; + case REASON_SWITCHED: + sound(current ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_DISAPPEARS + : SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_DISAPPEARS, vars); + break; + default: + sound(current ? SoundEvent.CLIENT_CONNECTION_DISCONNECTED_CURRENT_CHANNEL + : SoundEvent.CLIENT_CONNECTION_DISCONNECTED_SERVER, vars); + } + } + + /** A client we can see moved between two channels we can see ("stays"). */ + private void announceClientMoved(int reason, int clientId, int fromChannel, int toChannel) { + if (!connected) return; + boolean toCurrent = inOwnChannel(toChannel); + boolean fromCurrent = inOwnChannel(fromChannel); + Map vars = clientVars(clientId, null); + switch (reason) { + case REASON_MOVED: + sound(toCurrent ? SoundEvent.CLIENT_MOVED_TO_CURRENT_CHANNEL_STAYS + : fromCurrent ? SoundEvent.CLIENT_MOVED_FROM_CURRENT_CHANNEL_STAYS + : SoundEvent.CLIENT_MOVED_TO_OTHER_CHANNEL_STAYS, vars); + break; + case REASON_CHANNEL_KICK: + sound(toCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_CURRENT_CHANNEL_STAYS + : fromCurrent ? SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_FROM_CURRENT_CHANNEL_STAYS + : SoundEvent.CLIENT_WAS_KICKED_FROM_CHANNEL_TO_OTHER_CHANNEL_STAYS, vars); + break; + default: + sound(toCurrent ? SoundEvent.CLIENT_SWITCHED_TO_CURRENT_CHANNEL_STAYS + : fromCurrent ? SoundEvent.CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_STAYS + : SoundEvent.CLIENT_SWITCHED_TO_OTHER_CHANNEL_STAYS, vars); + } + } + + /** We changed channel ourselves, or somebody changed it for us. */ + private void announceOwnMove(int reason, ClientMovedEvent e) { + if (!connected) return; + Map vars = channelVars(e.getTargetChannelId(), e.get("invokername")); + switch (reason) { + case REASON_MOVED: + sound(SoundEvent.YOU_WERE_MOVED_TO_DIFFERENT_CHANNEL, vars); + break; + case REASON_CHANNEL_KICK: + sound(SoundEvent.YOU_WERE_KICKED_FROM_CHANNEL, vars); + break; + default: + sound(SoundEvent.YOU_SWITCHED_CHANNEL, vars); + } + } + + /** We were removed from the server (kick or ban); the disconnect follows. */ + private void announceOwnRemoval(int reason, ClientLeaveEvent e) { + if (!connected) return; + Map vars = SoundNotifier.vars( + "servername", model.getServerName(), + "clientname", orEmpty(e.get("invokername")), + "reason", orEmpty(e.get("reasonmsg"))); + if (reason == REASON_SERVER_KICK) { + sound(SoundEvent.YOU_WERE_KICKED_FROM_SERVER, vars); + } else if (reason == REASON_BAN) { + sound(SoundEvent.YOU_WERE_BANNED, vars); + } + } + @Override public void onClientChanged(ClientUpdatedEvent e) { ClientEntry c = model.getClient(e.getClientId()); if (c == null) return; - String nick = e.get("client_nickname"); - if (nick != null) c.nickname = nick; - if (e.get("client_input_muted") != null) c.inputMuted = e.getBoolean("client_input_muted"); - if (e.get("client_output_muted") != null) c.outputMuted = e.getBoolean("client_output_muted"); - if (e.get("client_away") != null) c.away = e.getBoolean("client_away"); - if (e.get("client_talk_power") != null) c.talkPower = e.getInt("client_talk_power"); - if (e.get("client_is_channel_commander") != null) + boolean renamed = has(e, "client_nickname") && !e.get("client_nickname").equals(c.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_output_muted")) c.outputMuted = e.getBoolean("client_output_muted"); + if (has(e, "client_away")) c.away = e.getBoolean("client_away"); + if (has(e, "client_talk_power")) c.talkPower = e.getInt("client_talk_power"); + if (has(e, "client_is_channel_commander")) c.channelCommander = e.getBoolean("client_is_channel_commander"); + announceClientUpdate(e, c, renamed); ui.onModelChanged(); } + /** Renames, talk-power changes and recording flags all arrive as a client update. */ + private void announceClientUpdate(ClientUpdatedEvent e, ClientEntry c, boolean renamed) { + if (!connected) return; + boolean self = c.id == selfClientId; + Map vars = clientVars(c.id, null); + + if (renamed) { + sound(safeInt(e, "invokerid") == selfClientId + ? SoundEvent.CLIENT_RENAMED_BY_YOU : SoundEvent.CLIENT_RENAMED_BY_OTHER, vars); + } + if (safeInt(e, "client_talk_request") > 0 && !self) { + sound(SoundEvent.CLIENT_REQUESTED_TALK_POWER, vars); + } + if (self && has(e, "client_is_talker")) { + sound(e.getBoolean("client_is_talker") + ? SoundEvent.YOU_WERE_GRANTED_TALK_POWER : SoundEvent.YOU_WERE_REVOKED_TALK_POWER, vars); + } + if (!self && has(e, "client_is_recording")) { + boolean recording = e.getBoolean("client_is_recording"); + if (!recording) { + sound(SoundEvent.CLIENT_RECORDING_STOP, vars); + } else { + sound(inOwnChannel(c.channelId) + ? SoundEvent.CLIENT_RECORDING_IN_CHANNEL : SoundEvent.CLIENT_RECORDING_START, vars); + } + } + } + @Override public void onChannelCreate(ChannelCreateEvent e) { int cid = e.getChannelId(); @@ -616,12 +839,21 @@ public final class TeamspeakConnection implements TS3Listener { long icon = safeLong(e, "channel_icon_id"); if (icon != 0) node.iconId = icon; model.relinkChannel(cid, pid, order); + if (connected) { + sound(byInvoker(e, SoundEvent.CHANNEL_CREATED_BY_YOU, SoundEvent.CHANNEL_CREATED_BY_OTHER, + SoundEvent.CHANNEL_CREATED_BY_OTHER), channelVars(cid, e.get("invokername"))); + } ui.onModelChanged(); } @Override public void onChannelDeleted(ChannelDeletedEvent e) { - model.removeChannel(e.getChannelId()); + int cid = e.getChannelId(); + if (connected) { + sound(byInvoker(e, SoundEvent.CHANNEL_DELETED_BY_YOU, SoundEvent.CHANNEL_DELETED_BY_OTHER, + SoundEvent.CHANNEL_DELETED_BY_SERVER), channelVars(cid, e.get("invokername"))); + } + model.removeChannel(cid); ui.onModelChanged(); } @@ -629,10 +861,18 @@ public final class TeamspeakConnection implements TS3Listener { public void onChannelEdit(ChannelEditedEvent e) { ChannelNode ch = model.getChannel(safeInt(e, "cid")); if (ch != null) { - String name = e.get("channel_name"); - if (name != null) ch.name = name; - if (e.get("channel_order") != null) ch.order = e.getInt("channel_order"); - if (e.get("channel_icon_id") != null) ch.iconId = safeLong(e, "channel_icon_id"); + if (has(e, "channel_name")) ch.name = e.get("channel_name"); + if (has(e, "channel_order")) ch.order = e.getInt("channel_order"); + if (has(e, "channel_icon_id")) ch.iconId = safeLong(e, "channel_icon_id"); + if (connected) { + boolean current = inOwnChannel(ch.id); + sound(current + ? byInvoker(e, SoundEvent.CHANNEL_EDITED_CURRENT_BY_YOU, + SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER, SoundEvent.CHANNEL_EDITED_CURRENT_BY_OTHER) + : byInvoker(e, SoundEvent.CHANNEL_EDITED_OTHER_BY_YOU, + SoundEvent.CHANNEL_EDITED_OTHER_BY_OTHER, SoundEvent.CHANNEL_EDITED_OTHER_BY_SERVER), + channelVars(ch.id, e.get("invokername"))); + } ui.onModelChanged(); } } @@ -641,13 +881,76 @@ public final class TeamspeakConnection implements TS3Listener { public void onChannelMoved(ChannelMovedEvent e) { ChannelNode ch = model.getChannel(safeInt(e, "cid")); if (ch != null) { - int parent = e.get("cpid") != null ? e.getInt("cpid") : ch.parentId; - int order = e.get("order") != null ? e.getInt("order") : ch.order; + int parent = has(e, "cpid") ? e.getInt("cpid") : ch.parentId; + int order = has(e, "order") ? e.getInt("order") : ch.order; model.relinkChannel(ch.id, parent, order); + if (connected) { + sound(byInvoker(e, SoundEvent.CHANNEL_MOVED_BY_YOU, SoundEvent.CHANNEL_MOVED_BY_OTHER, + SoundEvent.CHANNEL_MOVED_BY_OTHER), channelVars(ch.id, e.get("invokername"))); + } ui.onModelChanged(); } } + @Override + public void onServerEdit(ServerEditedEvent e) { + if (has(e, "virtualserver_name")) model.setServerName(e.get("virtualserver_name")); + if (connected) { + sound(byInvoker(e, SoundEvent.SERVER_EDITED_BY_YOU, SoundEvent.SERVER_EDITED_BY_OTHER, + SoundEvent.SERVER_EDITED_BY_OTHER), serverVars()); + } + ui.onModelChanged(); + } + + @Override + public void onServerGroupClientAdded(ServerGroupClientAddedEvent e) { + boolean self = e.getClientId() == selfClientId; + sound(self + ? byInvoker(e, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, SoundEvent.YOU_SERVERGROUP_ADDED_BY_USER, + SoundEvent.YOU_SERVERGROUP_ADDED_BY_SERVER) + : byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER, + SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_ADDED_BY_SERVER), + groupVars(e.getClientId(), e.getName())); + } + + @Override + public void onServerGroupClientDeleted(ServerGroupClientDeletedEvent e) { + boolean self = safeInt(e, "clid") == selfClientId; + sound(self + ? byInvoker(e, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, + SoundEvent.YOU_SERVERGROUP_REMOVED_BY_USER, SoundEvent.YOU_SERVERGROUP_REMOVED_BY_SERVER) + : byInvoker(e, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER, + SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_USER, SoundEvent.CLIENT_SERVERGROUP_REMOVED_BY_SERVER), + groupVars(safeInt(e, "clid"), e.get("name"))); + } + + @Override + public void onClientChannelGroupChanged(ClientChannelGroupChangedEvent e) { + ClientEntry c = model.getClient(e.getClientId()); + if (c != null) c.channelGroupId = e.getChannelGroupId(); + boolean self = e.getClientId() == selfClientId; + sound(self + ? byInvoker(e, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, + SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.YOU_CHANNELGROUP_CHANGED_BY_SERVER) + : byInvoker(e, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER, + SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_USER, SoundEvent.CLIENT_CHANNELGROUP_CHANGED_BY_SERVER), + groupVars(e.getClientId(), model.channelGroupName(e.getChannelGroupId()))); + ui.onModelChanged(); + } + + /** Picks the event variant matching who caused the change: us, another client, or the server. */ + private SoundEvent byInvoker(BaseEvent e, SoundEvent byYou, SoundEvent byOther, SoundEvent byServer) { + int invoker = safeInt(e, "invokerid"); + if (invoker == selfClientId && invoker != 0) return byYou; + return invoker == 0 ? byServer : byOther; + } + + private Map groupVars(int clientId, String groupName) { + Map vars = clientVars(clientId, null); + vars.put("groupname", orEmpty(groupName)); + return vars; + } + @Override public void onChannelList(ChannelListEvent e) { // Incremental channel arriving during connect. @@ -773,7 +1076,7 @@ public final class TeamspeakConnection implements TS3Listener { File target, FileTransfer.Listener listener) { FileTransferManager ft = fileTransfers; if (ft == null) throw new IllegalStateException("Not connected"); - return ft.download(channelId, channelPassword, remoteFullPath, target, listener); + return ft.download(channelId, channelPassword, remoteFullPath, target, announcing(listener)); } /** Begins uploading {@code source} into a channel; progress arrives via {@code listener}. */ @@ -781,7 +1084,25 @@ public final class TeamspeakConnection implements TS3Listener { File source, boolean overwrite, FileTransfer.Listener listener) { FileTransferManager ft = fileTransfers; if (ft == null) throw new IllegalStateException("Not connected"); - return ft.upload(channelId, channelPassword, remoteFullPath, source, overwrite, listener); + return ft.upload(channelId, channelPassword, remoteFullPath, source, overwrite, announcing(listener)); + } + + /** + * Wraps a transfer listener so the completion and failure sounds play once the + * transfer finishes, whoever is watching it. + */ + private FileTransfer.Listener announcing(FileTransfer.Listener listener) { + java.util.concurrent.atomic.AtomicBoolean announced = new java.util.concurrent.atomic.AtomicBoolean(); + return transfer -> { + FileTransfer.State state = transfer.getState(); + if (state != FileTransfer.State.CANCELLED && transfer.isDone() + && announced.compareAndSet(false, true)) { + sound(state == FileTransfer.State.COMPLETED + ? SoundEvent.OTHER_FILETRANSFER_COMPLETE + : SoundEvent.OTHER_FILETRANSFER_FAILED, serverVars()); + } + if (listener != null) listener.onTransferChanged(transfer); + }; } // ---- connection info ---- @@ -1027,22 +1348,28 @@ public final class TeamspeakConnection implements TS3Listener { public void onTextMessage(TextMessageEvent e) { if (e.getInvokerId() == selfClientId) return; // don't echo our own ConnectionListener.ChatScope scope; + SoundEvent notification; switch (e.getTargetMode()) { case CLIENT: scope = ConnectionListener.ChatScope.PRIVATE; + notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CLIENT; break; case CHANNEL: scope = ConnectionListener.ChatScope.CHANNEL; + notification = SoundEvent.CHAT_RECEIVED_MESSAGE_CHANNEL; break; default: scope = ConnectionListener.ChatScope.SERVER; + notification = SoundEvent.CHAT_RECEIVED_MESSAGE_SERVER; break; } + sound(notification, clientVars(e.getInvokerId(), e.getInvokerName())); ui.onChat(scope, e.getInvokerId(), e.getInvokerName(), e.getMessage()); } @Override public void onClientPoke(ClientPokeEvent e) { + sound(SoundEvent.OTHER_RECEIVED_POKE, clientVars(e.getInvokerId(), e.getInvokerName())); ui.onPoke(orEmpty(e.getInvokerName()), orEmpty(e.get("msg"))); } @@ -1050,10 +1377,71 @@ public final class TeamspeakConnection implements TS3Listener { public void onDisconnected(DisconnectedEvent e) { connected = false; safeCleanup(); + announceDisconnect(false); ui.onDisconnected(orEmpty(e.getReasonMessage())); ui.onStatus("Disconnected"); } + // ---- sound notifications ---- + + /** + * Plays the sound pack's entry for an action. Muting is passed along so the + * notifier can drop everything the user did not mark as important. + */ + private void sound(SoundEvent event) { + sounds.fire(event, deafened); + } + + private void sound(SoundEvent event, Map variables) { + sounds.fire(event, deafened, variables); + } + + /** The placeholder values a pack may reference for an action involving a client. */ + private Map clientVars(int clientId, String fallbackName) { + ClientEntry c = model.getClient(clientId); + Map vars = SoundNotifier.vars( + "servername", model.getServerName(), + "clientname", c != null ? c.nickname : orEmpty(fallbackName), + "clientuid", c != null ? c.uniqueId : ""); + if (c != null) { + ChannelNode ch = model.getChannel(c.channelId); + if (ch != null) vars.put("channelname", ch.name); + } + return vars; + } + + private Map channelVars(int channelId, String invokerName) { + ChannelNode ch = model.getChannel(channelId); + return SoundNotifier.vars( + "servername", model.getServerName(), + "channelname", ch != null ? ch.name : "", + "clientname", orEmpty(invokerName)); + } + + private Map 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) {