A sidebar of pages with large icons replaces the tabs, and each page opens under a banner with its title, subtitle and icon. OK, Cancel and Apply sit below, and the dialog reopens on the last page used. The pages follow TS3's: Application (muted-microphone warning, reported client version), myTeamSpeak, Playback, Capture, Design, Hotkeys, Contacts, Chat and Notifications. Capture now holds everything from the microphone to the server: the device and gain, activation with the microphone test, signal processing and the encoder, laid out in one grid so its columns line up. Forms are grouped under section headings. Cancel now goes back to what was last applied, and closing the window cancels too, so a theme or icon pack being tried out is put back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
413 lines
16 KiB
Java
413 lines
16 KiB
Java
package com.ts3client.ui;
|
|
|
|
import com.formdev.flatlaf.util.ScaledEmptyBorder;
|
|
import com.formdev.flatlaf.util.UIScale;
|
|
import com.ts3client.config.Settings;
|
|
import com.ts3client.sound.NotificationSettings;
|
|
import com.ts3client.sound.SoundEvent;
|
|
import com.ts3client.sound.SoundNotifier;
|
|
import com.ts3client.sound.SoundPack;
|
|
|
|
import javax.swing.JButton;
|
|
import javax.swing.JCheckBox;
|
|
import javax.swing.JComboBox;
|
|
import javax.swing.JComponent;
|
|
import javax.swing.JFileChooser;
|
|
import javax.swing.JLabel;
|
|
import javax.swing.JMenuItem;
|
|
import javax.swing.JPanel;
|
|
import javax.swing.JPopupMenu;
|
|
import javax.swing.JScrollPane;
|
|
import javax.swing.JSlider;
|
|
import javax.swing.JTable;
|
|
import javax.swing.JTextField;
|
|
import javax.swing.ListSelectionModel;
|
|
import javax.swing.table.AbstractTableModel;
|
|
import javax.swing.table.DefaultTableCellRenderer;
|
|
import java.awt.BorderLayout;
|
|
import java.awt.Component;
|
|
import java.awt.Dimension;
|
|
import java.awt.Font;
|
|
import java.awt.GridBagConstraints;
|
|
import java.awt.GridBagLayout;
|
|
import java.awt.Insets;
|
|
import java.awt.event.MouseAdapter;
|
|
import java.awt.event.MouseEvent;
|
|
import java.io.File;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
|
|
/**
|
|
* Options page for sound packs: which pack is active, how loud it is, and what
|
|
* each action does.
|
|
*
|
|
* <p>An action can be switched off entirely, or marked <em>important</em> (shown in
|
|
* bold) — important actions are the only ones still played while the speakers are
|
|
* muted, which is how the official client behaves. Actions the active pack has no
|
|
* sound for are greyed out.
|
|
*/
|
|
final class NotificationsPanel extends JPanel {
|
|
|
|
private static final int TOGGLE_COLUMN_WIDTH = 34;
|
|
|
|
/** A table row: either a category heading or one action. */
|
|
private static final class Row {
|
|
final SoundEvent.Category heading;
|
|
final SoundEvent event;
|
|
|
|
Row(SoundEvent.Category heading, SoundEvent event) {
|
|
this.heading = heading;
|
|
this.event = event;
|
|
}
|
|
|
|
boolean isHeading() {
|
|
return event == null;
|
|
}
|
|
}
|
|
|
|
private final Settings settings;
|
|
private final SoundNotifier sounds;
|
|
/** Edited copy, applied to the settings only when the dialog is confirmed. */
|
|
private final NotificationSettings working;
|
|
private final List<Row> rows = new ArrayList<>();
|
|
/** What {@link #revert()} goes back to: as the dialog opened, or as last applied. */
|
|
private String originalPackId;
|
|
private String originalPackDir;
|
|
|
|
private final JComboBox<SoundPack> packCombo = new JComboBox<>();
|
|
private final JLabel packInfo = new JLabel();
|
|
private final JSlider volume;
|
|
private final JTextField packDirField;
|
|
private final JCheckBox desktopNotifications =
|
|
new JCheckBox("Show pokes and private messages as desktop notifications while in the background");
|
|
private final JTable table;
|
|
private final EventTableModel tableModel = new EventTableModel();
|
|
|
|
NotificationsPanel(Settings settings, SoundNotifier sounds) {
|
|
super(new BorderLayout(0, UIScale.scale(8)));
|
|
this.settings = settings;
|
|
this.sounds = sounds;
|
|
this.working = settings.notifications.copy();
|
|
this.originalPackId = settings.soundPack;
|
|
this.originalPackDir = settings.soundPackDir;
|
|
this.volume = new JSlider(0, 100, (int) Math.round(settings.soundVolume * 100));
|
|
this.packDirField = new JTextField(settings.soundPackDir, 18);
|
|
|
|
buildRows();
|
|
this.table = buildTable();
|
|
|
|
setBorder(new ScaledEmptyBorder(12, 12, 12, 12));
|
|
add(buildHeader(), BorderLayout.NORTH);
|
|
add(new JScrollPane(table), BorderLayout.CENTER);
|
|
add(buildFooter(), BorderLayout.SOUTH);
|
|
|
|
reloadPacks();
|
|
}
|
|
|
|
/** Copies the edited state into the settings; the caller saves them. */
|
|
void apply() {
|
|
settings.notifications.copyFrom(working);
|
|
settings.soundVolume = volume.getValue() / 100.0;
|
|
String directory = packDirField.getText().trim();
|
|
boolean rescan = !directory.equals(settings.soundPackDir);
|
|
settings.soundPackDir = directory;
|
|
settings.desktopNotifications = desktopNotifications.isSelected();
|
|
sounds.setPack((SoundPack) packCombo.getSelectedItem());
|
|
if (rescan) sounds.reload();
|
|
originalPackId = settings.soundPack;
|
|
originalPackDir = settings.soundPackDir;
|
|
}
|
|
|
|
/** Puts back the pack (and folder) last applied, for a cancelled edit. */
|
|
void revert() {
|
|
settings.soundPackDir = originalPackDir;
|
|
settings.soundPack = originalPackId;
|
|
sounds.reload();
|
|
}
|
|
|
|
// ---- layout ----
|
|
|
|
private JComponent buildHeader() {
|
|
JPanel p = new JPanel(new GridBagLayout());
|
|
GridBagConstraints c = new GridBagConstraints();
|
|
c.insets = UIScale.scale(new Insets(2, 2, 2, 2));
|
|
c.anchor = GridBagConstraints.WEST;
|
|
c.fill = GridBagConstraints.HORIZONTAL;
|
|
|
|
packCombo.setToolTipText("Sound packs installed here or in a TeamSpeak 3 client");
|
|
packCombo.addActionListener(e -> onPackSelected());
|
|
JButton test = new JButton("Test");
|
|
test.setToolTipText("Play this pack's test sound");
|
|
test.addActionListener(e -> sounds.preview(SoundEvent.SPECIAL_SOUND_TEST));
|
|
|
|
JPanel packRow = new JPanel(new BorderLayout(UIScale.scale(6), 0));
|
|
packRow.add(packCombo, BorderLayout.CENTER);
|
|
packRow.add(test, BorderLayout.EAST);
|
|
|
|
int row = 0;
|
|
addRow(p, c, row++, new JLabel("Sound pack:"), packRow);
|
|
c.gridx = 1;
|
|
c.gridy = row++;
|
|
packInfo.setEnabled(false);
|
|
p.add(packInfo, c);
|
|
|
|
volume.setToolTipText("Volume of the notification sounds");
|
|
addRow(p, c, row++, new JLabel("Sound volume:"), volume);
|
|
|
|
JPanel dirRow = new JPanel(new BorderLayout(UIScale.scale(6), 0));
|
|
JButton browse = new JButton("Browse…");
|
|
browse.addActionListener(e -> browseForPackFolder());
|
|
dirRow.add(packDirField, BorderLayout.CENTER);
|
|
dirRow.add(browse, BorderLayout.EAST);
|
|
packDirField.setToolTipText("Extra folder to look for sound packs in");
|
|
addRow(p, c, row++, new JLabel("Extra pack folder:"), dirRow);
|
|
|
|
desktopNotifications.setSelected(settings.desktopNotifications);
|
|
c.gridx = 0;
|
|
c.gridy = row;
|
|
c.gridwidth = 2;
|
|
p.add(desktopNotifications, c);
|
|
c.gridwidth = 1;
|
|
return p;
|
|
}
|
|
|
|
private JComponent buildFooter() {
|
|
JPanel p = new JPanel(new BorderLayout(UIScale.scale(6), 0));
|
|
JLabel hint = new JLabel("<html><b>Bold</b> actions are important: they are still played "
|
|
+ "while your speakers are muted.</html>");
|
|
hint.setBorder(new ScaledEmptyBorder(4, 0, 0, 0));
|
|
|
|
JPanel buttons = new JPanel();
|
|
JButton important = new JButton("Toggle important");
|
|
important.addActionListener(e -> toggleImportant());
|
|
JButton play = new JButton("Play");
|
|
play.addActionListener(e -> previewSelected());
|
|
JButton defaults = new JButton("Reset");
|
|
defaults.setToolTipText("Restore the default notification settings");
|
|
defaults.addActionListener(e -> {
|
|
working.resetToDefaults();
|
|
tableModel.fireTableDataChanged();
|
|
});
|
|
buttons.add(play);
|
|
buttons.add(important);
|
|
buttons.add(defaults);
|
|
|
|
p.add(hint, BorderLayout.CENTER);
|
|
p.add(buttons, BorderLayout.EAST);
|
|
return p;
|
|
}
|
|
|
|
private JTable buildTable() {
|
|
JTable t = new JTable(tableModel);
|
|
t.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
|
t.setShowGrid(false);
|
|
t.setTableHeader(null);
|
|
t.setRowHeight(Math.max(t.getRowHeight(), t.getFontMetrics(t.getFont()).getHeight() + UIScale.scale(6)));
|
|
t.getColumnModel().getColumn(0).setMaxWidth(UIScale.scale(TOGGLE_COLUMN_WIDTH));
|
|
t.getColumnModel().getColumn(0).setMinWidth(UIScale.scale(TOGGLE_COLUMN_WIDTH));
|
|
t.getColumnModel().getColumn(0).setCellRenderer(new ToggleRenderer(t.getDefaultRenderer(Boolean.class)));
|
|
t.getColumnModel().getColumn(1).setCellRenderer(new ActionRenderer());
|
|
t.setPreferredScrollableViewportSize(UIScale.scale(new Dimension(360, 260)));
|
|
|
|
t.addMouseListener(new MouseAdapter() {
|
|
@Override
|
|
public void mouseClicked(MouseEvent e) {
|
|
if (e.getClickCount() == 2 && e.getButton() == MouseEvent.BUTTON1) previewSelected();
|
|
}
|
|
|
|
@Override
|
|
public void mousePressed(MouseEvent e) {
|
|
showMenu(e);
|
|
}
|
|
|
|
@Override
|
|
public void mouseReleased(MouseEvent e) {
|
|
showMenu(e);
|
|
}
|
|
|
|
private void showMenu(MouseEvent e) {
|
|
if (!e.isPopupTrigger()) return;
|
|
int row = t.rowAtPoint(e.getPoint());
|
|
if (row < 0 || rows.get(row).isHeading()) return;
|
|
t.setRowSelectionInterval(row, row);
|
|
contextMenu(rows.get(row).event).show(t, e.getX(), e.getY());
|
|
}
|
|
});
|
|
return t;
|
|
}
|
|
|
|
private JPopupMenu contextMenu(SoundEvent event) {
|
|
JPopupMenu menu = new JPopupMenu();
|
|
JMenuItem important = new JMenuItem(working.isImportant(event) ? "Mark as Unimportant" : "Mark as Important");
|
|
important.addActionListener(e -> toggleImportant());
|
|
JMenuItem enabled = new JMenuItem(working.isEnabled(event) ? "Turn sound off" : "Turn sound on");
|
|
enabled.addActionListener(e -> {
|
|
working.setEnabled(event, !working.isEnabled(event));
|
|
tableModel.fireTableRowsUpdated(0, rows.size() - 1);
|
|
});
|
|
JMenuItem play = new JMenuItem("Play sound");
|
|
play.addActionListener(e -> sounds.preview(event));
|
|
menu.add(play);
|
|
menu.addSeparator();
|
|
menu.add(enabled);
|
|
menu.add(important);
|
|
return menu;
|
|
}
|
|
|
|
// ---- actions ----
|
|
|
|
private void onPackSelected() {
|
|
SoundPack pack = (SoundPack) packCombo.getSelectedItem();
|
|
sounds.setPack(pack);
|
|
packInfo.setText(pack == null ? "No sound packs found"
|
|
: "by " + (pack.author().isEmpty() ? "unknown" : pack.author())
|
|
+ (pack.version().isEmpty() ? "" : ", version " + pack.version()));
|
|
tableModel.fireTableDataChanged();
|
|
}
|
|
|
|
private void reloadPacks() {
|
|
sounds.reload();
|
|
packCombo.removeAllItems();
|
|
for (SoundPack pack : sounds.availablePacks()) {
|
|
packCombo.addItem(pack);
|
|
}
|
|
packCombo.setSelectedItem(sounds.pack());
|
|
onPackSelected();
|
|
}
|
|
|
|
private void browseForPackFolder() {
|
|
JFileChooser chooser = new JFileChooser();
|
|
chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
|
|
chooser.setDialogTitle("Folder containing sound packs");
|
|
String current = packDirField.getText().trim();
|
|
if (!current.isEmpty()) chooser.setCurrentDirectory(new File(current));
|
|
if (chooser.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) return;
|
|
packDirField.setText(chooser.getSelectedFile().getAbsolutePath());
|
|
settings.soundPackDir = packDirField.getText();
|
|
reloadPacks();
|
|
}
|
|
|
|
private SoundEvent selectedEvent() {
|
|
int row = table.getSelectedRow();
|
|
return row < 0 || rows.get(row).isHeading() ? null : rows.get(row).event;
|
|
}
|
|
|
|
private void previewSelected() {
|
|
SoundEvent event = selectedEvent();
|
|
if (event != null) sounds.preview(event);
|
|
}
|
|
|
|
private void toggleImportant() {
|
|
SoundEvent event = selectedEvent();
|
|
if (event == null) return;
|
|
working.setImportant(event, !working.isImportant(event));
|
|
tableModel.fireTableRowsUpdated(table.getSelectedRow(), table.getSelectedRow());
|
|
}
|
|
|
|
private void buildRows() {
|
|
for (SoundEvent.Category category : SoundEvent.Category.values()) {
|
|
rows.add(new Row(category, null));
|
|
for (SoundEvent event : SoundEvent.values()) {
|
|
if (event.category() == category) rows.add(new Row(category, event));
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, Component field) {
|
|
c.gridx = 0;
|
|
c.gridy = row;
|
|
c.weightx = 0;
|
|
p.add(label, c);
|
|
c.gridx = 1;
|
|
c.weightx = 1;
|
|
p.add(field, c);
|
|
}
|
|
|
|
// ---- table plumbing ----
|
|
|
|
private final class EventTableModel extends AbstractTableModel {
|
|
|
|
@Override
|
|
public int getRowCount() {
|
|
return rows.size();
|
|
}
|
|
|
|
@Override
|
|
public int getColumnCount() {
|
|
return 2;
|
|
}
|
|
|
|
@Override
|
|
public Class<?> getColumnClass(int column) {
|
|
return column == 0 ? Boolean.class : String.class;
|
|
}
|
|
|
|
@Override
|
|
public Object getValueAt(int rowIndex, int column) {
|
|
Row row = rows.get(rowIndex);
|
|
if (column == 0) return row.isHeading() ? Boolean.FALSE : working.isEnabled(row.event);
|
|
return row.isHeading() ? row.heading.label() : row.event.label();
|
|
}
|
|
|
|
@Override
|
|
public boolean isCellEditable(int rowIndex, int column) {
|
|
return column == 0 && !rows.get(rowIndex).isHeading();
|
|
}
|
|
|
|
@Override
|
|
public void setValueAt(Object value, int rowIndex, int column) {
|
|
Row row = rows.get(rowIndex);
|
|
if (column == 0 && !row.isHeading()) {
|
|
working.setEnabled(row.event, Boolean.TRUE.equals(value));
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Only actions get a checkbox; a category heading spans an empty cell. */
|
|
private final class ToggleRenderer implements javax.swing.table.TableCellRenderer {
|
|
|
|
private final javax.swing.table.TableCellRenderer checkBox;
|
|
private final DefaultTableCellRenderer blank = new DefaultTableCellRenderer();
|
|
|
|
ToggleRenderer(javax.swing.table.TableCellRenderer checkBox) {
|
|
this.checkBox = checkBox;
|
|
}
|
|
|
|
@Override
|
|
public Component getTableCellRendererComponent(JTable table, Object value, boolean selected,
|
|
boolean focused, int rowIndex, int column) {
|
|
javax.swing.table.TableCellRenderer delegate = rows.get(rowIndex).isHeading() ? blank : checkBox;
|
|
return delegate.getTableCellRendererComponent(table,
|
|
delegate == blank ? "" : value, selected, focused, rowIndex, column);
|
|
}
|
|
}
|
|
|
|
/** Headings stand out, important actions are bold, unmapped ones are greyed. */
|
|
private final class ActionRenderer extends DefaultTableCellRenderer {
|
|
|
|
@Override
|
|
public Component getTableCellRendererComponent(JTable table, Object value, boolean selected,
|
|
boolean focused, int rowIndex, int column) {
|
|
super.getTableCellRendererComponent(table, value, selected, focused, rowIndex, column);
|
|
Row row = rows.get(rowIndex);
|
|
Font base = table.getFont();
|
|
// The foreground is set on every row: this renderer remembers the last
|
|
// unselected colour it was given, so a greyed row would tint the rest.
|
|
if (row.isHeading()) {
|
|
setFont(base.deriveFont(Font.BOLD));
|
|
setForeground(selected ? table.getSelectionForeground() : table.getForeground());
|
|
setBorder(new ScaledEmptyBorder(2, 2, 2, 2));
|
|
setToolTipText(null);
|
|
return this;
|
|
}
|
|
setBorder(new ScaledEmptyBorder(2, 18, 2, 2));
|
|
setFont(base.deriveFont(working.isImportant(row.event) ? Font.BOLD : Font.PLAIN));
|
|
boolean silent = !sounds.hasSound(row.event);
|
|
setForeground(selected ? table.getSelectionForeground()
|
|
: silent ? java.awt.Color.GRAY : table.getForeground());
|
|
setToolTipText(silent ? "This sound pack has no sound for this action" : null);
|
|
return this;
|
|
}
|
|
}
|
|
}
|