Publish the channel tree's lists whole instead of refilling them
buildTree() cleared and refilled the children and client lists inside the shared channel nodes, so a UI still walking the previous tree could see them change under it. It now builds new lists and publishes them unmodifiable. The node fields, written by the event and audio threads and read by the UI, are volatile. Whether a channel is full is now counted from the model, rather than from a tree that may not have been rebuilt since the last join. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,26 +3,30 @@ package com.ts3client.net;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** Mutable view-model of a TeamSpeak channel. */
|
/**
|
||||||
|
* View-model of a TeamSpeak channel. The event thread updates it while the UI reads it, so
|
||||||
|
* every field is volatile; {@link #children} and {@link #clients} are replaced whole on each
|
||||||
|
* {@link ServerModel#buildTree()}, never changed in place.
|
||||||
|
*/
|
||||||
public final class ChannelNode {
|
public final class ChannelNode {
|
||||||
public final int id;
|
public final int id;
|
||||||
public int parentId;
|
public volatile int parentId;
|
||||||
public int order;
|
public volatile int order;
|
||||||
public String name;
|
public volatile String name;
|
||||||
public String topic = "";
|
public volatile String topic = "";
|
||||||
public String description = "";
|
public volatile String description = "";
|
||||||
public boolean descriptionLoaded;
|
public volatile boolean descriptionLoaded;
|
||||||
public boolean hasPassword;
|
public volatile boolean hasPassword;
|
||||||
public boolean permanent;
|
public volatile boolean permanent;
|
||||||
public int maxClients = -1;
|
public volatile int maxClients = -1;
|
||||||
/** Id of the channel's custom icon in the server's file repository, or 0 for none. */
|
/** Id of the channel's custom icon in the server's file repository, or 0 for none. */
|
||||||
public long iconId;
|
public volatile long iconId;
|
||||||
/** Whether the server sends us this channel's client list; set from the subscription events. */
|
/** Whether the server sends us this channel's client list; set from the subscription events. */
|
||||||
public boolean subscribed;
|
public volatile boolean subscribed;
|
||||||
|
|
||||||
/** Populated when the tree is rebuilt. */
|
/** As of the last {@link ServerModel#buildTree()}; unmodifiable. */
|
||||||
public final List<ChannelNode> children = new ArrayList<>();
|
public volatile List<ChannelNode> children = List.of();
|
||||||
public final List<ClientEntry> clients = new ArrayList<>();
|
public volatile List<ClientEntry> clients = List.of();
|
||||||
|
|
||||||
public ChannelNode(int id, String name) {
|
public ChannelNode(int id, String name) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
|
|||||||
@@ -1,38 +1,41 @@
|
|||||||
package com.ts3client.net;
|
package com.ts3client.net;
|
||||||
|
|
||||||
/** Mutable view-model of a connected client. */
|
/**
|
||||||
|
* View-model of a connected client. The event and audio threads update it while the UI reads
|
||||||
|
* it, so every field is volatile; {@link #serverGroupIds} is replaced, never changed in place.
|
||||||
|
*/
|
||||||
public final class ClientEntry {
|
public final class ClientEntry {
|
||||||
public final int id;
|
public final int id;
|
||||||
public int channelId;
|
public volatile int channelId;
|
||||||
public String nickname;
|
public volatile String nickname;
|
||||||
public String uniqueId = "";
|
public volatile String uniqueId = "";
|
||||||
public int databaseId;
|
public volatile int databaseId;
|
||||||
public int type; // 0 = normal voice client, 1 = server-query
|
public volatile int type; // 0 = normal voice client, 1 = server-query
|
||||||
public int talkPower;
|
public volatile int talkPower;
|
||||||
|
|
||||||
public int[] serverGroupIds = new int[0];
|
public volatile int[] serverGroupIds = new int[0];
|
||||||
public int channelGroupId;
|
public volatile int channelGroupId;
|
||||||
|
|
||||||
// Filled on demand from clientinfo.
|
// Filled on demand from clientinfo.
|
||||||
public String platform = "";
|
public volatile String platform = "";
|
||||||
public String version = "";
|
public volatile String version = "";
|
||||||
public long idleTimeMs;
|
public volatile long idleTimeMs;
|
||||||
public String description = "";
|
public volatile String description = "";
|
||||||
/** MD5 of the client's avatar ({@code client_flag_avatar}); empty when they have none. */
|
/** MD5 of the client's avatar ({@code client_flag_avatar}); empty when they have none. */
|
||||||
public String avatarFlag = "";
|
public volatile String avatarFlag = "";
|
||||||
|
|
||||||
public boolean talking;
|
public volatile boolean talking;
|
||||||
public boolean inputMuted; // microphone muted (client_input_muted)
|
public volatile boolean inputMuted; // microphone muted (client_input_muted)
|
||||||
public boolean outputMuted; // speakers muted / deafened (client_output_muted)
|
public volatile boolean outputMuted; // speakers muted / deafened (client_output_muted)
|
||||||
/** False while the capture device is unavailable: another tab holds the microphone. */
|
/** False while the capture device is unavailable: another tab holds the microphone. */
|
||||||
public boolean inputHardware = true;
|
public volatile boolean inputHardware = true;
|
||||||
/** False while the playback device is unavailable. */
|
/** False while the playback device is unavailable. */
|
||||||
public boolean outputHardware = true;
|
public volatile boolean outputHardware = true;
|
||||||
public boolean away;
|
public volatile 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 volatile String awayMessage = "";
|
||||||
public boolean channelCommander;
|
public volatile boolean channelCommander;
|
||||||
public boolean self;
|
public volatile boolean self;
|
||||||
|
|
||||||
public ClientEntry(int id, String nickname) {
|
public ClientEntry(int id, String nickname) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
@@ -50,25 +53,27 @@ public final class ClientEntry {
|
|||||||
|
|
||||||
/** Adds a server group id, if not already present. */
|
/** Adds a server group id, if not already present. */
|
||||||
public void addServerGroup(int groupId) {
|
public void addServerGroup(int groupId) {
|
||||||
for (int id : serverGroupIds) if (id == groupId) return;
|
int[] ids = serverGroupIds;
|
||||||
int[] updated = java.util.Arrays.copyOf(serverGroupIds, serverGroupIds.length + 1);
|
for (int id : ids) if (id == groupId) return;
|
||||||
updated[serverGroupIds.length] = groupId;
|
int[] updated = java.util.Arrays.copyOf(ids, ids.length + 1);
|
||||||
|
updated[ids.length] = groupId;
|
||||||
serverGroupIds = updated;
|
serverGroupIds = updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Removes a server group id, if present. */
|
/** Removes a server group id, if present. */
|
||||||
public void removeServerGroup(int groupId) {
|
public void removeServerGroup(int groupId) {
|
||||||
|
int[] ids = serverGroupIds;
|
||||||
int index = -1;
|
int index = -1;
|
||||||
for (int i = 0; i < serverGroupIds.length; i++) {
|
for (int i = 0; i < ids.length; i++) {
|
||||||
if (serverGroupIds[i] == groupId) {
|
if (ids[i] == groupId) {
|
||||||
index = i;
|
index = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
int[] updated = new int[serverGroupIds.length - 1];
|
int[] updated = new int[ids.length - 1];
|
||||||
System.arraycopy(serverGroupIds, 0, updated, 0, index);
|
System.arraycopy(ids, 0, updated, 0, index);
|
||||||
System.arraycopy(serverGroupIds, index + 1, updated, index, serverGroupIds.length - index - 1);
|
System.arraycopy(ids, index + 1, updated, index, ids.length - index - 1);
|
||||||
serverGroupIds = updated;
|
serverGroupIds = updated;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,16 @@ package com.ts3client.net;
|
|||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thread-safe holder for the current server state (channels + clients).
|
* The current server state (channels + clients), updated from ts3j's event thread and read
|
||||||
*
|
* from the UI. The maps are guarded by the instance; the {@link ChannelNode}s and
|
||||||
* <p>Mutated from ts3j's event thread and read from the Swing EDT while building
|
* {@link ClientEntry}s handed out are live, with volatile fields, so a reader sees each value
|
||||||
* the tree, so all access is synchronised on the instance.
|
* as it is now but not several of them changed together.
|
||||||
*/
|
*/
|
||||||
public final class ServerModel {
|
public final class ServerModel {
|
||||||
|
|
||||||
@@ -198,7 +199,12 @@ public final class ServerModel {
|
|||||||
public synchronized boolean canJoinChannel(int channelId) {
|
public synchronized boolean canJoinChannel(int channelId) {
|
||||||
ChannelNode channel = channels.get(channelId);
|
ChannelNode channel = channels.get(channelId);
|
||||||
if (channel == null || !channel.subscribed) return false;
|
if (channel == null || !channel.subscribed) return false;
|
||||||
return channel.maxClients < 0 || channel.clients.size() < channel.maxClients;
|
if (channel.maxClients < 0) return true;
|
||||||
|
int inside = 0;
|
||||||
|
for (ClientEntry c : clients.values()) {
|
||||||
|
if (c.channelId == channelId && !c.isQuery()) inside++;
|
||||||
|
}
|
||||||
|
return inside < channel.maxClients;
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized String getServerName() {
|
public synchronized String getServerName() {
|
||||||
@@ -380,24 +386,23 @@ public final class ServerModel {
|
|||||||
* @return the list of root channels (parentId == 0)
|
* @return the list of root channels (parentId == 0)
|
||||||
*/
|
*/
|
||||||
public synchronized List<ChannelNode> buildTree() {
|
public synchronized List<ChannelNode> buildTree() {
|
||||||
// Reset transient child/client lists.
|
// Fresh lists, published whole: whoever still walks the previous tree keeps a consistent one.
|
||||||
for (ChannelNode c : channels.values()) {
|
Map<Integer, List<ChannelNode>> children = new HashMap<>();
|
||||||
c.children.clear();
|
Map<Integer, List<ClientEntry>> inside = new HashMap<>();
|
||||||
c.clients.clear();
|
|
||||||
}
|
|
||||||
List<ChannelNode> roots = new ArrayList<>();
|
List<ChannelNode> roots = new ArrayList<>();
|
||||||
for (ChannelNode c : channels.values()) {
|
for (ChannelNode c : channels.values()) {
|
||||||
ChannelNode parent = channels.get(c.parentId);
|
ChannelNode parent = channels.get(c.parentId);
|
||||||
if (c.parentId == 0 || parent == null) {
|
if (c.parentId == 0 || parent == null) {
|
||||||
roots.add(c);
|
roots.add(c);
|
||||||
} else {
|
} else {
|
||||||
parent.children.add(c);
|
children.computeIfAbsent(parent.id, id -> new ArrayList<>()).add(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (ClientEntry cl : clients.values()) {
|
for (ClientEntry cl : clients.values()) {
|
||||||
if (cl.isQuery()) continue; // hide server-query clients from the tree
|
if (cl.isQuery()) continue; // hide server-query clients from the tree
|
||||||
ChannelNode ch = channels.get(cl.channelId);
|
if (channels.containsKey(cl.channelId)) {
|
||||||
if (ch != null) ch.clients.add(cl);
|
inside.computeIfAbsent(cl.channelId, id -> new ArrayList<>()).add(cl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Comparator<ClientEntry> byClient = Comparator
|
Comparator<ClientEntry> byClient = Comparator
|
||||||
@@ -406,8 +411,12 @@ public final class ServerModel {
|
|||||||
|
|
||||||
sortSiblings(roots);
|
sortSiblings(roots);
|
||||||
for (ChannelNode c : channels.values()) {
|
for (ChannelNode c : channels.values()) {
|
||||||
sortSiblings(c.children);
|
List<ChannelNode> below = children.getOrDefault(c.id, List.of());
|
||||||
c.clients.sort(byClient);
|
sortSiblings(below);
|
||||||
|
c.children = List.copyOf(below);
|
||||||
|
List<ClientEntry> members = inside.getOrDefault(c.id, List.of());
|
||||||
|
if (members.size() > 1) members.sort(byClient);
|
||||||
|
c.clients = List.copyOf(members);
|
||||||
}
|
}
|
||||||
return roots;
|
return roots;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ class ServerModelRulesTest {
|
|||||||
channel.subscribed = true;
|
channel.subscribed = true;
|
||||||
assertTrue(model.canJoinChannel(1));
|
assertTrue(model.canJoinChannel(1));
|
||||||
channel.maxClients = 1;
|
channel.maxClients = 1;
|
||||||
channel.clients.add(model.getClient(5));
|
|
||||||
assertFalse(model.canJoinChannel(1), "full");
|
assertFalse(model.canJoinChannel(1), "full");
|
||||||
assertFalse(model.canJoinChannel(2), "unknown");
|
assertFalse(model.canJoinChannel(2), "unknown");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user