Files
ts3j/ts3-client/swing/src/main/java/com/ts3client/ui/NotificationsPanel.java
ericek111 2d7b82f9a3 Play sound-pack notifications for client actions
Adds TeamSpeak-format sound packs: a folder of waves plus a settings.ini
mapping actions to play()/say() entries, with ${clientType} and friends
resolved per event. Packs are found in the client's own sound folder, an
installed TS3 client and a folder of the user's choosing, so the official
packs work unchanged.

Each action can be switched off or marked important; important actions are
the only ones still played while the speakers are muted, as in TS3. The new
Notifications options page lists them by category, greys out what the active
pack has no sound for, and previews on double-click.

Sounds are decoded, resampled and mixed onto a single playback line that is
only open while something plays, so overlapping events never fight over the
device.

Fires the events from the protocol layer, following TeamSpeak's own
distinctions: reason ids separate switched/moved/kicked/banned/timed out,
and visibility decides appears/disappears/stays.

Also fixes a ts3j trap in the process: a field an event never carried reads
back as an empty string, so the existing "e.get(x) != null" checks were
always true. That made a partial clientupdate (someone muting) announce a
stopped recording, and it let a nickname-only update reset another client's
mute/away flags and talk power, or a channel edit blank the channel name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:53:11 +00:00

398 lines
15 KiB
Java

package com.ts3client.ui;
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.BorderFactory;
import javax.swing.JButton;
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<>();
private final String originalPackId;
private final 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 JTable table;
private final EventTableModel tableModel = new EventTableModel();
NotificationsPanel(Settings settings, SoundNotifier sounds) {
super(new BorderLayout(0, 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(BorderFactory.createEmptyBorder(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;
sounds.setPack((SoundPack) packCombo.getSelectedItem());
if (rescan) sounds.reload();
}
/** Puts back the pack (and folder) the dialog started with, 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 = 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(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(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);
return p;
}
private JComponent buildFooter() {
JPanel p = new JPanel(new BorderLayout(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(BorderFactory.createEmptyBorder(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() + 6));
t.getColumnModel().getColumn(0).setMaxWidth(TOGGLE_COLUMN_WIDTH);
t.getColumnModel().getColumn(0).setMinWidth(TOGGLE_COLUMN_WIDTH);
t.getColumnModel().getColumn(0).setCellRenderer(new ToggleRenderer(t.getDefaultRenderer(Boolean.class)));
t.getColumnModel().getColumn(1).setCellRenderer(new ActionRenderer());
t.setPreferredScrollableViewportSize(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(BorderFactory.createEmptyBorder(2, 2, 2, 2));
setToolTipText(null);
return this;
}
setBorder(BorderFactory.createEmptyBorder(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;
}
}
}