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

@@ -49,6 +49,10 @@ final class HotkeyActions implements HotkeyEngine.Handler {
case MIC_UNMUTE -> setMicMuted(targets, false);
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_UNMUTE -> setDeafened(targets, false);
case SPEAKER_TOGGLE -> setDeafened(targets, !anyDeafened(targets));
@@ -105,6 +109,11 @@ final class HotkeyActions implements HotkeyEngine.Handler {
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) {
for (ServerTab tab : targets) tab.setDeafened(deaf);
frame.refreshAfterHotkey();
@@ -124,6 +133,10 @@ final class HotkeyActions implements HotkeyEngine.Handler {
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) {
return tabs.stream().anyMatch(ServerTab::isDeafened);
}

View File

@@ -170,6 +170,33 @@ public final class Icons {
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. */
public static ImageIcon clientCommander(int size) {
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);
}
/** 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) {
g.setColor(new Color(0x2E8B57));
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.version.isEmpty()) row(sb, "Version", esc(cl.version));
if (cl.away) row(sb, "Status", "Away");
if (cl.inputMuted) row(sb, "Microphone", "muted");
if (cl.outputMuted) row(sb, "Speakers", "muted");
if (!cl.inputHardware) row(sb, "Microphone", "disabled");
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.description != null && !cl.description.isEmpty()) {

View File

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

View File

@@ -49,6 +49,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
private String identityId = "";
private boolean micMuted;
private boolean micLocalMuted;
private boolean deafened;
private boolean away;
/** Away message currently published, empty when away carries no message. */
@@ -139,6 +140,10 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
return micMuted;
}
boolean isMicLocalMuted() {
return micLocalMuted;
}
boolean isDeafened() {
return deafened;
}
@@ -160,6 +165,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
if (!conn.isConnected()) return SelfState.DISCONNECTED;
if (deafened) return SelfState.DEAFENED;
if (micMuted) return SelfState.MIC_MUTED;
if (micLocalMuted) return SelfState.MIC_LOCAL_MUTED;
if (away) return SelfState.AWAY;
ClientEntry self = conn.getModel().getClient(conn.getSelfClientId());
boolean talking = self != null && self.talking;
@@ -231,6 +237,13 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
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) {
deafened = deaf;
conn.setDeafened(deaf);
@@ -484,6 +497,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
connecting = false;
treePanel.setSelfClientId(conn.getSelfClientId());
micMuted = false;
micLocalMuted = false;
deafened = false;
away = false;
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. */
private ImageIcon iconFor(ClientEntry cl) {
if (cl.isQuery()) return Icons.clientQuery();
if (!cl.outputHardware) return Icons.speakerDisabled();
if (cl.outputMuted) return Icons.speakerMuted();
if (!cl.inputHardware) return Icons.micDisabled();
if (cl.inputMuted) return Icons.micMuted();
if (cl.away) return Icons.clientAway();
if (cl.channelCommander) {