Report mute/hardware status correctly, add Local Mic Mute, fix stale talking indicator

- Mute/deafen status now goes out through clientupdate instead of
  clientedit, which silently rejected client_input_muted/output_muted
  since they are runtime status, not editable client properties.
- Publish client_input_hardware so other clients see "Microphone
  Disabled" instead of silence while another tab holds the capture
  device; track input/output hardware flags for other clients too,
  and show a distinct grey "disabled" icon instead of reusing the red
  "muted" one for both the tree and the info panel.
- Implement TS3's Enable/Disable/Toggle Local Mic Mute hotkeys: they
  silence capture like a real mute, but never touch the published
  mute status or play a sound.
- A speaker's "talking" indicator only ever cleared when its
  zero-length end-of-burst voice packet arrived; if that one UDP
  packet was lost, the indicator stuck until their next burst. Add a
  watchdog that clears it after 200ms of silence from that speaker
  regardless.
This commit is contained in:
2026-08-17 08:43:22 +00:00
parent e228dd4ad0
commit 75ae6b89b2
12 changed files with 198 additions and 15 deletions

View File

@@ -18,6 +18,15 @@ public interface VoiceInput extends Microphone {
void setMuted(boolean muted); void setMuted(boolean muted);
/**
* Silences capture the same way {@link #setMuted} does, but locally only: it does
* not touch {@link #isMuted}, so the mute status published to the server (and the
* mute/unmute sound) is unaffected. TS3's "Local Mic Mute".
*/
void setLocalMuted(boolean muted);
boolean isLocalMuted();
void setMode(Settings.InputMode mode); void setMode(Settings.InputMode mode);
void setVadMode(Settings.VadMode mode); void setVadMode(Settings.VadMode mode);

View File

@@ -36,13 +36,16 @@ public enum HotkeyAction {
"Unmute Microphone", true, Argument.NONE, true), "Unmute Microphone", true, Argument.NONE, true),
MIC_TOGGLE(Category.SERVER, 0x0030, "Toggle", MIC_TOGGLE(Category.SERVER, 0x0030, "Toggle",
"Toggle Microphone Mute", false, Argument.NONE, true), "Toggle Microphone Mute", false, Argument.NONE, true),
/** TS3's "local" mute is the hardware-level mute of the capture device itself. */ /**
* TS3's "local" mute silences capture without publishing a mute status, or playing
* a mute/unmute sound — unlike {@link #MIC_MUTE}.
*/
MIC_LOCAL_UNMUTE(Category.SERVER, 0x0030, "ActivateLocalMute", MIC_LOCAL_UNMUTE(Category.SERVER, 0x0030, "ActivateLocalMute",
"Disable Local Mic Mute", true, Argument.NONE, false), "Disable Local Mic Mute", true, Argument.NONE, true),
MIC_LOCAL_MUTE(Category.SERVER, 0x0030, "DeactivateLocalMute", MIC_LOCAL_MUTE(Category.SERVER, 0x0030, "DeactivateLocalMute",
"Enable Local Mic Mute", true, Argument.NONE, false), "Enable Local Mic Mute", true, Argument.NONE, true),
MIC_LOCAL_TOGGLE(Category.SERVER, 0x0030, "ToggleLocalMute", MIC_LOCAL_TOGGLE(Category.SERVER, 0x0030, "ToggleLocalMute",
"Toggle Local Mic Mute", true, Argument.NONE, false), "Toggle Local Mic Mute", true, Argument.NONE, true),
SPEAKER_MUTE(Category.SERVER, 0x0040, "Mute", SPEAKER_MUTE(Category.SERVER, 0x0040, "Mute",
"Mute Speaker", true, Argument.NONE, true), "Mute Speaker", true, Argument.NONE, true),

View File

@@ -21,6 +21,10 @@ public final class ClientEntry {
public boolean talking; public boolean talking;
public boolean inputMuted; // microphone muted (client_input_muted) public boolean inputMuted; // microphone muted (client_input_muted)
public boolean outputMuted; // speakers muted / deafened (client_output_muted) public boolean outputMuted; // speakers muted / deafened (client_output_muted)
/** False while the capture device is unavailable: another tab holds the microphone. */
public boolean inputHardware = true;
/** False while the playback device is unavailable. */
public boolean outputHardware = true;
public boolean away; public boolean away;
/** The message published with the away state, empty when there is none. */ /** The message published with the away state, empty when there is none. */
public String awayMessage = ""; public String awayMessage = "";

View File

@@ -153,6 +153,22 @@ public final class TeamspeakConnection implements TS3Listener {
} catch (Exception e) { } catch (Exception e) {
ui.onError("Microphone unavailable: " + rootMessage(e)); ui.onError("Microphone unavailable: " + rootMessage(e));
} }
pushInputHardware();
}
/**
* Reports whether the capture device is actually available on this connection, so
* other clients see "Microphone Disabled" rather than plain silence while another
* tab holds it. {@code clientinit} always claims hardware input, so this is what
* corrects that once tabs start sharing one microphone.
*/
private void pushInputHardware() {
boolean hardware = microphoneActive;
updateSelf(self -> self.inputHardware = hardware);
if (client == null || !connected) return;
selfUpdate(cmd ->
cmd.add(new CommandSingleParameter("client_input_hardware", hardware ? "1" : "0")),
"Microphone hardware status update failed");
} }
// ---- connection lifecycle ---- // ---- connection lifecycle ----
@@ -403,6 +419,8 @@ public final class TeamspeakConnection implements TS3Listener {
e.talkPower = cl.getTalkPower(); e.talkPower = cl.getTalkPower();
e.inputMuted = cl.isInputMuted(); e.inputMuted = cl.isInputMuted();
e.outputMuted = cl.isOutputMuted(); e.outputMuted = cl.isOutputMuted();
e.inputHardware = cl.isInputHardware();
e.outputHardware = cl.isOutputHardware();
e.away = cl.isAway(); e.away = cl.isAway();
e.awayMessage = orEmpty(cl.get("client_away_message")); e.awayMessage = orEmpty(cl.get("client_away_message"));
e.uniqueId = cl.getUniqueIdentifier(); e.uniqueId = cl.getUniqueIdentifier();
@@ -505,6 +523,14 @@ public final class TeamspeakConnection implements TS3Listener {
pushSelfFlags(); pushSelfFlags();
} }
/**
* TS3's "Local Mic Mute": silences capture without touching the {@code client_input_muted}
* status the server (and other clients) see, and without a mute/unmute sound.
*/
public void setMicLocalMuted(boolean muted) {
if (microphone != null) microphone.setLocalMuted(muted);
}
public void setDeafened(boolean deaf) { public void setDeafened(boolean deaf) {
// Announce muting while we can still be heard, and unmuting once we can again. // Announce muting while we can still be heard, and unmuting once we can again.
if (deaf) sound(SoundEvent.SOUND_PLAYBACK_MUTED); if (deaf) sound(SoundEvent.SOUND_PLAYBACK_MUTED);
@@ -524,15 +550,14 @@ public final class TeamspeakConnection implements TS3Listener {
self.outputMuted = deaf; self.outputMuted = deaf;
}); });
// Best-effort: publish input/output muted flags to the server so others see them. // Publish the input/output muted flags to the server so others see them. These
// are runtime status, not editable client-database properties, so they go out
// through clientupdate (like nickname/away), not clientedit.
if (client == null || !connected) return; if (client == null || !connected) return;
try { selfUpdate(cmd -> {
java.util.Map<String, String> props = new java.util.HashMap<>(); cmd.add(new CommandSingleParameter("client_input_muted", micMuted ? "1" : "0"));
props.put("client_input_muted", microphone.isMuted() ? "1" : "0"); cmd.add(new CommandSingleParameter("client_output_muted", deaf ? "1" : "0"));
props.put("client_output_muted", playback.isDeafened() ? "1" : "0"); }, "Mute status update failed");
client.editClient(selfClientId, props);
} catch (Exception ignored) {
}
} }
public void joinChannel(int channelId, String password) { public void joinChannel(int channelId, String password) {
@@ -758,6 +783,8 @@ public final class TeamspeakConnection implements TS3Listener {
c.talkPower = e.getClientTalkPower(); c.talkPower = e.getClientTalkPower();
c.inputMuted = e.isClientInputMuted(); c.inputMuted = e.isClientInputMuted();
c.outputMuted = e.isClientOutputMuted(); c.outputMuted = e.isClientOutputMuted();
c.inputHardware = e.isClientUsingHardwareInput();
c.outputHardware = e.isClientUsingHardwareOutput();
c.away = e.isClientAway(); c.away = e.isClientAway();
c.awayMessage = orEmpty(e.get("client_away_message")); c.awayMessage = orEmpty(e.get("client_away_message"));
c.uniqueId = orEmpty(e.getUniqueClientIdentifier()); c.uniqueId = orEmpty(e.getUniqueClientIdentifier());
@@ -926,6 +953,8 @@ public final class TeamspeakConnection implements TS3Listener {
if (renamed) c.nickname = e.get("client_nickname"); if (renamed) c.nickname = e.get("client_nickname");
if (has(e, "client_input_muted")) c.inputMuted = e.getBoolean("client_input_muted"); if (has(e, "client_input_muted")) c.inputMuted = e.getBoolean("client_input_muted");
if (has(e, "client_output_muted")) c.outputMuted = e.getBoolean("client_output_muted"); if (has(e, "client_output_muted")) c.outputMuted = e.getBoolean("client_output_muted");
if (has(e, "client_input_hardware")) c.inputHardware = e.getBoolean("client_input_hardware");
if (has(e, "client_output_hardware")) c.outputHardware = e.getBoolean("client_output_hardware");
if (has(e, "client_away")) { if (has(e, "client_away")) {
c.away = e.getBoolean("client_away"); c.away = e.getBoolean("client_away");
// Both fields travel together, so an absent message here means "no message" // Both fields travel together, so an absent message here means "no message"

View File

@@ -51,6 +51,7 @@ public final class DesktopVoiceInput implements VoiceInput {
private final ConcurrentLinkedQueue<byte[]> queue = new ConcurrentLinkedQueue<>(); private final ConcurrentLinkedQueue<byte[]> queue = new ConcurrentLinkedQueue<>();
private final AtomicBoolean muted = new AtomicBoolean(false); private final AtomicBoolean muted = new AtomicBoolean(false);
private final AtomicBoolean localMuted = new AtomicBoolean(false);
private final AtomicBoolean transmitting = new AtomicBoolean(false); private final AtomicBoolean transmitting = new AtomicBoolean(false);
private final AtomicBoolean pttDown = new AtomicBoolean(false); private final AtomicBoolean pttDown = new AtomicBoolean(false);
private final AtomicBoolean running = new AtomicBoolean(false); private final AtomicBoolean running = new AtomicBoolean(false);
@@ -239,6 +240,16 @@ public final class DesktopVoiceInput implements VoiceInput {
this.muted.set(m); this.muted.set(m);
} }
@Override
public void setLocalMuted(boolean m) {
this.localMuted.set(m);
}
@Override
public boolean isLocalMuted() {
return localMuted.get();
}
// ---- lifecycle ---- // ---- lifecycle ----
public synchronized void start() { public synchronized void start() {
@@ -405,7 +416,7 @@ public final class DesktopVoiceInput implements VoiceInput {
} }
private boolean decideGate(double db, float[] pcm) { private boolean decideGate(double db, float[] pcm) {
if (muted.get()) { if (muted.get() || localMuted.get()) {
hangover = 0; hangover = 0;
detectMutedSpeech(db); detectMutedSpeech(db);
return false; return false;

View File

@@ -10,6 +10,8 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
/** /**
@@ -24,6 +26,16 @@ public final class DesktopVoiceOutput implements VoiceOutput {
/** Longest Opus frame (120 ms @ 48 kHz) a packet may decode to, per channel. */ /** Longest Opus frame (120 ms @ 48 kHz) a packet may decode to, per channel. */
private static final int MAX_FRAME = 5760; private static final int MAX_FRAME = 5760;
/**
* A speaker is only meant to stop when the empty voice packet marking the end of a
* talk burst arrives — but that packet is UDP too, and a lost one otherwise leaves
* the talking indicator stuck until the speaker's next burst. Opus frames are 20&nbsp;ms
* apart while someone is actually talking, so a gap several times that long, with no
* packet of either kind, is unambiguous: force the indicator off rather than trust
* the one packet that could go missing.
*/
private static final long TALK_TIMEOUT_NANOS = TimeUnit.MILLISECONDS.toNanos(200);
/** One speaker's decode + playback pipeline. */ /** One speaker's decode + playback pipeline. */
private final class ClientStream { private final class ClientStream {
final int clientId; final int clientId;
@@ -81,8 +93,32 @@ public final class DesktopVoiceOutput implements VoiceOutput {
/** Notified (clientId, talking) on the EDT-agnostic worker thread when a speaker starts/stops. */ /** Notified (clientId, talking) on the EDT-agnostic worker thread when a speaker starts/stops. */
private volatile BiConsumer<Integer, Boolean> talkListener; private volatile BiConsumer<Integer, Boolean> talkListener;
/** Catches a talk burst whose end packet never arrived; see {@link #TALK_TIMEOUT_NANOS}. */
private final ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "ts3j-talk-watchdog");
t.setDaemon(true);
return t;
});
public DesktopVoiceOutput(String outputDevice) { public DesktopVoiceOutput(String outputDevice) {
this.outputDevice = outputDevice; this.outputDevice = outputDevice;
watchdog.scheduleWithFixedDelay(this::checkTalkTimeouts, 50, 50, TimeUnit.MILLISECONDS);
}
private void checkTalkTimeouts() {
long now = System.nanoTime();
for (ClientStream s : streams.values()) {
if (s.talking && now - s.lastPacketNanos > TALK_TIMEOUT_NANOS) {
s.worker.submit(() -> {
try {
s.line.drain();
} catch (Exception ignored) {
}
if (s.decoder != null) s.decoder.reset();
markTalking(s, false);
});
}
}
} }
public void setTalkListener(BiConsumer<Integer, Boolean> l) { public void setTalkListener(BiConsumer<Integer, Boolean> l) {
@@ -226,6 +262,7 @@ public final class DesktopVoiceOutput implements VoiceOutput {
} }
public void shutdown() { public void shutdown() {
watchdog.shutdownNow();
for (ClientStream s : streams.values()) { for (ClientStream s : streams.values()) {
s.close(); s.close();
} }

View File

@@ -49,6 +49,10 @@ final class HotkeyActions implements HotkeyEngine.Handler {
case MIC_UNMUTE -> setMicMuted(targets, false); case MIC_UNMUTE -> setMicMuted(targets, false);
case MIC_TOGGLE -> setMicMuted(targets, !anyMicMuted(targets)); case MIC_TOGGLE -> setMicMuted(targets, !anyMicMuted(targets));
case MIC_LOCAL_MUTE -> setMicLocalMuted(targets, true);
case MIC_LOCAL_UNMUTE -> setMicLocalMuted(targets, false);
case MIC_LOCAL_TOGGLE -> setMicLocalMuted(targets, !anyMicLocalMuted(targets));
case SPEAKER_MUTE -> setDeafened(targets, true); case SPEAKER_MUTE -> setDeafened(targets, true);
case SPEAKER_UNMUTE -> setDeafened(targets, false); case SPEAKER_UNMUTE -> setDeafened(targets, false);
case SPEAKER_TOGGLE -> setDeafened(targets, !anyDeafened(targets)); case SPEAKER_TOGGLE -> setDeafened(targets, !anyDeafened(targets));
@@ -105,6 +109,11 @@ final class HotkeyActions implements HotkeyEngine.Handler {
frame.refreshAfterHotkey(); frame.refreshAfterHotkey();
} }
private void setMicLocalMuted(List<ServerTab> targets, boolean muted) {
for (ServerTab tab : targets) tab.setMicLocalMuted(muted);
frame.refreshAfterHotkey();
}
private void setDeafened(List<ServerTab> targets, boolean deaf) { private void setDeafened(List<ServerTab> targets, boolean deaf) {
for (ServerTab tab : targets) tab.setDeafened(deaf); for (ServerTab tab : targets) tab.setDeafened(deaf);
frame.refreshAfterHotkey(); frame.refreshAfterHotkey();
@@ -124,6 +133,10 @@ final class HotkeyActions implements HotkeyEngine.Handler {
return tabs.stream().anyMatch(ServerTab::isMicMuted); return tabs.stream().anyMatch(ServerTab::isMicMuted);
} }
private static boolean anyMicLocalMuted(List<ServerTab> tabs) {
return tabs.stream().anyMatch(ServerTab::isMicLocalMuted);
}
private static boolean anyDeafened(List<ServerTab> tabs) { private static boolean anyDeafened(List<ServerTab> tabs) {
return tabs.stream().anyMatch(ServerTab::isDeafened); return tabs.stream().anyMatch(ServerTab::isDeafened);
} }

View File

@@ -170,6 +170,33 @@ public final class Icons {
return themed("OUTPUT_MUTED", size, Icons::paintSpeakerMuted); return themed("OUTPUT_MUTED", size, Icons::paintSpeakerMuted);
} }
/** The capture device is unavailable (another tab holds it), as opposed to muted. */
public static ImageIcon micDisabled() {
return micDisabled(SZ);
}
public static ImageIcon micDisabled(int size) {
return themed("HARDWARE_INPUT_MUTED", size, Icons::paintMicDisabled);
}
/** The playback device is unavailable, as opposed to muted/deafened. */
public static ImageIcon speakerDisabled() {
return speakerDisabled(SZ);
}
public static ImageIcon speakerDisabled(int size) {
return themed("HARDWARE_OUTPUT_MUTED", size, Icons::paintSpeakerDisabled);
}
/** TS3's "Local Mic Mute": silenced, but not reported as muted. */
public static ImageIcon micLocalMuted() {
return micLocalMuted(SZ);
}
public static ImageIcon micLocalMuted(int size) {
return themed("INPUT_MUTED_LOCAL", size, Icons::paintMicLocalMuted);
}
/** A channel commander that is not talking. */ /** A channel commander that is not talking. */
public static ImageIcon clientCommander(int size) { public static ImageIcon clientCommander(int size) {
return themed("PLAYER_COMMANDER_OFF", size, g -> paintPerson(g, Theme.IDLE_CLIENT)); return themed("PLAYER_COMMANDER_OFF", size, g -> paintPerson(g, Theme.IDLE_CLIENT));
@@ -296,6 +323,35 @@ public final class Icons {
g.drawLine(2, 2, 14, 14); g.drawLine(2, 2, 14, 14);
} }
/** Same shape as {@link #paintMicMuted}, but grey: unavailable, not muted by choice. */
private static void paintMicDisabled(Graphics2D g) {
g.setColor(Theme.IDLE_CLIENT);
g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140);
g.drawLine(8, 12, 8, 14);
g.setStroke(new BasicStroke(2f));
g.drawLine(2, 2, 14, 14);
}
/** Same shape as {@link #paintSpeakerMuted}, but grey: unavailable, not muted by choice. */
private static void paintSpeakerDisabled(Graphics2D g) {
g.setColor(Theme.IDLE_CLIENT);
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(2f));
g.drawLine(2, 2, 14, 14);
}
/** {@link #paintMicMuted}, marked with a small dot: silenced, but not reported as muted. */
private static void paintMicLocalMuted(Graphics2D g) {
paintMicMuted(g);
g.setColor(Theme.ACCENT);
g.fillOval(11, 10, 4, 4);
}
private static void paintConnect(Graphics2D g) { private static void paintConnect(Graphics2D g) {
g.setColor(new Color(0x2E8B57)); g.setColor(new Color(0x2E8B57));
g.setStroke(new BasicStroke(2f)); g.setStroke(new BasicStroke(2f));

View File

@@ -96,8 +96,10 @@ public final class InfoPanel extends JScrollPane {
if (!cl.platform.isEmpty()) row(sb, "Platform", esc(cl.platform)); if (!cl.platform.isEmpty()) row(sb, "Platform", esc(cl.platform));
if (!cl.version.isEmpty()) row(sb, "Version", esc(cl.version)); if (!cl.version.isEmpty()) row(sb, "Version", esc(cl.version));
if (cl.away) row(sb, "Status", "Away"); if (cl.away) row(sb, "Status", "Away");
if (cl.inputMuted) row(sb, "Microphone", "muted"); if (!cl.inputHardware) row(sb, "Microphone", "disabled");
if (cl.outputMuted) row(sb, "Speakers", "muted"); else if (cl.inputMuted) row(sb, "Microphone", "muted");
if (!cl.outputHardware) row(sb, "Speakers", "disabled");
else if (cl.outputMuted) row(sb, "Speakers", "muted");
if (!cl.uniqueId.isEmpty()) row(sb, "Unique ID", esc(cl.uniqueId)); if (!cl.uniqueId.isEmpty()) row(sb, "Unique ID", esc(cl.uniqueId));
if (cl.description != null && !cl.description.isEmpty()) { if (cl.description != null && !cl.description.isEmpty()) {

View File

@@ -12,6 +12,7 @@ enum SelfState {
DISCONNECTED("Not connected"), DISCONNECTED("Not connected"),
DEAFENED("Speakers muted"), DEAFENED("Speakers muted"),
MIC_MUTED("Microphone muted"), MIC_MUTED("Microphone muted"),
MIC_LOCAL_MUTED("Microphone locally muted"),
AWAY("Away"), AWAY("Away"),
COMMANDER_TALKING("Talking (channel commander)"), COMMANDER_TALKING("Talking (channel commander)"),
COMMANDER("Channel commander"), COMMANDER("Channel commander"),
@@ -37,6 +38,8 @@ enum SelfState {
return Icons.speakerMuted(size); return Icons.speakerMuted(size);
case MIC_MUTED: case MIC_MUTED:
return Icons.micMuted(size); return Icons.micMuted(size);
case MIC_LOCAL_MUTED:
return Icons.micLocalMuted(size);
case AWAY: case AWAY:
return Icons.clientAway(size); return Icons.clientAway(size);
case COMMANDER_TALKING: case COMMANDER_TALKING:

View File

@@ -49,6 +49,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
private String identityId = ""; private String identityId = "";
private boolean micMuted; private boolean micMuted;
private boolean micLocalMuted;
private boolean deafened; private boolean deafened;
private boolean away; private boolean away;
/** Away message currently published, empty when away carries no message. */ /** Away message currently published, empty when away carries no message. */
@@ -139,6 +140,10 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
return micMuted; return micMuted;
} }
boolean isMicLocalMuted() {
return micLocalMuted;
}
boolean isDeafened() { boolean isDeafened() {
return deafened; return deafened;
} }
@@ -160,6 +165,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
if (!conn.isConnected()) return SelfState.DISCONNECTED; if (!conn.isConnected()) return SelfState.DISCONNECTED;
if (deafened) return SelfState.DEAFENED; if (deafened) return SelfState.DEAFENED;
if (micMuted) return SelfState.MIC_MUTED; if (micMuted) return SelfState.MIC_MUTED;
if (micLocalMuted) return SelfState.MIC_LOCAL_MUTED;
if (away) return SelfState.AWAY; if (away) return SelfState.AWAY;
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId()); ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
boolean talking = self != null && self.talking; boolean talking = self != null && self.talking;
@@ -231,6 +237,13 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
chatPanel.appendSystem(muted ? "Microphone muted." : "Microphone active."); chatPanel.appendSystem(muted ? "Microphone muted." : "Microphone active.");
} }
/** TS3's "Local Mic Mute": silences capture without publishing a status change. */
void setMicLocalMuted(boolean muted) {
micLocalMuted = muted;
conn.setMicLocalMuted(muted);
chatPanel.appendSystem(muted ? "Microphone locally muted." : "Microphone locally unmuted.");
}
void setDeafened(boolean deaf) { void setDeafened(boolean deaf) {
deafened = deaf; deafened = deaf;
conn.setDeafened(deaf); conn.setDeafened(deaf);
@@ -484,6 +497,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
connecting = false; connecting = false;
treePanel.setSelfClientId(conn.getSelfClientId()); treePanel.setSelfClientId(conn.getSelfClientId());
micMuted = false; micMuted = false;
micLocalMuted = false;
deafened = false; deafened = false;
away = false; away = false;
awayMessage = ""; awayMessage = "";

View File

@@ -650,7 +650,9 @@ public final class ServerTreePanel extends JScrollPane {
/** The client's state, in the order the official client gives them priority. */ /** The client's state, in the order the official client gives them priority. */
private ImageIcon iconFor(ClientEntry cl) { private ImageIcon iconFor(ClientEntry cl) {
if (cl.isQuery()) return Icons.clientQuery(); if (cl.isQuery()) return Icons.clientQuery();
if (!cl.outputHardware) return Icons.speakerDisabled();
if (cl.outputMuted) return Icons.speakerMuted(); if (cl.outputMuted) return Icons.speakerMuted();
if (!cl.inputHardware) return Icons.micDisabled();
if (cl.inputMuted) return Icons.micMuted(); if (cl.inputMuted) return Icons.micMuted();
if (cl.away) return Icons.clientAway(); if (cl.away) return Icons.clientAway();
if (cl.channelCommander) { if (cl.channelCommander) {