Tabbed servers: multiple simultaneous connections

Each connection now lives in its own ServerTab with its own tree, info
and chat views plus its own mic-mute, deafen, away and commander state.
MainFrame keeps only the shell (menus, toolbar, status bar) and shows
one tab at a time; the tab bar below the toolbar appears once a second
server is open.

Incoming voice from every connection mixes into the same output — each
connection already renders one line per speaker. Capture is exclusive:
a connection only opens the microphone while it holds it, and a toolbar
button moves it to the visible tab. The mic follows a disconnect to
another connected tab, and push-to-talk drives whoever holds it.

Startup bookmarks are no longer mutually exclusive: every flagged
bookmark opens its own tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 15:18:47 +00:00
parent 5db3f4d8c4
commit 03c6ea9a3b
7 changed files with 843 additions and 373 deletions

View File

@@ -30,12 +30,13 @@ public final class Bookmarks {
if (index >= 0 && index < entries.size()) entries.remove(index);
}
/** First bookmark flagged to connect at startup, or {@code null}. */
public Bookmark startupBookmark() {
/** Bookmarks flagged to connect at startup, each in its own tab. */
public List<Bookmark> startupBookmarks() {
List<Bookmark> out = new ArrayList<>();
for (Bookmark b : entries) {
if (b.connectOnStartup) return b;
if (b.connectOnStartup) out.add(b);
}
return null;
return out;
}
public static Bookmarks load() {

View File

@@ -48,6 +48,14 @@ public final class TeamspeakConnection implements TS3Listener {
private volatile int selfClientId = -1;
private volatile long connectedAtMs;
private volatile String serverHost;
private volatile int serverPort;
/**
* Whether this connection owns the capture device. Only one connection may
* capture at a time, so the frontend hands the microphone to a single
* connection and the others keep their (idle) input pipeline.
*/
private volatile boolean microphoneActive;
/** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */
private final Map<Integer, PendingConnInfo> pendingConnInfo = new ConcurrentHashMap<>();
@@ -83,6 +91,41 @@ public final class TeamspeakConnection implements TS3Listener {
return identity;
}
/** Address this connection was last asked to connect to. */
public String getServerHost() {
return serverHost == null ? "" : serverHost;
}
public int getServerPort() {
return serverPort;
}
/**
* Gives this connection the capture device, or takes it away. Capturing only
* starts once connected; calling this before or during a connect is fine, the
* requested state is applied as soon as the microphone exists.
*/
public synchronized void setMicrophoneActive(boolean active) {
microphoneActive = active;
applyMicrophoneState();
}
public boolean isMicrophoneActive() {
return microphoneActive;
}
/** Starts or stops capture to match {@link #microphoneActive}. */
private synchronized void applyMicrophoneState() {
VoiceInput mic = microphone;
if (mic == null || !connected) return;
try {
if (microphoneActive) mic.start();
else mic.stop();
} catch (Exception e) {
ui.onError("Microphone unavailable: " + rootMessage(e));
}
}
// ---- connection lifecycle ----
/**
@@ -112,6 +155,8 @@ public final class TeamspeakConnection implements TS3Listener {
String channel, String channelPassword) {
try {
identity = withIdentity;
serverHost = address;
serverPort = port;
model.clear();
playback = audio.createOutput(settings);
@@ -154,7 +199,6 @@ public final class TeamspeakConnection implements TS3Listener {
// Protocol connection established; anything past this point is best-effort.
selfClientId = client.getClientId();
serverHost = address;
fileTransfers = new FileTransferManager(client, () -> serverHost);
client.setMicrophone(microphone);
connected = true;
@@ -167,16 +211,16 @@ public final class TeamspeakConnection implements TS3Listener {
joinDefaultChannelIfNeeded(channel, channelPassword);
ui.onStatus("Connected to " + model.getServerName());
try {
microphone.start();
} catch (Exception micError) {
ui.onError("Microphone unavailable: " + rootMessage(micError));
}
// Only captures if this connection holds the microphone.
applyMicrophoneState();
} catch (Exception e) {
connected = false;
ui.onError("Connection failed: " + rootMessage(e));
ui.onStatus("Disconnected");
safeCleanup();
// Report the failed attempt as a disconnect too, so the UI leaves the
// "connecting" state and the tab becomes reusable.
ui.onDisconnected("connection failed");
ui.onStatus("Disconnected");
}
}