The toolbar now has a right-click menu to hide/show the status bar and a short Master Volume slider (drag or scroll) that multiplies both voice and notification volume via new Settings.effectiveOutputVolume()/ effectiveSoundVolume() helpers. Also splits MainFrame's toolbar, menu bar and status bar construction into their own MainToolbar/MainMenuBar/StatusBar classes, each talking back to MainFrame only through a Listener interface. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
830 lines
28 KiB
Java
830 lines
28 KiB
Java
package com.ts3client.ui;
|
|
|
|
import com.ts3client.audio.AudioBackend;
|
|
import com.ts3client.audio.desktop.DesktopAudioBackend;
|
|
import com.ts3client.config.AwayMessages;
|
|
import com.ts3client.config.Bookmark;
|
|
import com.ts3client.config.Bookmarks;
|
|
import com.ts3client.config.IdentityStore;
|
|
import com.ts3client.config.Settings;
|
|
import com.ts3client.net.ChannelNode;
|
|
import com.ts3client.sound.SoundNotifier;
|
|
import com.ts3client.sound.SoundPlayer;
|
|
|
|
import javax.swing.BorderFactory;
|
|
import javax.swing.JCheckBox;
|
|
import javax.swing.JCheckBoxMenuItem;
|
|
import javax.swing.JFrame;
|
|
import javax.swing.JLabel;
|
|
import javax.swing.JMenuItem;
|
|
import javax.swing.JOptionPane;
|
|
import javax.swing.JPanel;
|
|
import javax.swing.JPopupMenu;
|
|
import javax.swing.JTextField;
|
|
import javax.swing.SwingUtilities;
|
|
import java.awt.BorderLayout;
|
|
import java.awt.Dimension;
|
|
import java.awt.GridLayout;
|
|
import java.awt.event.WindowAdapter;
|
|
import java.awt.event.WindowEvent;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import java.util.concurrent.atomic.AtomicBoolean;
|
|
|
|
/**
|
|
* The main application window: toolbar, tab bar, status bar and menus, hosting
|
|
* one {@link ServerTab} per open server connection.
|
|
*
|
|
* <p>Several servers can be connected at once — their incoming voice is mixed
|
|
* into the same output — but only one of them owns the microphone at a time
|
|
* ("active" tab, marked in the tab strip and switched from the toolbar).
|
|
*/
|
|
public final class MainFrame extends JFrame implements ServerTabPane.Listener {
|
|
|
|
private final Settings settings;
|
|
private final Bookmarks bookmarks = Bookmarks.load();
|
|
private final AwayMessages awayMessages = AwayMessages.load();
|
|
private final IdentityStore identities;
|
|
private final AudioBackend audio = new DesktopAudioBackend();
|
|
/** Sound pack playback, shared by every connection. */
|
|
private final SoundNotifier sounds;
|
|
private final SoundPlayer soundPlayer;
|
|
|
|
private final List<ServerTab> tabs = new ArrayList<>();
|
|
private final ServerTabPane tabPane = new ServerTabPane(this);
|
|
|
|
/** The tab whose views are on screen. */
|
|
private ServerTab selected;
|
|
/** The tab that owns the capture device; null when nobody is capturing. */
|
|
private ServerTab micTab;
|
|
|
|
private MainMenuBar menuBar;
|
|
private javax.swing.Timer statusTimer;
|
|
|
|
/** Mirrors the active server's own client state next to the clock. */
|
|
private TrayController tray;
|
|
|
|
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;
|
|
|
|
/** Guards {@link #shutdown()} so the window listener and JVM hook don't both run it. */
|
|
private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
|
|
private final Thread shutdownHook = new Thread(this::shutdown, "ts3j-shutdown");
|
|
|
|
public MainFrame(Settings settings) {
|
|
super("TS3J — TeamSpeak 3 Java Client");
|
|
this.settings = settings;
|
|
this.identities = IdentityStore.load(settings);
|
|
this.sounds = new SoundNotifier(settings);
|
|
this.soundPlayer = audio.createSoundPlayer(settings);
|
|
this.sounds.setPlayer(soundPlayer);
|
|
this.hotkeys = new HotkeyService(new HotkeyActions(this), this::hotkeyArgumentChoices);
|
|
|
|
setIconImage(Icons.app().getImage());
|
|
// We tear the connections down ourselves on close, so don't let Swing kill the JVM.
|
|
setDefaultCloseOperation(DO_NOTHING_ON_CLOSE);
|
|
addWindowListener(new WindowAdapter() {
|
|
@Override
|
|
public void windowClosing(WindowEvent e) {
|
|
quit();
|
|
}
|
|
});
|
|
// Catches Ctrl+C / SIGTERM so we still leave the servers cleanly.
|
|
Runtime.getRuntime().addShutdownHook(shutdownHook);
|
|
setMinimumSize(new Dimension(720, 480));
|
|
|
|
tray = new TrayController(this, this::quit);
|
|
menuBar = buildMenuBar();
|
|
setJMenuBar(menuBar);
|
|
|
|
toolbar = buildToolbar();
|
|
add(toolbar, BorderLayout.NORTH);
|
|
// Switching the icon pack in the options dialog re-decorates the whole window.
|
|
IconTheme.get().addListener(() -> SwingUtilities.invokeLater(this::rebuildIcons));
|
|
add(tabPane, BorderLayout.CENTER);
|
|
statusBar = new StatusBar();
|
|
statusBar.setVisible(settings.showStatusBar);
|
|
add(statusBar, BorderLayout.SOUTH);
|
|
|
|
statusBar.setCodec(audio.description());
|
|
|
|
ServerTab first = newTab();
|
|
selectTab(first);
|
|
first.chat().appendSystem("Welcome to the TS3J Swing client.");
|
|
first.chat().appendSystem("Use Connections → Connect to join a server.");
|
|
|
|
statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus());
|
|
statusTimer.start();
|
|
setSize(880, 560);
|
|
setLocationRelativeTo(null);
|
|
|
|
// Deferred so the window is on screen before we start connecting.
|
|
List<Bookmark> startup = bookmarks.startupBookmarks();
|
|
if (!startup.isEmpty()) {
|
|
SwingUtilities.invokeLater(() -> {
|
|
for (Bookmark b : startup) connectToBookmark(b);
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---- UI construction ----
|
|
|
|
private MainMenuBar buildMenuBar() {
|
|
return new MainMenuBar(bookmarks, new MainMenuBar.Listener() {
|
|
@Override
|
|
public void onConnect() {
|
|
showConnectDialog();
|
|
}
|
|
|
|
@Override
|
|
public void onDisconnect() {
|
|
doDisconnect();
|
|
}
|
|
|
|
@Override
|
|
public void onCloseTab() {
|
|
closeTab(selected);
|
|
}
|
|
|
|
@Override
|
|
public void onQuit() {
|
|
quit();
|
|
}
|
|
|
|
@Override
|
|
public void onToggleMic() {
|
|
toolbar.clickMic();
|
|
}
|
|
|
|
@Override
|
|
public void onToggleSpeaker() {
|
|
toolbar.clickSpeaker();
|
|
}
|
|
|
|
@Override
|
|
public void onAwayToggle(boolean away) {
|
|
toggleAway(away);
|
|
}
|
|
|
|
@Override
|
|
public void onAwayStatus() {
|
|
setAwayStatus();
|
|
}
|
|
|
|
@Override
|
|
public void onCommanderToggle(boolean commander) {
|
|
if (selected != null) selected.setCommander(commander);
|
|
}
|
|
|
|
@Override
|
|
public void onChangeNickname() {
|
|
changeNickname();
|
|
}
|
|
|
|
@Override
|
|
public void onShowIdentities() {
|
|
showIdentities();
|
|
}
|
|
|
|
@Override
|
|
public void onShowSettings() {
|
|
showSettings();
|
|
}
|
|
|
|
@Override
|
|
public void onShowAbout() {
|
|
showAbout();
|
|
}
|
|
|
|
@Override
|
|
public void onConnectBookmark(Bookmark bookmark) {
|
|
connectToBookmark(bookmark);
|
|
}
|
|
|
|
@Override
|
|
public void onAddCurrentServerBookmark() {
|
|
addCurrentServerBookmark();
|
|
}
|
|
|
|
@Override
|
|
public void onManageBookmarks() {
|
|
new BookmarksDialog(MainFrame.this, bookmarks, identities,
|
|
MainFrame.this::connectToBookmark, menuBar::rebuildBookmarks).setVisible(true);
|
|
}
|
|
});
|
|
}
|
|
|
|
private MainToolbar buildToolbar() {
|
|
return new MainToolbar(settings, new MainToolbar.Listener() {
|
|
@Override
|
|
public void onConnect() {
|
|
showConnectDialog();
|
|
}
|
|
|
|
@Override
|
|
public void onDisconnect() {
|
|
doDisconnect();
|
|
}
|
|
|
|
@Override
|
|
public void onActivate() {
|
|
moveMicrophoneToSelectedTab();
|
|
}
|
|
|
|
@Override
|
|
public void onMicMuteToggle(boolean muted) {
|
|
if (selected == null) return;
|
|
selected.setMicMuted(muted);
|
|
updateToolbar();
|
|
}
|
|
|
|
@Override
|
|
public void onDeafenToggle(boolean deafened) {
|
|
if (selected == null) return;
|
|
selected.setDeafened(deafened);
|
|
updateToolbar();
|
|
}
|
|
|
|
@Override
|
|
public void onAwayToggle(boolean away) {
|
|
toggleAway(away);
|
|
}
|
|
|
|
@Override
|
|
public JPopupMenu buildAwayMenu() {
|
|
return MainFrame.this.buildAwayMenu();
|
|
}
|
|
|
|
@Override
|
|
public void onSettings() {
|
|
showSettings();
|
|
}
|
|
|
|
@Override
|
|
public void onMasterVolumeChanged() {
|
|
applyOutputSettingsToAllTabs();
|
|
}
|
|
|
|
@Override
|
|
public void onStatusBarVisibilityChanged(boolean visible) {
|
|
statusBar.setVisible(visible);
|
|
revalidate();
|
|
repaint();
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Rebuilds the icon-bearing chrome after the active icon pack changed. */
|
|
private void rebuildIcons() {
|
|
setIconImage(Icons.app().getImage());
|
|
menuBar = buildMenuBar();
|
|
setJMenuBar(menuBar);
|
|
remove(toolbar);
|
|
toolbar = buildToolbar();
|
|
add(toolbar, BorderLayout.NORTH);
|
|
updateToolbar();
|
|
refreshTabs();
|
|
if (tray != null) tray.refreshIcon();
|
|
revalidate();
|
|
repaint();
|
|
}
|
|
|
|
// ---- tab management ----
|
|
|
|
private ServerTab newTab() {
|
|
ServerTab tab = new ServerTab(this, settings, identities, audio, sounds);
|
|
tabs.add(tab);
|
|
tabPane.addTab(tab);
|
|
refreshTabs();
|
|
return tab;
|
|
}
|
|
|
|
/** The tab a new connection should use: the current one if it is free, else a new one. */
|
|
private ServerTab tabForNewConnection() {
|
|
if (selected != null && !selected.isBusy()) return selected;
|
|
ServerTab tab = newTab();
|
|
selectTab(tab);
|
|
return tab;
|
|
}
|
|
|
|
@Override
|
|
public void selectTab(ServerTab tab) {
|
|
if (tab == null || !tabs.contains(tab)) return;
|
|
selected = tab;
|
|
tabPane.setSelected(tab);
|
|
refreshTabs();
|
|
updateToolbar();
|
|
updateStatusLabel();
|
|
}
|
|
|
|
@Override
|
|
public void closeTab(ServerTab tab) {
|
|
if (tab == null) return;
|
|
tab.disconnect();
|
|
if (tabs.size() == 1) return; // always keep one view around
|
|
|
|
tabs.remove(tab);
|
|
tab.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();
|
|
}
|
|
|
|
/** 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();
|
|
// Closing and reopening the capture line can block briefly; keep it off the EDT.
|
|
new Thread(() -> {
|
|
if (previous != null) previous.setMicrophoneActive(false);
|
|
if (tab != null) tab.setMicrophoneActive(true);
|
|
}, "mic-handover").start();
|
|
}
|
|
|
|
/** Gives the microphone to some connected tab when nobody holds it. */
|
|
private void assignMicrophoneIfFree() {
|
|
if (micTab != null && tabs.contains(micTab) && micTab.isConnected()) return;
|
|
micTab = null;
|
|
for (ServerTab t : tabs) {
|
|
if (t.isConnected()) {
|
|
setMicTab(t);
|
|
return;
|
|
}
|
|
}
|
|
refreshTabs();
|
|
}
|
|
|
|
/** Repaints the tab labels (title, microphone marker). */
|
|
private void refreshTabs() {
|
|
tabPane.refresh(micTab);
|
|
}
|
|
|
|
// ---- callbacks from ServerTab ----
|
|
|
|
/** A tab's title, status or connection state changed. */
|
|
void tabUpdated(ServerTab tab) {
|
|
if (!tabs.contains(tab)) return;
|
|
refreshTabs();
|
|
if (tab == selected) {
|
|
updateToolbar();
|
|
updateStatusLabel();
|
|
}
|
|
updateTray();
|
|
}
|
|
|
|
/** The local client on {@code tab} started or stopped talking, muted itself, … */
|
|
void selfStateChanged(ServerTab tab) {
|
|
if (tabs.contains(tab)) updateTray();
|
|
}
|
|
|
|
void tabConnected(ServerTab tab) {
|
|
if (!tabs.contains(tab)) return;
|
|
assignMicrophoneIfFree();
|
|
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();
|
|
tabUpdated(tab);
|
|
}
|
|
|
|
// ---- hotkeys ----
|
|
|
|
/**
|
|
* Which connections a fired hotkey reaches: with "on active server" ticked only the
|
|
* selected tab, otherwise every connected one.
|
|
*/
|
|
List<ServerTab> hotkeyTargets(boolean activeServerOnly) {
|
|
List<ServerTab> out = new ArrayList<>();
|
|
if (activeServerOnly) {
|
|
if (selected != null && selected.isConnected()) out.add(selected);
|
|
return out;
|
|
}
|
|
for (ServerTab tab : tabs) {
|
|
if (tab.isConnected()) out.add(tab);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
ServerTab selectedTab() {
|
|
return selected;
|
|
}
|
|
|
|
List<ServerTab> allTabs() {
|
|
return new ArrayList<>(tabs);
|
|
}
|
|
|
|
/** Opens the microphone while a push-to-talk hotkey is held. */
|
|
void setPushToTalk(boolean talking) {
|
|
pttPressed = talking;
|
|
ServerTab tab = micTab;
|
|
if (tab == null || !tab.isConnected() || tab.connection().getMicrophone() == null) return;
|
|
tab.connection().getMicrophone().setPushToTalk(talking);
|
|
}
|
|
|
|
void moveMicrophoneToSelectedTab() {
|
|
if (selected != null && selected.isConnected()) setMicTab(selected);
|
|
updateToolbar();
|
|
}
|
|
|
|
/** Connects to the bookmark with this label; no label means the first one. */
|
|
void connectBookmark(String label, boolean newTab) {
|
|
Bookmark match = null;
|
|
for (Bookmark b : bookmarks.all()) {
|
|
if (label == null || label.isBlank() || label.equalsIgnoreCase(b.label)) {
|
|
match = b;
|
|
break;
|
|
}
|
|
}
|
|
if (match == null) return;
|
|
if (newTab) selectTab(newTab());
|
|
connectToBookmark(match);
|
|
}
|
|
|
|
/** Selects the tab with this 1-based number, as the "Select Server Tab" action names it. */
|
|
void selectTabNumber(int number) {
|
|
if (number >= 1 && number <= tabs.size()) selectTab(tabs.get(number - 1));
|
|
}
|
|
|
|
void stepTab(int delta) {
|
|
if (tabs.isEmpty()) return;
|
|
int index = Math.max(0, tabs.indexOf(selected));
|
|
selectTab(tabs.get(Math.floorMod(index + delta, tabs.size())));
|
|
}
|
|
|
|
void setSoundsMuted(boolean muted) {
|
|
sounds.setMuted(muted);
|
|
}
|
|
|
|
boolean areSoundsMuted() {
|
|
return sounds.isMuted();
|
|
}
|
|
|
|
void adjustMasterVolume(double delta) {
|
|
settings.outputVolume = Math.max(0, Math.min(2.0, settings.outputVolume + delta));
|
|
settings.save();
|
|
applyOutputSettingsToAllTabs();
|
|
updateStatusLabel();
|
|
}
|
|
|
|
/** Applies a new nickname, or asks for one when the hotkey carries none. */
|
|
void changeNickname(String nickname) {
|
|
if (nickname == null || nickname.isBlank()) {
|
|
changeNickname();
|
|
return;
|
|
}
|
|
settings.nickname = nickname.trim();
|
|
settings.save();
|
|
for (ServerTab tab : tabs) tab.setNickname(settings.nickname);
|
|
}
|
|
|
|
void browseCurrentChannel() {
|
|
if (selected == null || !selected.isConnected()) return;
|
|
ChannelNode channel = selected.currentChannel();
|
|
if (channel != null) selected.browseFiles(channel);
|
|
}
|
|
|
|
void reloadSkin() {
|
|
IconTheme.get().reload(settings);
|
|
}
|
|
|
|
void bringToFront() {
|
|
setVisible(true);
|
|
setExtendedState(getExtendedState() & ~JFrame.ICONIFIED);
|
|
toFront();
|
|
requestFocus();
|
|
}
|
|
|
|
void sendToBack() {
|
|
setExtendedState(getExtendedState() | JFrame.ICONIFIED);
|
|
}
|
|
|
|
/**
|
|
* The values a parameterised hotkey action can take, for the tree in the hotkey
|
|
* dialog: the saved bookmarks, the installed sound packs and the channels of the
|
|
* server on screen.
|
|
*/
|
|
private List<String> hotkeyArgumentChoices(com.ts3client.hotkey.HotkeyAction action) {
|
|
List<String> out = new ArrayList<>();
|
|
switch (action.argument()) {
|
|
case BOOKMARK -> {
|
|
for (Bookmark b : bookmarks.all()) {
|
|
if (b.label != null && !b.label.isBlank()) out.add(b.label);
|
|
}
|
|
}
|
|
case PROFILE -> {
|
|
if (action == com.ts3client.hotkey.HotkeyAction.SOUNDPACK_ACTIVATE) {
|
|
for (com.ts3client.sound.SoundPack pack : sounds.availablePacks()) out.add(pack.name());
|
|
}
|
|
}
|
|
case CHANNEL -> {
|
|
if (selected != null && selected.isConnected()) {
|
|
out.addAll(selected.channelPaths());
|
|
}
|
|
}
|
|
default -> {
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Repaints the chrome after a hotkey changed the local client's state. */
|
|
void refreshAfterHotkey() {
|
|
updateToolbar();
|
|
updateTray();
|
|
refreshTabs();
|
|
}
|
|
|
|
// ---- actions ----
|
|
|
|
private void showConnectDialog() {
|
|
ConnectDialog dlg = new ConnectDialog(this, settings, identities);
|
|
dlg.setVisible(true);
|
|
if (!dlg.isConfirmed()) return;
|
|
startConnection(dlg.getAddress(), dlg.getPort(), dlg.getNickname(), dlg.getPassword(),
|
|
dlg.getIdentityId(), "", "");
|
|
}
|
|
|
|
/**
|
|
* Opens a connection in the current tab when it is free, otherwise in a new one.
|
|
*
|
|
* @param identityId identity to use, or empty for the default one
|
|
* @param channel channel path to join on connect, or empty for the default channel
|
|
* @param channelPassword password for that channel, if any
|
|
*/
|
|
private void startConnection(String address, int port, String nickname, String password, String identityId,
|
|
String channel, String channelPassword) {
|
|
settings.lastAddress = address + ":" + port;
|
|
settings.nickname = nickname;
|
|
settings.serverPassword = password;
|
|
settings.save();
|
|
|
|
ServerTab tab = tabForNewConnection();
|
|
// The first connection to come up takes the microphone; later ones are muted
|
|
// until the user activates them.
|
|
if (micTab == null) setMicTab(tab);
|
|
tab.connect(address, port, nickname, password, identityId, channel, channelPassword);
|
|
}
|
|
|
|
private void connectToBookmark(Bookmark b) {
|
|
String nick = (b.nickname != null && !b.nickname.isBlank()) ? b.nickname : settings.nickname;
|
|
startConnection(b.address, b.port, nick, b.password, b.identityId, b.channel, b.channelPassword);
|
|
}
|
|
|
|
private void addCurrentServerBookmark() {
|
|
if (selected == null || !selected.isConnected()) {
|
|
JOptionPane.showMessageDialog(this, "Connect to a server first.",
|
|
"Add bookmark", JOptionPane.INFORMATION_MESSAGE);
|
|
return;
|
|
}
|
|
String addr = selected.address();
|
|
int port = selected.port();
|
|
String channelPath = selected.currentChannelPath();
|
|
|
|
JTextField labelField = new JTextField(selected.title());
|
|
JCheckBox joinChannel = new JCheckBox("Join \"" + channelPath + "\" on connect", !channelPath.isEmpty());
|
|
JPanel form = new JPanel(new GridLayout(0, 1, 0, 2));
|
|
form.add(new JLabel("Bookmark label:"));
|
|
form.add(labelField);
|
|
if (!channelPath.isEmpty()) form.add(joinChannel);
|
|
if (JOptionPane.showConfirmDialog(this, form, "Add bookmark",
|
|
JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE) != JOptionPane.OK_OPTION) {
|
|
return;
|
|
}
|
|
Bookmark bookmark = new Bookmark(labelField.getText().trim(), addr, port,
|
|
settings.nickname, settings.serverPassword);
|
|
bookmark.identityId = selected.identityId();
|
|
if (joinChannel.isSelected()) bookmark.channel = channelPath;
|
|
bookmarks.add(bookmark);
|
|
bookmarks.save();
|
|
menuBar.rebuildBookmarks();
|
|
}
|
|
|
|
/** 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(ServerTab::isConnected);
|
|
|
|
JCheckBoxMenuItem globally = new JCheckBoxMenuItem("Set Globally Away", Icons.of("AWAY"), isGloballyAway());
|
|
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());
|
|
if (message != null) setAwayEverywhere(true, message);
|
|
});
|
|
|
|
menu.add(globally);
|
|
menu.add(globalStatus);
|
|
menu.addSeparator();
|
|
|
|
for (String preset : awayMessages.all()) {
|
|
JMenuItem item = new JMenuItem(preset);
|
|
item.setEnabled(anyConnected);
|
|
item.addActionListener(e -> setAwayEverywhere(true, preset));
|
|
menu.add(item);
|
|
}
|
|
if (!awayMessages.all().isEmpty()) menu.addSeparator();
|
|
|
|
JMenuItem manage = new JMenuItem("Manage away messages…", Icons.of("EDIT"));
|
|
manage.addActionListener(e -> new AwayMessagesDialog(this, awayMessages, null).setVisible(true));
|
|
menu.add(manage);
|
|
return menu;
|
|
}
|
|
|
|
private void toggleAway(boolean away) {
|
|
if (selected == null) return;
|
|
selected.setAway(away, "");
|
|
updateToolbar();
|
|
}
|
|
|
|
/** Sets the away message on the selected server only. */
|
|
private void setAwayStatus() {
|
|
if (selected == null) return;
|
|
String message = askAwayMessage(selected.awayMessage());
|
|
if (message == null) return;
|
|
selected.setAway(true, message);
|
|
updateToolbar();
|
|
}
|
|
|
|
private void setAwayEverywhere(boolean away, String message) {
|
|
for (ServerTab tab : tabs) {
|
|
if (tab.isConnected()) tab.setAway(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.isConnected()) continue;
|
|
any = true;
|
|
if (!tab.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.awayMessage().isEmpty()) return selected.awayMessage();
|
|
for (ServerTab tab : tabs) {
|
|
if (tab.isConnected() && !tab.awayMessage().isEmpty()) return tab.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",
|
|
JOptionPane.PLAIN_MESSAGE, Icons.of("AWAY"), null, initial);
|
|
}
|
|
|
|
private void doDisconnect() {
|
|
if (selected != null) selected.disconnect();
|
|
}
|
|
|
|
/**
|
|
* Tear everything down before the process exits: disconnect from every server
|
|
* synchronously (so the "Leaving" notification actually reaches them) and stop
|
|
* background timers. Idempotent and safe to call from any thread — the window
|
|
* close handler, the Quit menu and the JVM shutdown hook may all invoke it.
|
|
*/
|
|
private void shutdown() {
|
|
if (!shuttingDown.compareAndSet(false, true)) return;
|
|
if (statusTimer != null) statusTimer.stop();
|
|
try {
|
|
Runtime.getRuntime().removeShutdownHook(shutdownHook);
|
|
} catch (IllegalStateException ignored) {
|
|
// Already shutting down (hook itself is running); nothing to remove.
|
|
}
|
|
for (ServerTab tab : new ArrayList<>(tabs)) {
|
|
tab.shutdown();
|
|
}
|
|
soundPlayer.shutdown();
|
|
hotkeys.dispose();
|
|
if (tray != null) tray.dispose();
|
|
}
|
|
|
|
/** Leaves every server and ends the process. */
|
|
private void quit() {
|
|
shutdown();
|
|
System.exit(0);
|
|
}
|
|
|
|
private void showIdentities() {
|
|
new IdentitiesDialog(this, identities, settings, bookmarks, null).setVisible(true);
|
|
}
|
|
|
|
private void showSettings() {
|
|
// 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,
|
|
micTab == null ? null : micTab.connection().getMicrophone(),
|
|
selected == null ? null : selected.connection().getPlayback(),
|
|
sounds, hotkeys,
|
|
this::applyOutputSettingsToAllTabs);
|
|
dlg.setVisible(true);
|
|
}
|
|
|
|
/** Master volume / output device are global, so push them to every open connection. */
|
|
private void applyOutputSettingsToAllTabs() {
|
|
soundPlayer.setOutputDevice(settings.outputDevice);
|
|
for (ServerTab tab : tabs) {
|
|
if (tab.connection().getPlayback() == null) continue;
|
|
tab.connection().getPlayback().setMasterVolume(settings.effectiveOutputVolume());
|
|
tab.connection().getPlayback().setOutputDevice(settings.outputDevice);
|
|
}
|
|
}
|
|
|
|
private void changeNickname() {
|
|
String n = JOptionPane.showInputDialog(this, "New nickname:", settings.nickname);
|
|
if (n != null && !n.trim().isEmpty()) {
|
|
settings.nickname = n.trim();
|
|
settings.save();
|
|
for (ServerTab tab : tabs) tab.setNickname(settings.nickname);
|
|
}
|
|
}
|
|
|
|
private void showAbout() {
|
|
JOptionPane.showMessageDialog(this,
|
|
"TS3J Swing Client\n\n" +
|
|
"An open-source TeamSpeak 3 desktop client built on the ts3j\n" +
|
|
"reverse-engineered protocol library, with native Opus voice,\n" +
|
|
"voice-activation detection and push-to-talk.\n\n" +
|
|
statusBar.codecText(),
|
|
"About TS3J", JOptionPane.INFORMATION_MESSAGE);
|
|
}
|
|
|
|
// ---- toolbar / status ----
|
|
|
|
private void updateToolbar() {
|
|
boolean connected = selected != null && selected.isConnected();
|
|
boolean anyConnected = tabs.stream().anyMatch(ServerTab::isConnected);
|
|
|
|
boolean micMuted = connected && selected.isMicMuted();
|
|
boolean deaf = connected && selected.isDeafened();
|
|
boolean active = selected != null && selected == micTab;
|
|
boolean away = connected && selected.isAway();
|
|
boolean commander = connected && selected.isCommander();
|
|
toolbar.refresh(connected, anyConnected, micMuted, deaf, active, away);
|
|
menuBar.refresh(connected, away, commander);
|
|
updateTray();
|
|
}
|
|
|
|
/**
|
|
* The server the tray icon speaks for: the one holding the microphone, or the
|
|
* visible one when nobody is capturing.
|
|
*/
|
|
private ServerTab trayTab() {
|
|
if (micTab != null && micTab.isConnected()) return micTab;
|
|
return selected != null && selected.isConnected() ? selected : null;
|
|
}
|
|
|
|
private void updateTray() {
|
|
if (tray == null) return;
|
|
ServerTab tab = trayTab();
|
|
if (tab == null) {
|
|
tray.update(SelfState.DISCONNECTED, null);
|
|
} else {
|
|
tray.update(tab.selfState(), tab.title());
|
|
}
|
|
}
|
|
|
|
private void updateStatusLabel() {
|
|
statusBar.setStatus(selected == null ? "Not connected" : selected.status());
|
|
}
|
|
|
|
private void updateConnectionStatus() {
|
|
ServerTab tab = selected;
|
|
if (tab == null || !tab.isConnected()) return;
|
|
int users = tab.connection().getModel().clientCount();
|
|
StringBuilder s = new StringBuilder("Connected to ")
|
|
.append(tab.connection().getModel().getServerName())
|
|
.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");
|
|
statusBar.setStatus(s.toString());
|
|
}
|
|
}
|