Play sound-pack notifications for client actions

Adds TeamSpeak-format sound packs: a folder of waves plus a settings.ini
mapping actions to play()/say() entries, with ${clientType} and friends
resolved per event. Packs are found in the client's own sound folder, an
installed TS3 client and a folder of the user's choosing, so the official
packs work unchanged.

Each action can be switched off or marked important; important actions are
the only ones still played while the speakers are muted, as in TS3. The new
Notifications options page lists them by category, greys out what the active
pack has no sound for, and previews on double-click.

Sounds are decoded, resampled and mixed onto a single playback line that is
only open while something plays, so overlapping events never fight over the
device.

Fires the events from the protocol layer, following TeamSpeak's own
distinctions: reason ids separate switched/moved/kicked/banned/timed out,
and visibility decides appears/disappears/stays.

Also fixes a ts3j trap in the process: a field an event never carried reads
back as an empty string, so the existing "e.get(x) != null" checks were
always true. That made a partial clientupdate (someone muting) announce a
stopped recording, and it let a nickname-only update reset another client's
mute/away flags and talk power, or a channel edit blank the channel name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 11:53:11 +00:00
parent 23896456ee
commit 2d7b82f9a3
23 changed files with 2103 additions and 32 deletions

View File

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

View File

@@ -19,5 +19,11 @@
<groupId>com.github.manevolent</groupId>
<artifactId>ts3j</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.13.4</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

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

View File

@@ -55,4 +55,7 @@ public interface VoiceInput extends Microphone {
/** Receives local transmit-state transitions (talking / silent). */
void setTalkListener(Consumer<Boolean> listener);
/** Called when speech is detected while the microphone is muted. */
void setMutedTalkListener(Runnable listener);
}

View File

@@ -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) {

View File

@@ -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<Integer, PendingConnInfo> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> groupVars(int clientId, String groupName) {
Map<String, String> 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<String, String> variables) {
sounds.fire(event, deafened, variables);
}
/** The placeholder values a pack may reference for an action involving a client. */
private Map<String, String> clientVars(int clientId, String fallbackName) {
ClientEntry c = model.getClient(clientId);
Map<String, String> 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<String, String> 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<String, String> 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);

View File

@@ -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 <em>important</em> — important actions are the only ones still
* heard while the speakers are muted.
*
* <p>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<SoundEvent> disabled = EnumSet.noneOf(SoundEvent.class);
private final Set<SoundEvent> 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);
}
}

View File

@@ -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}.
*
* <p>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;
}
}
}

View File

@@ -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.
*
* <p>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 <em>important</em> — 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<SoundPack> 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<SoundPack> 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<String, String> 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<String, String> 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<String, String> withDefaults(Map<String, String> variables) {
Map<String, String> 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<String, String> vars(String... nameValuePairs) {
Map<String, String> 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;
}
}

View File

@@ -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.
*
* <p>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:
*
* <pre>
* [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 =
* </pre>
*/
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<SoundEvent, SoundScript> scripts;
private SoundPack(String id, File directory, String name, String version, String author,
Map<SoundEvent, SoundScript> 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<SoundEvent, SoundScript> scripts = new EnumMap<>(SoundEvent.class);
Map<String, String> 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);
}
}

View File

@@ -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<SoundPack> findAll(File userDirectory, String extraDirectory) {
Map<String, SoundPack> 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<SoundPack> 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<SoundPack> packs, String id) {
for (SoundPack p : packs) {
if (p.id().equals(id)) return p;
}
return packs.isEmpty() ? null : packs.get(0);
}
private static List<File> searchRoots(File userDirectory, String extraDirectory) {
List<File> roots = new ArrayList<>();
if (userDirectory != null) roots.add(userDirectory);
if (extraDirectory != null && !extraDirectory.isBlank()) roots.add(new File(extraDirectory.trim()));
String configured = System.getProperty(CLIENT_DIR_PROPERTY, System.getenv(CLIENT_DIR_ENV));
if (configured != null && !configured.isBlank()) roots.add(new File(configured.trim(), "sound"));
String home = System.getProperty("user.home", ".");
for (String candidate : new String[]{
"TeamSpeak3-Client-linux_amd64", "TeamSpeak3-Client-linux_x86",
".local/share/TeamSpeak3-Client-linux_amd64", "Applications/TeamSpeak3-Client-linux_amd64"}) {
roots.add(new File(new File(home, candidate), "sound"));
}
for (String candidate : new String[]{
"/opt/teamspeak3-client", "/opt/teamspeak3", "/usr/share/teamspeak3",
"/usr/share/teamspeak3-client"}) {
roots.add(new File(candidate, "sound"));
}
String programFiles = System.getenv("ProgramFiles");
if (programFiles != null) {
roots.add(new File(programFiles, "TeamSpeak 3 Client" + File.separator + "sound"));
}
// Running from a checkout that has the official client unpacked next to it.
roots.add(new File("TeamSpeak3-Client-linux_amd64/sound"));
roots.add(new File("../TeamSpeak3-Client-linux_amd64/sound"));
List<File> existing = new ArrayList<>();
for (File root : roots) {
if (root.isDirectory()) existing.add(root);
}
return existing;
}
}

View File

@@ -0,0 +1,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();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,8 @@ public final class DesktopVoiceInput implements VoiceInput {
private volatile Consumer<Double> levelListener; // input level in dBFS
private volatile Consumer<Boolean> 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) {

View File

@@ -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.
*
* <p>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&nbsp;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<String, float[]> cache = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, float[]> eldest) {
return size() > MAX_CACHED_FILES;
}
};
private final List<Voice> 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<Voice> 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;
}
}

View File

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

View File

@@ -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.
*
* <p>An action can be switched off entirely, or marked <em>important</em> (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<Row> rows = new ArrayList<>();
private final String originalPackId;
private final String originalPackDir;
private final JComboBox<SoundPack> 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("<html><b>Bold</b> actions are important: they are still played "
+ "while your speakers are muted.</html>");
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;
}
}
}

View File

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

View File

@@ -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<AudioDevices.Device> inputCombo;
private JComboBox<AudioDevices.Device> 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) {