Add Master Volume slider, status bar toggle, and toolbar customization menu

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>
This commit is contained in:
2026-08-18 13:37:36 +00:00
parent e5fb87a796
commit cf5ba94092
6 changed files with 601 additions and 200 deletions

View File

@@ -95,6 +95,12 @@ public final class Settings {
public boolean music = false;
/** Master playback gain, 0..1 (may exceed 1 for boost up to 2). */
public double outputVolume = 1.0;
/**
* Global multiplier on top of {@link #outputVolume} and {@link #soundVolume},
* controlled from the toolbar's Master Volume slider so both can be ridden
* with one control. 0..2, same boost headroom as the other volumes.
*/
public double masterVolume = 1.0;
/** Microphone input gain multiplier applied before VAD/encode. */
public double inputVolume = 1.0;
/** Remove steady background noise from the microphone (spectral denoise). */
@@ -119,6 +125,12 @@ public final class Settings {
/** Extra folder to look for icon packs in, on top of the well-known locations. */
public String iconPackDir = "";
// ---- window chrome ----
/** Whether the status bar at the bottom of the window is shown. */
public boolean showStatusBar = true;
/** Whether the Master Volume slider is shown on the toolbar. */
public boolean showMasterVolumeSlider = true;
/** Which actions make a sound, and which ones are important enough to survive muting. */
public final NotificationSettings notifications = new NotificationSettings();
@@ -155,6 +167,16 @@ public final class Settings {
return new File(identityFile);
}
/** {@link #outputVolume} scaled by the Master Volume slider. */
public double effectiveOutputVolume() {
return outputVolume * masterVolume;
}
/** {@link #soundVolume} scaled by the Master Volume slider. */
public double effectiveSoundVolume() {
return soundVolume * masterVolume;
}
/** The directory holding all persistent client state (settings, identities, caches). */
public static File configDir() {
return DIR;
@@ -185,6 +207,7 @@ public final class Settings {
packetLoss = parseI(props.getProperty("packetLoss"), packetLoss);
music = parseB(props.getProperty("music"), music);
outputVolume = parseD(props.getProperty("outputVolume"), outputVolume);
masterVolume = parseD(props.getProperty("masterVolume"), masterVolume);
inputVolume = parseD(props.getProperty("inputVolume"), inputVolume);
denoise = parseB(props.getProperty("denoise"), denoise);
denoiserLevel = parseD(props.getProperty("denoiserLevel"), denoiserLevel);
@@ -195,6 +218,8 @@ public final class Settings {
soundPackDir = props.getProperty("soundPackDir", soundPackDir);
iconPack = props.getProperty("iconPack", iconPack);
iconPackDir = props.getProperty("iconPackDir", iconPackDir);
showStatusBar = parseB(props.getProperty("showStatusBar"), showStatusBar);
showMasterVolumeSlider = parseB(props.getProperty("showMasterVolumeSlider"), showMasterVolumeSlider);
notifications.load(props);
}
@@ -223,6 +248,7 @@ public final class Settings {
props.setProperty("packetLoss", Integer.toString(packetLoss));
props.setProperty("music", Boolean.toString(music));
props.setProperty("outputVolume", Double.toString(outputVolume));
props.setProperty("masterVolume", Double.toString(masterVolume));
props.setProperty("inputVolume", Double.toString(inputVolume));
props.setProperty("denoise", Boolean.toString(denoise));
props.setProperty("denoiserLevel", Double.toString(denoiserLevel));
@@ -233,6 +259,8 @@ public final class Settings {
props.setProperty("soundPackDir", soundPackDir);
props.setProperty("iconPack", iconPack);
props.setProperty("iconPackDir", iconPackDir);
props.setProperty("showStatusBar", Boolean.toString(showStatusBar));
props.setProperty("showMasterVolumeSlider", Boolean.toString(showMasterVolumeSlider));
notifications.store(props);
}

View File

@@ -113,7 +113,7 @@ public final class SoundNotifier {
if (script == null) return;
String resolved = script.resolve(withDefaults(variables));
double volume = Math.max(0, Math.min(1.0, settings.soundVolume));
double volume = Math.max(0, Math.min(1.0, settings.effectiveSoundVolume()));
if (volume <= 0) return;
if (script.kind() == SoundScript.Kind.SAY) {

View File

@@ -12,22 +12,15 @@ import com.ts3client.sound.SoundNotifier;
import com.ts3client.sound.SoundPlayer;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JTextField;
import javax.swing.JToggleButton;
import javax.swing.JToolBar;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
import java.awt.BorderLayout;
import java.awt.Dimension;
@@ -65,25 +58,14 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
/** The tab that owns the capture device; null when nobody is capturing. */
private ServerTab micTab;
private JMenu bookmarksMenu;
private JCheckBoxMenuItem awayItem;
private JMenuItem awayStatusItem;
private JCheckBoxMenuItem commanderItem;
private MainMenuBar menuBar;
private javax.swing.Timer statusTimer;
private final JLabel statusLabel = new JLabel("Not connected");
private final JLabel codecLabel = new JLabel();
/** Mirrors the active server's own client state next to the clock. */
private TrayController tray;
private JToolBar toolbar;
private JButton connectButton;
private JButton disconnectButton;
private JToggleButton activeButton;
private JToggleButton micButton;
private JToggleButton speakerButton;
private DropDownToggleButton awayButton;
private MainToolbar toolbar;
private StatusBar statusBar;
private boolean pttPressed;
@@ -117,16 +99,19 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
setMinimumSize(new Dimension(720, 480));
tray = new TrayController(this, this::quit);
setJMenuBar(buildMenuBar());
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);
add(buildStatusBar(), BorderLayout.SOUTH);
statusBar = new StatusBar();
statusBar.setVisible(settings.showStatusBar);
add(statusBar, BorderLayout.SOUTH);
codecLabel.setText(audio.description());
statusBar.setCodec(audio.description());
ServerTab first = newTab();
selectTab(first);
@@ -149,160 +134,156 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
// ---- UI construction ----
private JMenuBar buildMenuBar() {
JMenuBar bar = new JMenuBar();
private MainMenuBar buildMenuBar() {
return new MainMenuBar(bookmarks, new MainMenuBar.Listener() {
@Override
public void onConnect() {
showConnectDialog();
}
JMenu connections = new JMenu("Connections");
JMenuItem connect = new JMenuItem("Connect…", Icons.of("CONNECT"));
connect.setAccelerator(KeyStroke.getKeyStroke("control S"));
connect.addActionListener(e -> showConnectDialog());
JMenuItem disconnect = new JMenuItem("Disconnect", Icons.of("DISCONNECT"));
disconnect.addActionListener(e -> doDisconnect());
JMenuItem closeTab = new JMenuItem("Close tab", Icons.of("CLOSE_BUTTON"));
closeTab.setAccelerator(KeyStroke.getKeyStroke("control W"));
closeTab.addActionListener(e -> closeTab(selected));
JMenuItem quit = new JMenuItem("Quit", Icons.of("QUIT"));
quit.addActionListener(e -> quit());
connections.add(connect);
connections.add(disconnect);
connections.add(closeTab);
connections.addSeparator();
connections.add(quit);
@Override
public void onDisconnect() {
doDisconnect();
}
bookmarksMenu = new JMenu("Bookmarks");
rebuildBookmarksMenu();
@Override
public void onCloseTab() {
closeTab(selected);
}
JMenu self = new JMenu("Self");
JMenuItem mute = new JMenuItem("Toggle microphone", Icons.of("CAPTURE"));
mute.addActionListener(e -> micButton.doClick());
JMenuItem deaf = new JMenuItem("Toggle speakers", Icons.of("PLAYBACK"));
deaf.addActionListener(e -> speakerButton.doClick());
awayItem = new JCheckBoxMenuItem("Away", Icons.of("AWAY"), false);
awayItem.addActionListener(e -> toggleAway());
awayStatusItem = new JMenuItem("Set away status…", Icons.of("EDIT"));
awayStatusItem.addActionListener(e -> setAwayStatus());
commanderItem = new JCheckBoxMenuItem("Channel commander", Icons.of("CHANNEL_COMMANDER"), false);
commanderItem.addActionListener(e -> {
if (selected != null) selected.setCommander(commanderItem.isSelected());
@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);
}
});
JMenuItem nick = new JMenuItem("Change nickname…", Icons.of("CHANGE_NICKNAME"));
nick.addActionListener(e -> changeNickname());
self.add(mute);
self.add(deaf);
self.addSeparator();
self.add(awayItem);
self.add(awayStatusItem);
self.add(commanderItem);
self.addSeparator();
self.add(nick);
JMenu tools = new JMenu("Tools");
JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER"));
identitiesItem.addActionListener(e -> showIdentities());
JMenuItem options = new JMenuItem("Options…", Icons.of("SETTINGS"));
options.addActionListener(e -> showSettings());
tools.add(identitiesItem);
tools.addSeparator();
tools.add(options);
JMenu help = new JMenu("Help");
JMenuItem about = new JMenuItem("About", Icons.of("ABOUT"));
about.addActionListener(e -> showAbout());
help.add(about);
bar.add(connections);
bar.add(bookmarksMenu);
bar.add(self);
bar.add(tools);
bar.add(help);
return bar;
}
private void rebuildBookmarksMenu() {
bookmarksMenu.removeAll();
for (Bookmark b : bookmarks.all()) {
JMenuItem item = new JMenuItem(b.displayName(), Icons.of("SERVER_GREEN"));
item.addActionListener(e -> connectToBookmark(b));
bookmarksMenu.add(item);
}
if (!bookmarks.all().isEmpty()) bookmarksMenu.addSeparator();
JMenuItem addCurrent = new JMenuItem("Add current server…", Icons.of("BOOKMARK_ADD"));
addCurrent.addActionListener(e -> addCurrentServerBookmark());
JMenuItem manage = new JMenuItem("Manage bookmarks…", Icons.of("BOOKMARK_MANAGER"));
manage.addActionListener(e -> new BookmarksDialog(this, bookmarks, identities,
this::connectToBookmark, this::rebuildBookmarksMenu).setVisible(true));
bookmarksMenu.add(addCurrent);
bookmarksMenu.add(manage);
}
private MainToolbar buildToolbar() {
return new MainToolbar(settings, new MainToolbar.Listener() {
@Override
public void onConnect() {
showConnectDialog();
}
private JToolBar buildToolbar() {
JToolBar tb = new JToolBar();
tb.setFloatable(false);
tb.setBackground(Theme.TOOLBAR_BG);
tb.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
@Override
public void onDisconnect() {
doDisconnect();
}
connectButton = new JButton(Icons.connect());
connectButton.setToolTipText("Connect to a server");
connectButton.addActionListener(e -> showConnectDialog());
@Override
public void onActivate() {
moveMicrophoneToSelectedTab();
}
disconnectButton = new JButton(Icons.disconnect());
disconnectButton.setToolTipText("Disconnect");
disconnectButton.addActionListener(e -> doDisconnect());
@Override
public void onMicMuteToggle(boolean muted) {
if (selected == null) return;
selected.setMicMuted(muted);
updateToolbar();
}
activeButton = new JToggleButton(Icons.micActive());
activeButton.setToolTipText("Speak on this server (moves the microphone to this tab)");
activeButton.addActionListener(e -> {
if (selected != null && selected.isConnected()) setMicTab(selected);
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();
}
});
micButton = new JToggleButton(Icons.mic());
micButton.setToolTipText("Mute / unmute microphone on this server");
micButton.addActionListener(e -> {
if (selected == null) return;
selected.setMicMuted(micButton.isSelected());
updateToolbar();
});
speakerButton = new JToggleButton(Icons.speaker());
speakerButton.setToolTipText("Deafen / undeafen (mute speakers) on this server");
speakerButton.addActionListener(e -> {
if (selected == null) return;
selected.setDeafened(speakerButton.isSelected());
updateToolbar();
});
awayButton = new DropDownToggleButton(Icons.away(),
"Away on this server (the arrow offers the global actions and presets)",
this::buildAwayMenu);
awayButton.addActionListener(e -> {
if (selected == null) return;
// The plain toggle carries no message; the menu is where messages are chosen.
selected.setAway(awayButton.isSelected(), "");
updateToolbar();
});
JButton settingsButton = new JButton(Icons.settings());
settingsButton.setToolTipText("Options");
settingsButton.addActionListener(e -> showSettings());
tb.add(connectButton);
tb.add(disconnectButton);
tb.addSeparator();
tb.add(activeButton);
tb.add(micButton);
tb.add(speakerButton);
tb.add(awayButton);
tb.addSeparator();
tb.add(settingsButton);
tb.add(Box.createHorizontalGlue());
return tb;
}
/** Rebuilds the icon-bearing chrome after the active icon pack changed. */
private void rebuildIcons() {
setIconImage(Icons.app().getImage());
setJMenuBar(buildMenuBar());
menuBar = buildMenuBar();
setJMenuBar(menuBar);
remove(toolbar);
toolbar = buildToolbar();
add(toolbar, BorderLayout.NORTH);
@@ -313,18 +294,6 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
repaint();
}
private JPanel buildStatusBar() {
JPanel bar = new JPanel(new BorderLayout());
bar.setBackground(Theme.STATUS_BG);
bar.setBorder(BorderFactory.createEmptyBorder(3, 8, 3, 8));
statusLabel.setFont(Theme.UI_FONT);
codecLabel.setFont(Theme.UI_FONT);
codecLabel.setForeground(Theme.CHAT_SYSTEM);
bar.add(statusLabel, BorderLayout.WEST);
bar.add(codecLabel, BorderLayout.EAST);
return bar;
}
// ---- tab management ----
private ServerTab newTab() {
@@ -645,7 +614,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
if (joinChannel.isSelected()) bookmark.channel = channelPath;
bookmarks.add(bookmark);
bookmarks.save();
rebuildBookmarksMenu();
menuBar.rebuildBookmarks();
}
/** The away button's drop-down: the global actions, the presets and their editor. */
@@ -682,9 +651,9 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
return menu;
}
private void toggleAway() {
private void toggleAway(boolean away) {
if (selected == null) return;
selected.setAway(awayItem.isSelected(), "");
selected.setAway(away, "");
updateToolbar();
}
@@ -782,7 +751,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
soundPlayer.setOutputDevice(settings.outputDevice);
for (ServerTab tab : tabs) {
if (tab.connection().getPlayback() == null) continue;
tab.connection().getPlayback().setMasterVolume(settings.outputVolume);
tab.connection().getPlayback().setMasterVolume(settings.effectiveOutputVolume());
tab.connection().getPlayback().setOutputDevice(settings.outputDevice);
}
}
@@ -802,7 +771,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
"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" +
codecLabel.getText(),
statusBar.codecText(),
"About TS3J", JOptionPane.INFORMATION_MESSAGE);
}
@@ -810,28 +779,15 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private void updateToolbar() {
boolean connected = selected != null && selected.isConnected();
disconnectButton.setEnabled(connected);
micButton.setEnabled(connected);
speakerButton.setEnabled(connected);
activeButton.setEnabled(connected);
awayItem.setEnabled(connected);
awayStatusItem.setEnabled(connected);
// The menu's actions are global, so the arrow stays live while any server is up.
awayButton.setEnabled(tabs.stream().anyMatch(ServerTab::isConnected));
awayButton.setToggleEnabled(connected);
commanderItem.setEnabled(connected);
boolean anyConnected = tabs.stream().anyMatch(ServerTab::isConnected);
boolean micMuted = connected && selected.isMicMuted();
boolean deaf = connected && selected.isDeafened();
micButton.setSelected(micMuted);
micButton.setIcon(micMuted ? Icons.micMutedLarge() : Icons.mic());
speakerButton.setSelected(deaf);
speakerButton.setIcon(deaf ? Icons.speakerMutedLarge() : Icons.speaker());
activeButton.setSelected(selected != null && selected == micTab);
boolean active = selected != null && selected == micTab;
boolean away = connected && selected.isAway();
awayItem.setSelected(away);
awayButton.setSelected(away);
commanderItem.setSelected(connected && selected.isCommander());
boolean commander = connected && selected.isCommander();
toolbar.refresh(connected, anyConnected, micMuted, deaf, active, away);
menuBar.refresh(connected, away, commander);
updateTray();
}
@@ -855,7 +811,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
}
private void updateStatusLabel() {
statusLabel.setText(selected == null ? "Not connected" : selected.status());
statusBar.setStatus(selected == null ? "Not connected" : selected.status());
}
private void updateConnectionStatus() {
@@ -868,6 +824,6 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
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");
statusLabel.setText(s.toString());
statusBar.setStatus(s.toString());
}
}

View File

@@ -0,0 +1,155 @@
package com.ts3client.ui;
import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
import javax.swing.KeyStroke;
/**
* The main window's menu bar: Connections, Bookmarks, Self, Tools and Help.
* Talks to {@link MainFrame} only through {@link Listener}; the away/commander
* item states are pushed in by {@link #refresh}, mirroring how {@link MainToolbar}
* is kept in sync.
*/
final class MainMenuBar extends JMenuBar {
interface Listener {
void onConnect();
void onDisconnect();
void onCloseTab();
void onQuit();
void onToggleMic();
void onToggleSpeaker();
/** The plain toggle carries no message; "Set away status…" is where messages are chosen. */
void onAwayToggle(boolean away);
void onAwayStatus();
void onCommanderToggle(boolean commander);
void onChangeNickname();
void onShowIdentities();
void onShowSettings();
void onShowAbout();
void onConnectBookmark(Bookmark bookmark);
void onAddCurrentServerBookmark();
void onManageBookmarks();
}
private final Bookmarks bookmarks;
private final Listener listener;
private JMenu bookmarksMenu;
private JCheckBoxMenuItem awayItem;
private JMenuItem awayStatusItem;
private JCheckBoxMenuItem commanderItem;
MainMenuBar(Bookmarks bookmarks, Listener listener) {
this.bookmarks = bookmarks;
this.listener = listener;
JMenu connections = new JMenu("Connections");
JMenuItem connect = new JMenuItem("Connect…", Icons.of("CONNECT"));
connect.setAccelerator(KeyStroke.getKeyStroke("control S"));
connect.addActionListener(e -> listener.onConnect());
JMenuItem disconnect = new JMenuItem("Disconnect", Icons.of("DISCONNECT"));
disconnect.addActionListener(e -> listener.onDisconnect());
JMenuItem closeTab = new JMenuItem("Close tab", Icons.of("CLOSE_BUTTON"));
closeTab.setAccelerator(KeyStroke.getKeyStroke("control W"));
closeTab.addActionListener(e -> listener.onCloseTab());
JMenuItem quit = new JMenuItem("Quit", Icons.of("QUIT"));
quit.addActionListener(e -> listener.onQuit());
connections.add(connect);
connections.add(disconnect);
connections.add(closeTab);
connections.addSeparator();
connections.add(quit);
bookmarksMenu = new JMenu("Bookmarks");
rebuildBookmarks();
JMenu self = new JMenu("Self");
JMenuItem mute = new JMenuItem("Toggle microphone", Icons.of("CAPTURE"));
mute.addActionListener(e -> listener.onToggleMic());
JMenuItem deaf = new JMenuItem("Toggle speakers", Icons.of("PLAYBACK"));
deaf.addActionListener(e -> listener.onToggleSpeaker());
awayItem = new JCheckBoxMenuItem("Away", Icons.of("AWAY"), false);
awayItem.addActionListener(e -> listener.onAwayToggle(awayItem.isSelected()));
awayStatusItem = new JMenuItem("Set away status…", Icons.of("EDIT"));
awayStatusItem.addActionListener(e -> listener.onAwayStatus());
commanderItem = new JCheckBoxMenuItem("Channel commander", Icons.of("CHANNEL_COMMANDER"), false);
commanderItem.addActionListener(e -> listener.onCommanderToggle(commanderItem.isSelected()));
JMenuItem nick = new JMenuItem("Change nickname…", Icons.of("CHANGE_NICKNAME"));
nick.addActionListener(e -> listener.onChangeNickname());
self.add(mute);
self.add(deaf);
self.addSeparator();
self.add(awayItem);
self.add(awayStatusItem);
self.add(commanderItem);
self.addSeparator();
self.add(nick);
JMenu tools = new JMenu("Tools");
JMenuItem identitiesItem = new JMenuItem("Identities…", Icons.of("IDENTITY_MANAGER"));
identitiesItem.addActionListener(e -> listener.onShowIdentities());
JMenuItem options = new JMenuItem("Options…", Icons.of("SETTINGS"));
options.addActionListener(e -> listener.onShowSettings());
tools.add(identitiesItem);
tools.addSeparator();
tools.add(options);
JMenu help = new JMenu("Help");
JMenuItem about = new JMenuItem("About", Icons.of("ABOUT"));
about.addActionListener(e -> listener.onShowAbout());
help.add(about);
add(connections);
add(bookmarksMenu);
add(self);
add(tools);
add(help);
}
/** Reflects the current tab's away/commander state on the menu items. */
void refresh(boolean connected, boolean away, boolean commander) {
awayItem.setEnabled(connected);
awayStatusItem.setEnabled(connected);
commanderItem.setEnabled(connected);
awayItem.setSelected(away);
commanderItem.setSelected(commander);
}
/** Re-lists the saved bookmarks; called after they change (add, remove, manage…). */
void rebuildBookmarks() {
bookmarksMenu.removeAll();
for (Bookmark b : bookmarks.all()) {
JMenuItem item = new JMenuItem(b.displayName(), Icons.of("SERVER_GREEN"));
item.addActionListener(e -> listener.onConnectBookmark(b));
bookmarksMenu.add(item);
}
if (!bookmarks.all().isEmpty()) bookmarksMenu.addSeparator();
JMenuItem addCurrent = new JMenuItem("Add current server…", Icons.of("BOOKMARK_ADD"));
addCurrent.addActionListener(e -> listener.onAddCurrentServerBookmark());
JMenuItem manage = new JMenuItem("Manage bookmarks…", Icons.of("BOOKMARK_MANAGER"));
manage.addActionListener(e -> listener.onManageBookmarks());
bookmarksMenu.add(addCurrent);
bookmarksMenu.add(manage);
}
}

View File

@@ -0,0 +1,226 @@
package com.ts3client.ui;
import com.ts3client.config.Settings;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.JButton;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JSlider;
import javax.swing.JToggleButton;
import javax.swing.JToolBar;
import javax.swing.event.PopupMenuEvent;
import javax.swing.event.PopupMenuListener;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.FlowLayout;
/**
* The main window's toolbar: connect/disconnect, the per-tab mic/speaker/away
* controls, and the Master Volume slider on the right. A right-click anywhere on
* the bar opens a customization menu for the window's optional chrome (this
* toolbar has no say over the status bar's own visibility beyond reporting the
* toggle).
*
* <p>Talks to {@link MainFrame} only through {@link Listener}, so it owns no
* connection/tab state itself &mdash; {@link #refresh} is handed everything it
* needs to redraw each time the selection changes.
*/
final class MainToolbar extends JToolBar {
interface Listener {
void onConnect();
void onDisconnect();
/** Moves the microphone to the tab on screen. */
void onActivate();
void onMicMuteToggle(boolean muted);
void onDeafenToggle(boolean deafened);
/** The plain toggle carries no message; the drop-down arrow is where messages are chosen. */
void onAwayToggle(boolean away);
JPopupMenu buildAwayMenu();
void onSettings();
/** The slider changed; push the new master volume to every open connection. */
void onMasterVolumeChanged();
void onStatusBarVisibilityChanged(boolean visible);
}
private final Settings settings;
private final Listener listener;
private final JButton connectButton;
private final JButton disconnectButton;
private final JToggleButton activeButton;
private final JToggleButton micButton;
private final JToggleButton speakerButton;
private final DropDownToggleButton awayButton;
private final JComponent masterVolumePanel;
MainToolbar(Settings settings, Listener listener) {
this.settings = settings;
this.listener = listener;
setFloatable(false);
setBackground(Theme.TOOLBAR_BG);
setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
setComponentPopupMenu(buildContextMenu());
connectButton = new JButton(Icons.connect());
connectButton.setToolTipText("Connect to a server");
connectButton.addActionListener(e -> listener.onConnect());
disconnectButton = new JButton(Icons.disconnect());
disconnectButton.setToolTipText("Disconnect");
disconnectButton.addActionListener(e -> listener.onDisconnect());
activeButton = new JToggleButton(Icons.micActive());
activeButton.setToolTipText("Speak on this server (moves the microphone to this tab)");
activeButton.addActionListener(e -> listener.onActivate());
micButton = new JToggleButton(Icons.mic());
micButton.setToolTipText("Mute / unmute microphone on this server");
micButton.addActionListener(e -> listener.onMicMuteToggle(micButton.isSelected()));
speakerButton = new JToggleButton(Icons.speaker());
speakerButton.setToolTipText("Deafen / undeafen (mute speakers) on this server");
speakerButton.addActionListener(e -> listener.onDeafenToggle(speakerButton.isSelected()));
awayButton = new DropDownToggleButton(Icons.away(),
"Away on this server (the arrow offers the global actions and presets)",
listener::buildAwayMenu);
awayButton.addActionListener(e -> listener.onAwayToggle(awayButton.isSelected()));
JButton settingsButton = new JButton(Icons.settings());
settingsButton.setToolTipText("Options");
settingsButton.addActionListener(e -> listener.onSettings());
add(connectButton);
add(disconnectButton);
addSeparator();
add(activeButton);
add(micButton);
add(speakerButton);
add(awayButton);
addSeparator();
add(settingsButton);
add(Box.createHorizontalGlue());
masterVolumePanel = buildMasterVolumeControl();
masterVolumePanel.setVisible(settings.showMasterVolumeSlider);
add(masterVolumePanel);
}
/** Reflects the current tab's state on the buttons. */
void refresh(boolean connected, boolean anyConnected, boolean micMuted, boolean deafened,
boolean active, boolean away) {
disconnectButton.setEnabled(connected);
micButton.setEnabled(connected);
speakerButton.setEnabled(connected);
activeButton.setEnabled(connected);
// The away menu's actions are global, so the arrow stays live while any server is up.
awayButton.setEnabled(anyConnected);
awayButton.setToggleEnabled(connected);
micButton.setSelected(micMuted);
micButton.setIcon(micMuted ? Icons.micMutedLarge() : Icons.mic());
speakerButton.setSelected(deafened);
speakerButton.setIcon(deafened ? Icons.speakerMutedLarge() : Icons.speaker());
activeButton.setSelected(active);
awayButton.setSelected(away);
}
/** Simulates a click, for the "Toggle microphone" menu item. */
void clickMic() {
micButton.doClick();
}
/** Simulates a click, for the "Toggle speakers" menu item. */
void clickSpeaker() {
speakerButton.doClick();
}
/** The Master Volume slider: multiplies both voice and notification volume. */
private JComponent buildMasterVolumeControl() {
JPanel panel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0));
panel.setOpaque(false);
panel.setAlignmentY(Component.CENTER_ALIGNMENT);
JLabel icon = new JLabel(Icons.speaker());
icon.setToolTipText("Master volume (voice + notifications)");
icon.setAlignmentY(Component.CENTER_ALIGNMENT);
JSlider slider = new JSlider(0, 200, (int) Math.round(settings.masterVolume * 100));
slider.setOpaque(false);
slider.setToolTipText("Master volume (voice + notifications) — scroll to adjust");
slider.setAlignmentY(Component.CENTER_ALIGNMENT);
slider.setPreferredSize(new Dimension(135, slider.getPreferredSize().height));
slider.addChangeListener(e -> {
settings.masterVolume = slider.getValue() / 100.0;
listener.onMasterVolumeChanged();
// Only touch the disk once the drag (or a wheel step) has settled.
if (!slider.getValueIsAdjusting()) settings.save();
});
slider.addMouseWheelListener(e -> {
int step = e.getWheelRotation() < 0 ? 5 : -5;
slider.setValue(slider.getValue() + step);
e.consume();
});
panel.add(icon);
panel.add(slider);
return panel;
}
/** Right-click on the toolbar: toggles for the window's optional chrome. */
private JPopupMenu buildContextMenu() {
JPopupMenu menu = new JPopupMenu();
JCheckBoxMenuItem statusBarItem = new JCheckBoxMenuItem("Show Status Bar", settings.showStatusBar);
statusBarItem.addActionListener(e -> {
settings.showStatusBar = statusBarItem.isSelected();
settings.save();
listener.onStatusBarVisibilityChanged(settings.showStatusBar);
});
JCheckBoxMenuItem volumeItem = new JCheckBoxMenuItem("Show Master Volume Slider", settings.showMasterVolumeSlider);
volumeItem.addActionListener(e -> {
settings.showMasterVolumeSlider = volumeItem.isSelected();
settings.save();
masterVolumePanel.setVisible(settings.showMasterVolumeSlider);
revalidate();
repaint();
});
menu.add(statusBarItem);
menu.add(volumeItem);
// Either toggle can also be flipped elsewhere (or via settings.properties), so
// re-sync the ticks each time the menu is about to be shown.
menu.addPopupMenuListener(new PopupMenuListener() {
@Override
public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
statusBarItem.setSelected(settings.showStatusBar);
volumeItem.setSelected(settings.showMasterVolumeSlider);
}
@Override
public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
}
@Override
public void popupMenuCanceled(PopupMenuEvent e) {
}
});
return menu;
}
}

View File

@@ -0,0 +1,36 @@
package com.ts3client.ui;
import javax.swing.BorderFactory;
import javax.swing.JLabel;
import javax.swing.JPanel;
import java.awt.BorderLayout;
/** The strip at the bottom of the window: connection status on the left, codec info on the right. */
final class StatusBar extends JPanel {
private final JLabel statusLabel = new JLabel("Not connected");
private final JLabel codecLabel = new JLabel();
StatusBar() {
super(new BorderLayout());
setBackground(Theme.STATUS_BG);
setBorder(BorderFactory.createEmptyBorder(3, 8, 3, 8));
statusLabel.setFont(Theme.UI_FONT);
codecLabel.setFont(Theme.UI_FONT);
codecLabel.setForeground(Theme.CHAT_SYSTEM);
add(statusLabel, BorderLayout.WEST);
add(codecLabel, BorderLayout.EAST);
}
void setStatus(String text) {
statusLabel.setText(text);
}
void setCodec(String text) {
codecLabel.setText(text);
}
String codecText() {
return codecLabel.getText();
}
}