diff --git a/ts3-client/core/src/main/java/com/ts3client/session/Sessions.java b/ts3-client/core/src/main/java/com/ts3client/session/Sessions.java new file mode 100644 index 0000000..c649140 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/session/Sessions.java @@ -0,0 +1,153 @@ +package com.ts3client.session; + +import com.ts3client.audio.VoiceInput; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Every server the client is on, and what they share. Only one of them can capture with the + * microphone at a time, so it is handed from one to another here; the away state and the + * nickname are the user's own, so they can be set on every server at once. + * + *

Driven from one thread, the frontend's. + */ +public final class Sessions { + + private final List sessions = new ArrayList<>(); + private final Runnable onMicrophoneMoved; + private ServerSession microphoneOwner; + /** + * Opens and closes capture lines, which can block briefly, off the frontend's thread. One + * thread, so quick successive hand-overs stop and start in the order they were made. + */ + private final ExecutorService handover = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "mic-handover"); + t.setDaemon(true); + return t; + }); + + /** @param onMicrophoneMoved told whenever the microphone changes hands */ + public Sessions(Runnable onMicrophoneMoved) { + this.onMicrophoneMoved = onMicrophoneMoved; + } + + public void add(ServerSession session) { + sessions.add(session); + } + + /** Forgets a session that is being closed; the microphone moves on if it had it. */ + public void remove(ServerSession session) { + sessions.remove(session); + if (microphoneOwner == session) microphoneOwner = null; + assignMicrophoneIfFree(); + } + + public boolean anyConnected() { + for (ServerSession s : sessions) { + if (s.isConnected()) return true; + } + return false; + } + + // ---- the microphone ---- + + /** The session capturing with the microphone, or null when none is. */ + public ServerSession microphoneOwner() { + return microphoneOwner; + } + + /** Moves the microphone to {@code session}, taking it from whoever held it. */ + public void giveMicrophoneTo(ServerSession session) { + if (microphoneOwner == session) return; + ServerSession previous = microphoneOwner; + microphoneOwner = session; + handover.execute(() -> { + if (previous != null) previous.setMicrophoneActive(false); + if (session != null) session.setMicrophoneActive(true); + }); + onMicrophoneMoved.run(); + } + + /** Gives the microphone to some connected session when nobody who could use it holds it. */ + public void assignMicrophoneIfFree() { + if (microphoneOwner != null && sessions.contains(microphoneOwner) && microphoneOwner.isConnected()) return; + ServerSession previous = microphoneOwner; + microphoneOwner = null; + for (ServerSession s : sessions) { + if (s.isConnected()) { + giveMicrophoneTo(s); + return; + } + } + if (previous != null) onMicrophoneMoved.run(); + } + + public void connected(ServerSession session) { + assignMicrophoneIfFree(); + } + + /** A disconnected session cannot capture; the microphone moves on if it had it. */ + public void disconnected(ServerSession session) { + if (microphoneOwner == session) microphoneOwner = null; + assignMicrophoneIfFree(); + } + + /** Opens the microphone while push-to-talk is held. */ + public void setPushToTalk(boolean talking) { + VoiceInput mic = microphoneOf(microphoneOwner); + if (mic != null) mic.setPushToTalk(talking); + } + + /** Tells every connected microphone about a key press, as TS3 tells all its connections. */ + public void keyPressed() { + for (ServerSession s : sessions) { + VoiceInput mic = microphoneOf(s); + if (mic != null) mic.keyPressed(); + } + } + + private static VoiceInput microphoneOf(ServerSession session) { + return session == null || !session.isConnected() ? null : session.connection().getMicrophone(); + } + + // ---- the user's own state ---- + + public void setAwayEverywhere(boolean away, String message) { + for (ServerSession s : sessions) { + if (s.isConnected()) s.setAway(away, message); + } + } + + /** @return true when every connected server has us away, and there is one */ + public boolean isAwayEverywhere() { + boolean any = false; + for (ServerSession s : sessions) { + if (!s.isConnected()) continue; + any = true; + if (!s.isAway()) return false; + } + return any; + } + + /** The away message to suggest: {@code preferred}'s, else any server's that has one. */ + public String awayMessage(ServerSession preferred) { + if (preferred != null && !preferred.awayMessage().isEmpty()) return preferred.awayMessage(); + for (ServerSession s : sessions) { + if (s.isConnected() && !s.awayMessage().isEmpty()) return s.awayMessage(); + } + return ""; + } + + public void setNickname(String nickname) { + for (ServerSession s : sessions) s.setNickname(nickname); + } + + /** Leaves every server, waiting until each has been told. */ + public void shutdown() { + for (ServerSession s : new ArrayList<>(sessions)) s.shutdown(); + handover.shutdown(); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index 73928d1..144e9be 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -13,6 +13,8 @@ import com.ts3client.teamspeak.TeamSpeakImporter; import com.ts3client.contacts.ContactStore; import com.ts3client.net.ChannelNode; import com.ts3client.session.SelfState; +import com.ts3client.session.ServerSession; +import com.ts3client.session.Sessions; import com.ts3client.sound.SoundNotifier; import com.ts3client.sound.SoundPlayer; @@ -37,8 +39,6 @@ import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.util.ArrayList; import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; /** @@ -63,22 +63,16 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { private final SoundPlayer soundPlayer; private final List tabs = new ArrayList<>(); + /** The tabs' sessions: who holds the microphone, and the state shared by every server. */ + private final Sessions sessions = new Sessions(() -> { + refreshTabs(); + updateTray(); + }); private final ServerTabPane tabPane = new ServerTabPane(this); /** The tab whose views are on screen. */ private ServerTab selected; private BanReasonPresets banReasonPresets; - /** The tab that owns the capture device; null when nobody is capturing. */ - private ServerTab micTab; - /** - * Opens and closes capture lines, which can block briefly, off the EDT. One thread, so quick - * successive hand-overs stop and start the microphone in the order they were made. - */ - private final ExecutorService micHandover = Executors.newSingleThreadExecutor(r -> { - Thread t = new Thread(r, "mic-handover"); - t.setDaemon(true); - return t; - }); private MainMenuBar menuBar; private javax.swing.Timer statusTimer; @@ -89,8 +83,6 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { private MainToolbar toolbar; private StatusBar statusBar; - private boolean pttPressed; - /** Global hotkeys: the bindings, the matching engine and the platform input hook. */ private final HotkeyService hotkeys; @@ -107,7 +99,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { this.soundPlayer = audio.createSoundPlayer(settings); this.sounds.setPlayer(soundPlayer); this.hotkeys = new HotkeyService(new HotkeyActions(this), this::hotkeyArgumentChoices); - hotkeys.addKeyPressListener(() -> SwingUtilities.invokeLater(this::keyPressed)); + hotkeys.addKeyPressListener(() -> SwingUtilities.invokeLater(sessions::keyPressed)); contacts.addListener(() -> SwingUtilities.invokeLater(this::contactsChanged)); setIconImages(Icons.appImages()); @@ -355,6 +347,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { private ServerTab newTab() { ServerTab tab = new ServerTab(this, settings, identities, audio, sounds, contacts); tabs.add(tab); + sessions.add(tab.session()); tabPane.addTab(tab); refreshTabs(); return tab; @@ -387,45 +380,27 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { tabs.remove(tab); tab.session().dispose(); tabPane.removeTab(tab); - if (micTab == tab) micTab = null; if (selected == tab) { selected = null; selectTab(tabs.get(0)); } else { selectTab(selected); // the pane rebuilt itself; restore the selection } - assignMicrophoneIfFree(); + sessions.remove(tab.session()); } - /** Moves the capture device to {@code tab}, taking it from whoever held it. */ - private void setMicTab(ServerTab tab) { - if (micTab == tab) return; - ServerTab previous = micTab; - micTab = tab; - refreshTabs(); - updateTray(); - micHandover.execute(() -> { - if (previous != null) previous.session().setMicrophoneActive(false); - if (tab != null) tab.session().setMicrophoneActive(true); - }); - } - - /** Gives the microphone to some connected tab when nobody holds it. */ - private void assignMicrophoneIfFree() { - if (micTab != null && tabs.contains(micTab) && micTab.session().isConnected()) return; - micTab = null; - for (ServerTab t : tabs) { - if (t.session().isConnected()) { - setMicTab(t); - return; - } + /** The tab that holds the capture device; null when nobody is capturing. */ + private ServerTab micTab() { + ServerSession owner = sessions.microphoneOwner(); + for (ServerTab tab : tabs) { + if (tab.session() == owner) return tab; } - refreshTabs(); + return null; } /** Repaints the tab labels (title, microphone marker). */ private void refreshTabs() { - tabPane.refresh(micTab); + tabPane.refresh(micTab()); } // ---- callbacks from ServerTab ---- @@ -448,15 +423,13 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { void tabConnected(ServerTab tab) { if (!tabs.contains(tab)) return; - assignMicrophoneIfFree(); + sessions.connected(tab.session()); tabUpdated(tab); } void tabDisconnected(ServerTab tab) { if (!tabs.contains(tab)) return; - // A disconnected tab cannot capture; hand the microphone on if it held it. - if (micTab == tab) micTab = null; - assignMicrophoneIfFree(); + sessions.disconnected(tab.session()); tabUpdated(tab); } @@ -488,23 +461,11 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { /** Opens the microphone while a push-to-talk hotkey is held. */ void setPushToTalk(boolean talking) { - pttPressed = talking; - ServerTab tab = micTab; - if (tab == null || !tab.session().isConnected() || tab.connection().getMicrophone() == null) return; - tab.connection().getMicrophone().setPushToTalk(talking); - } - - /** Tells every connected microphone about a key press, as TS3 tells all its connections. */ - private void keyPressed() { - for (ServerTab tab : tabs) { - if (tab.session().isConnected() && tab.connection().getMicrophone() != null) { - tab.connection().getMicrophone().keyPressed(); - } - } + sessions.setPushToTalk(talking); } void moveMicrophoneToSelectedTab() { - if (selected != null && selected.session().isConnected()) setMicTab(selected); + if (selected != null && selected.session().isConnected()) sessions.giveMicrophoneTo(selected.session()); updateToolbar(); } @@ -556,7 +517,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { } settings.nickname = nickname.trim(); settings.save(); - for (ServerTab tab : tabs) tab.session().setNickname(settings.nickname); + sessions.setNickname(settings.nickname); } void browseCurrentChannel() { @@ -652,7 +613,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { ServerTab tab = (replaceCurrent && selected != null) ? selected : tabForNewConnection(); // The first connection to come up takes the microphone; later ones are muted // until the user activates them. - if (micTab == null) setMicTab(tab); + if (sessions.microphoneOwner() == null) sessions.giveMicrophoneTo(tab.session()); tab.session().switchTo(address, port, nickname, password, identityId, channel, channelPassword); } @@ -694,16 +655,17 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { /** The away button's drop-down: the global actions, the presets and their editor. */ private JPopupMenu buildAwayMenu() { JPopupMenu menu = new JPopupMenu(); - boolean anyConnected = tabs.stream().anyMatch(t -> t.session().isConnected()); + boolean anyConnected = sessions.anyConnected(); - JCheckBoxMenuItem globally = new JCheckBoxMenuItem("Set Globally Away", Icons.of("AWAY"), isGloballyAway()); + JCheckBoxMenuItem globally = new JCheckBoxMenuItem("Set Globally Away", Icons.of("AWAY"), + sessions.isAwayEverywhere()); globally.setEnabled(anyConnected); globally.addActionListener(e -> setAwayEverywhere(globally.isSelected(), null)); JMenuItem globalStatus = new JMenuItem("Set Globally Away Status…", Icons.of("EDIT")); globalStatus.setEnabled(anyConnected); globalStatus.addActionListener(e -> { - String message = askAwayMessage(currentAwayMessage()); + String message = askAwayMessage(sessions.awayMessage(selected == null ? null : selected.session())); if (message != null) setAwayEverywhere(true, message); }); @@ -741,32 +703,10 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { } private void setAwayEverywhere(boolean away, String message) { - for (ServerTab tab : tabs) { - if (tab.session().isConnected()) tab.session().setAway(away, message); - } + sessions.setAwayEverywhere(away, message); updateToolbar(); } - /** @return true when every connected server is marked away (and there is one) */ - private boolean isGloballyAway() { - boolean any = false; - for (ServerTab tab : tabs) { - if (!tab.session().isConnected()) continue; - any = true; - if (!tab.session().isAway()) return false; - } - return any; - } - - /** The message to preload the prompt with: the selected tab's, else any set one. */ - private String currentAwayMessage() { - if (selected != null && !selected.session().awayMessage().isEmpty()) return selected.session().awayMessage(); - for (ServerTab tab : tabs) { - if (tab.session().isConnected() && !tab.session().awayMessage().isEmpty()) return tab.session().awayMessage(); - } - return ""; - } - /** @return the entered message (possibly empty), or null when cancelled */ private String askAwayMessage(String initial) { return (String) JOptionPane.showInputDialog(this, "Away message (optional):", "Away status", @@ -792,9 +732,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { } catch (IllegalStateException ignored) { // Already shutting down (hook itself is running); nothing to remove. } - for (ServerTab tab : new ArrayList<>(tabs)) { - tab.session().shutdown(); - } + sessions.shutdown(); soundPlayer.shutdown(); hotkeys.dispose(); if (tray != null) tray.dispose(); @@ -846,7 +784,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { // Device and voice-gating changes apply to the capturing connection and the // playback of the visible one; the rest pick the new settings up on connect. SettingsDialog dlg = new SettingsDialog(this, settings, audio, - micTab == null ? null : micTab.connection().getMicrophone(), + sessions.microphoneOwner() == null ? null : sessions.microphoneOwner().connection().getMicrophone(), selected == null ? null : selected.connection().getPlayback(), sounds, hotkeys, () -> { @@ -889,11 +827,11 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { private void updateToolbar() { boolean connected = selected != null && selected.session().isConnected(); - boolean anyConnected = tabs.stream().anyMatch(t -> t.session().isConnected()); + boolean anyConnected = sessions.anyConnected(); boolean micMuted = connected && selected.session().isMicMuted(); boolean deaf = connected && selected.session().isDeafened(); - boolean active = selected != null && selected == micTab; + boolean active = selected != null && selected == micTab(); boolean away = connected && selected.session().isAway(); boolean commander = connected && selected.session().isCommander(); toolbar.refresh(connected, anyConnected, micMuted, deaf, active, away); @@ -906,6 +844,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { * visible one when nobody is capturing. */ private ServerTab trayTab() { + ServerTab micTab = micTab(); if (micTab != null && micTab.session().isConnected()) return micTab; return selected != null && selected.session().isConnected() ? selected : null; } @@ -933,7 +872,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { .append(" | ").append(users).append(users == 1 ? " user" : " users"); double ping = tab.connection().getPingMillis(); if (ping >= 0) s.append(" | ping ").append(Math.round(ping)).append(" ms"); - if (tab != micTab) s.append(" | microphone on another tab"); + if (tab != micTab()) s.append(" | microphone on another tab"); statusBar.setStatus(s.toString()); } }