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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
158
ts3-client/core/src/main/java/com/ts3client/sound/SoundPack.java
Normal file
158
ts3-client/core/src/main/java/com/ts3client/sound/SoundPack.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user