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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user