Files
ts3java/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java

977 lines
35 KiB
Java

package com.ts3client.ui;
import com.formdev.flatlaf.util.UIScale;
import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.desktop.DesktopAudioBackend;
import com.ts3client.config.AppDirs;
import com.ts3client.config.AwayMessages;
import com.ts3client.config.BanReasonPresets;
import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityStore;
import com.ts3client.config.Settings;
import com.ts3client.myts.MyTeamSpeakLogin;
import com.ts3client.teamspeak.TeamSpeakImporter;
import com.ts3client.contacts.ContactStore;
import com.ts3client.net.ChannelNode;
import com.ts3client.net.HostButton;
import com.ts3client.net.PictureDownloads;
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;
import com.ts3client.ui.tray.TrayBackend;
import javax.swing.AbstractAction;
import javax.swing.JCheckBox;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JComponent;
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.KeyStroke;
import javax.swing.SwingUtilities;
import java.io.File;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.GridLayout;
import java.awt.KeyboardFocusManager;
import java.awt.event.ActionEvent;
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 MyTeamSpeakLogin myTeamSpeak = MyTeamSpeakLogin.load();
/** Friends and blocked clients, shared by every connection. */
private final ContactStore contacts = ContactStore.load();
private final AudioBackend audio = new DesktopAudioBackend();
private final PictureDownloads pictures = new PictureDownloads(new File(AppDirs.profile(), "pictures"), true);
private final TextZoom chatZoom;
private final TextZoom infoZoom;
/** Sound pack playback, shared by every connection. */
private final SoundNotifier sounds;
private final SoundPlayer soundPlayer;
private final List<ServerTab> 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;
private MainMenuBar menuBar;
private javax.swing.Timer statusTimer;
/** Mirrors the active server's own client state next to the clock; set once the window is up. */
private volatile TrayController tray;
private final DesktopNotifier notifier;
private MainToolbar toolbar;
private StatusBar statusBar;
/** 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.chatZoom = new TextZoom(settings.chatZoom, z -> {
settings.chatZoom = z;
settings.save();
});
this.infoZoom = new TextZoom(settings.infoZoom, z -> {
settings.infoZoom = z;
settings.save();
});
this.identities = IdentityStore.load(settings);
new TeamSpeakImporter(identities, bookmarks, settings).seedIfFresh();
this.sounds = new SoundNotifier(settings);
this.soundPlayer = audio.createSoundPlayer(settings);
this.sounds.setPlayer(soundPlayer);
this.notifier = new DesktopNotifier(this, settings);
// Any of the client's windows coming to the front counts, a dialog included.
KeyboardFocusManager.getCurrentKeyboardFocusManager().addPropertyChangeListener("activeWindow", e -> {
if (e.getNewValue() != null) notifier.windowActivated();
});
this.hotkeys = new HotkeyService(new HotkeyActions(this), this::hotkeyArgumentChoices);
hotkeys.addKeyPressListener(() -> SwingUtilities.invokeLater(sessions::keyPressed));
contacts.addListener(() -> SwingUtilities.invokeLater(this::contactsChanged));
setIconImages(Icons.appImages());
// 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();
}
@Override
public void windowOpened(WindowEvent e) {
// Docking waits for the panel to adopt the icon, so it must not hold up the window.
SwingUtilities.invokeLater(MainFrame.this::installTray);
}
});
// Catches Ctrl+C / SIGTERM so we still leave the servers cleanly.
Runtime.getRuntime().addShutdownHook(shutdownHook);
setMinimumSize(UIScale.scale(new Dimension(720, 480)));
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);
// The tree and the chat each claim Ctrl+F for themselves while they have the focus;
// from anywhere else it searches the chat.
getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
.put(KeyStroke.getKeyStroke("control F"), "ts3-find");
getRootPane().getActionMap().put("ts3-find", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
if (selected != null) selected.chat().openFind();
}
});
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(UIScale.scale(new Dimension(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.session().setCommander(commander);
}
@Override
public void onChangeNickname() {
changeNickname();
}
@Override
public boolean canSetAvatar() {
return selected != null && selected.session().canSetAvatar();
}
@Override
public void onSetAvatar() {
if (selected != null) selected.avatar().choose();
}
@Override
public boolean hasAvatar() {
return selected != null && selected.session().hasAvatar();
}
@Override
public void onDeleteAvatar() {
if (selected != null) selected.avatar().delete();
}
@Override
public void onShowIdentities() {
showIdentities();
}
@Override
public void onShowContacts() {
showContacts(null);
}
@Override
public boolean canViewBanList() {
return selected != null && selected.session().canViewBanList();
}
@Override
public void onShowBanList() {
if (selected != null) selected.showBanList(banReasonPresets());
}
@Override
public void onShowSettings() {
showSettings();
}
@Override
public void onShowAbout() {
showAbout();
}
@Override
public void onConnectBookmark(Bookmark bookmark, boolean newTab) {
if (newTab) selectTab(newTab());
connectToBookmark(bookmark);
}
@Override
public void onAddCurrentServerBookmark() {
addCurrentServerBookmark();
}
@Override
public void onManageBookmarks() {
new BookmarksDialog(MainFrame.this, bookmarks, identities, settings,
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.session().setMicMuted(muted);
updateToolbar();
}
@Override
public void onDeafenToggle(boolean deafened) {
if (selected == null) return;
selected.session().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();
}
}, pictures);
}
/** Rebuilds the icon-bearing chrome after the active icon pack changed. */
private void rebuildIcons() {
setIconImages(Icons.appImages());
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, contacts, pictures);
tabs.add(tab);
sessions.add(tab.session());
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.session().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.session().disconnect();
if (tabs.size() == 1) return; // always keep one view around
tabs.remove(tab);
tab.session().dispose();
tabPane.removeTab(tab);
if (selected == tab) {
selected = null;
selectTab(tabs.get(0));
} else {
selectTab(selected); // the pane rebuilt itself; restore the selection
}
sessions.remove(tab.session());
}
/** 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;
}
return null;
}
/** 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 server on {@code tab} may have changed its host button. */
void refreshHostButton(ServerTab tab) {
if (tab == selected) toolbar.showHostButton(hostButtonOf(tab));
}
/** Bookmarks leading to the server on {@code tab} take on its icon. */
void serverIconChanged(ServerTab tab, long iconId) {
if (!bookmarks.rememberServerIcon(tab.session().address(), tab.session().port(), iconId)) return;
bookmarks.save();
menuBar.rebuildBookmarks();
}
/** 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;
sessions.connected(tab.session());
tabUpdated(tab);
}
void tabDisconnected(ServerTab tab) {
if (!tabs.contains(tab)) return;
sessions.disconnected(tab.session());
notifier.forget(tab);
tabUpdated(tab);
}
/** Tells the user of a poke or a private message while the client is in the background. */
TextZoom chatZoom() {
return chatZoom;
}
TextZoom infoZoom() {
return infoZoom;
}
DesktopNotifier notifier() {
return notifier;
}
/** The tray icon when AWT docked it, whose messages are the notifications on Windows and macOS. */
TrayBackend awtTray() {
TrayController current = tray;
return current == null ? null : current.backend();
}
// ---- 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.session().isConnected()) out.add(selected);
return out;
}
for (ServerTab tab : tabs) {
if (tab.session().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) {
sessions.setPushToTalk(talking);
}
void moveMicrophoneToSelectedTab() {
if (selected != null && selected.session().isConnected()) sessions.giveMicrophoneTo(selected.session());
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();
sessions.setNickname(settings.nickname);
}
void browseCurrentChannel() {
if (selected == null || !selected.session().isConnected()) return;
ChannelNode channel = selected.session().currentChannel();
if (channel != null) selected.browseFiles(channel);
}
void reloadSkin() {
IconTheme.get().reload(settings);
}
/** Brings the window back from the tray, the taskbar or behind other windows. */
void bringToFront() {
TrayController current = tray;
if (current != null) {
// The tray can ask the window manager in a way it honours for a window without focus.
current.show();
return;
}
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.session().isConnected()) {
out.addAll(selected.session().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) {
startConnection(address, port, nickname, password, identityId, channel, channelPassword, false);
}
/**
* @param replaceCurrent connect on the current tab, leaving the server it is on, instead of
* moving to a free tab
*/
private void startConnection(String address, int port, String nickname, String password, String identityId,
String channel, String channelPassword, boolean replaceCurrent) {
settings.lastAddress = address + ":" + port;
if (!nickname.isBlank()) settings.nickname = nickname;
settings.serverPassword = password;
settings.save();
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 (sessions.microphoneOwner() == null) sessions.giveMicrophoneTo(tab.session());
tab.session().switchTo(address, port, nickname, password, identityId, channel, channelPassword);
}
/** Connects on the current tab, leaving whatever server it is on. */
private void connectToBookmark(Bookmark b) {
startConnection(b.address, b.port, b.nickname == null ? "" : b.nickname, b.password, b.identityId, b.channel, b.channelPassword, true);
}
private void addCurrentServerBookmark() {
if (selected == null || !selected.session().isConnected()) {
JOptionPane.showMessageDialog(this, "Connect to a server first.",
"Add bookmark", JOptionPane.INFORMATION_MESSAGE);
return;
}
String addr = selected.session().address();
int port = selected.session().port();
String channelPath = selected.session().currentChannelPath();
JTextField labelField = new JTextField(selected.session().title());
JCheckBox joinChannel = new JCheckBox("Join \"" + channelPath + "\" on connect", !channelPath.isEmpty());
JPanel form = new JPanel(new GridLayout(0, 1, 0, UIScale.scale(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.session().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 = sessions.anyConnected();
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(sessions.awayMessage(selected == null ? null : selected.session()));
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.session().setAway(away, "");
updateToolbar();
}
/** Sets the away message on the selected server only. */
private void setAwayStatus() {
if (selected == null) return;
String message = askAwayMessage(selected.session().awayMessage());
if (message == null) return;
selected.session().setAway(true, message);
updateToolbar();
}
private void setAwayEverywhere(boolean away, String message) {
sessions.setAwayEverywhere(away, message);
updateToolbar();
}
/** @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.session().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();
settings.save(); // keeps the layout the user left behind (info panel height, …)
try {
Runtime.getRuntime().removeShutdownHook(shutdownHook);
} catch (IllegalStateException ignored) {
// Already shutting down (hook itself is running); nothing to remove.
}
sessions.shutdown();
contacts.flush();
soundPlayer.shutdown();
hotkeys.dispose();
if (tray != null) tray.dispose();
notifier.shutdown();
}
/** Leaves every server and ends the process. */
private void quit() {
shutdown();
System.exit(0);
}
/** Loaded the first time a ban dialog needs them, and shared by every tab from then on. */
private BanReasonPresets banReasonPresets() {
if (banReasonPresets == null) banReasonPresets = BanReasonPresets.load();
return banReasonPresets;
}
private void showIdentities() {
new IdentitiesDialog(this, identities, settings, bookmarks, null).setVisible(true);
}
Settings settings() {
return settings;
}
/** The one contacts window, kept so a second request brings it forward instead of doubling it. */
private ContactsDialog contactsDialog;
/**
* Opens the contact list, or brings it forward.
*
* @param uniqueId the contact to select, or {@code null} to keep the current selection
*/
void showContacts(String uniqueId) {
if (contactsDialog == null || !contactsDialog.isDisplayable()) {
contactsDialog = new ContactsDialog(this, contacts, settings);
}
if (uniqueId != null) contactsDialog.select(uniqueId);
contactsDialog.setVisible(true);
contactsDialog.toFront();
}
/** The contact list changed somewhere: every open server follows. */
private void contactsChanged() {
for (ServerTab tab : tabs) tab.contactsChanged();
}
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, audio,
sessions.microphoneOwner() == null ? null : sessions.microphoneOwner().connection().getMicrophone(),
selected == null ? null : selected.connection().getPlayback(),
sounds, hotkeys,
new MyTeamSpeakPanel(myTeamSpeak, new TeamSpeakImporter(identities, bookmarks, settings),
menuBar::rebuildBookmarks),
() -> {
applyOutputSettingsToAllTabs();
contactsChanged();
});
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.session().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.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 away = connected && selected.session().isAway();
boolean commander = connected && selected.session().isCommander();
toolbar.refresh(connected, anyConnected, micMuted, deaf, active, away);
toolbar.showHostButton(hostButtonOf(selected));
menuBar.refresh(connected, away, commander);
updateTray();
}
private static HostButton hostButtonOf(ServerTab tab) {
return tab != null && tab.session().isConnected() ? tab.connection().getModel().hostButton() : null;
}
/**
* The server the tray icon speaks for: the one holding the microphone, or the
* 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;
}
private void installTray() {
if (shuttingDown.get()) return;
tray = new TrayController(this, this::quit);
updateTray();
}
private void updateTray() {
if (tray == null) return;
ServerTab tab = trayTab();
if (tab == null) {
tray.update(SelfState.DISCONNECTED, null);
} else {
tray.update(tab.session().selfState(), tab.session().title());
}
}
private void updateStatusLabel() {
statusBar.setStatus(selected == null ? "Not connected" : selected.session().status());
}
private void updateConnectionStatus() {
ServerTab tab = selected;
if (tab == null || !tab.session().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());
}
}