Icon packs are read in TeamSpeak's own format — a zip (or unpacked folder) of SVG/PNG art plus a settings.ini mapping icon keys to files — so the packs of an installed client and the ones from its add-on site work unchanged. Legacy packs without that mapping (default.zip) are resolved by their file naming convention instead, picking the resolution closest to the drawn size. The pack draws the tree, toolbar, menus, context menus, file browser and the default group icons; a pack's FALLBACK option decides whether what it lacks comes from default.zip, and anything still missing falls back to the icons the client draws itself. Options → Design picks the pack and shows a viewer of everything in it. Vector art is rasterised with JSVG, and the pack search roots are shared with the sound packs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
481 lines
18 KiB
Java
481 lines
18 KiB
Java
package com.ts3client.ui;
|
|
|
|
import com.ts3client.net.ChannelNode;
|
|
import com.ts3client.net.TeamspeakConnection;
|
|
import com.ts3client.net.filetransfer.FileTransfer;
|
|
import com.ts3client.net.filetransfer.RemoteFile;
|
|
|
|
import javax.swing.BorderFactory;
|
|
import javax.swing.Box;
|
|
import javax.swing.BoxLayout;
|
|
import javax.swing.JButton;
|
|
import javax.swing.JDialog;
|
|
import javax.swing.JFileChooser;
|
|
import javax.swing.JLabel;
|
|
import javax.swing.JOptionPane;
|
|
import javax.swing.JPanel;
|
|
import javax.swing.JProgressBar;
|
|
import javax.swing.JScrollPane;
|
|
import javax.swing.JTable;
|
|
import javax.swing.ListSelectionModel;
|
|
import javax.swing.SwingUtilities;
|
|
import javax.swing.table.AbstractTableModel;
|
|
import java.awt.BorderLayout;
|
|
import java.awt.Component;
|
|
import java.awt.Dimension;
|
|
import java.awt.Frame;
|
|
import java.awt.event.MouseAdapter;
|
|
import java.awt.event.MouseEvent;
|
|
import java.io.File;
|
|
import java.text.DecimalFormat;
|
|
import java.text.SimpleDateFormat;
|
|
import java.util.ArrayList;
|
|
import java.util.Collections;
|
|
import java.util.Comparator;
|
|
import java.util.Date;
|
|
import java.util.List;
|
|
|
|
/**
|
|
* Browses a channel's file repository and drives uploads and downloads. The table
|
|
* lists the current directory; the toolbar navigates and acts on the selection; the
|
|
* strip at the bottom shows live progress for each active transfer.
|
|
*
|
|
* <p>All server calls run on background threads so the dialog never blocks the EDT;
|
|
* results are marshalled back with {@link SwingUtilities#invokeLater}.
|
|
*/
|
|
public final class FileBrowserDialog extends JDialog {
|
|
|
|
private final TeamspeakConnection conn;
|
|
private final int channelId;
|
|
private final String channelPassword;
|
|
|
|
private final FileTableModel tableModel = new FileTableModel();
|
|
private final JTable table = new JTable(tableModel);
|
|
private final JLabel pathLabel = new JLabel("/");
|
|
private final JButton upButton = new JButton("Up", Icons.of("FILE_UP"));
|
|
private final JButton downloadButton = new JButton("Download", Icons.of("DOWNLOAD"));
|
|
private final JButton deleteButton = new JButton("Delete", Icons.of("DELETE"));
|
|
private final JPanel transfersPanel = new JPanel();
|
|
|
|
private volatile String currentPath = "/";
|
|
|
|
public FileBrowserDialog(Frame owner, TeamspeakConnection conn, ChannelNode channel) {
|
|
super(owner, "Files — " + channel.name, false);
|
|
this.conn = conn;
|
|
this.channelId = channel.id;
|
|
this.channelPassword = "";
|
|
|
|
JPanel content = new JPanel(new BorderLayout(0, 8));
|
|
content.setBackground(Theme.WINDOW_BG);
|
|
content.setBorder(BorderFactory.createEmptyBorder(10, 12, 10, 12));
|
|
content.add(buildToolbar(), BorderLayout.NORTH);
|
|
content.add(buildTable(), BorderLayout.CENTER);
|
|
content.add(buildTransfers(), BorderLayout.SOUTH);
|
|
setContentPane(content);
|
|
|
|
setPreferredSize(new Dimension(640, 480));
|
|
pack();
|
|
setLocationRelativeTo(owner);
|
|
|
|
refresh();
|
|
}
|
|
|
|
// ---- construction ----
|
|
|
|
private JPanel buildToolbar() {
|
|
JPanel bar = new JPanel();
|
|
bar.setLayout(new BoxLayout(bar, BoxLayout.X_AXIS));
|
|
bar.setBackground(Theme.WINDOW_BG);
|
|
|
|
upButton.addActionListener(e -> navigateUp());
|
|
JButton refresh = new JButton("Refresh", Icons.of("FILE_REFRESH"));
|
|
refresh.addActionListener(e -> refresh());
|
|
JButton mkdir = new JButton("New folder", Icons.of("ADD_FOLDER"));
|
|
mkdir.addActionListener(e -> createDirectory());
|
|
JButton upload = new JButton("Upload…", Icons.of("UPLOAD"));
|
|
upload.addActionListener(e -> chooseUpload());
|
|
downloadButton.addActionListener(e -> downloadSelected());
|
|
deleteButton.addActionListener(e -> deleteSelected());
|
|
|
|
bar.add(upButton);
|
|
bar.add(Box.createHorizontalStrut(6));
|
|
bar.add(refresh);
|
|
bar.add(Box.createHorizontalStrut(16));
|
|
bar.add(pathLabel);
|
|
bar.add(Box.createHorizontalGlue());
|
|
bar.add(mkdir);
|
|
bar.add(Box.createHorizontalStrut(6));
|
|
bar.add(upload);
|
|
bar.add(Box.createHorizontalStrut(6));
|
|
bar.add(downloadButton);
|
|
bar.add(Box.createHorizontalStrut(6));
|
|
bar.add(deleteButton);
|
|
|
|
pathLabel.setFont(Theme.UI_FONT);
|
|
pathLabel.setForeground(Theme.TREE_TEXT);
|
|
return bar;
|
|
}
|
|
|
|
private JScrollPane buildTable() {
|
|
table.setFont(Theme.UI_FONT);
|
|
table.setRowHeight(20);
|
|
table.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
|
|
table.setFillsViewportHeight(true);
|
|
table.getColumnModel().getColumn(0).setPreferredWidth(300);
|
|
table.getColumnModel().getColumn(0).setCellRenderer(new NameRenderer());
|
|
table.getColumnModel().getColumn(1).setPreferredWidth(90);
|
|
table.getColumnModel().getColumn(2).setPreferredWidth(70);
|
|
table.getColumnModel().getColumn(3).setPreferredWidth(150);
|
|
table.getSelectionModel().addListSelectionListener(e -> updateButtons());
|
|
table.addMouseListener(new MouseAdapter() {
|
|
@Override
|
|
public void mouseClicked(MouseEvent e) {
|
|
if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2) {
|
|
int row = table.rowAtPoint(e.getPoint());
|
|
if (row >= 0) openEntry(tableModel.get(row));
|
|
}
|
|
}
|
|
});
|
|
JScrollPane scroll = new JScrollPane(table);
|
|
scroll.getViewport().setBackground(Theme.TREE_BG);
|
|
return scroll;
|
|
}
|
|
|
|
private JScrollPane buildTransfers() {
|
|
transfersPanel.setLayout(new BoxLayout(transfersPanel, BoxLayout.Y_AXIS));
|
|
transfersPanel.setBackground(Theme.WINDOW_BG);
|
|
JScrollPane scroll = new JScrollPane(transfersPanel);
|
|
scroll.setBorder(BorderFactory.createTitledBorder("Transfers"));
|
|
scroll.setPreferredSize(new Dimension(10, 120));
|
|
scroll.getViewport().setBackground(Theme.WINDOW_BG);
|
|
return scroll;
|
|
}
|
|
|
|
// ---- navigation ----
|
|
|
|
private void navigateUp() {
|
|
String path = currentPath;
|
|
if (path.equals("/")) return;
|
|
String trimmed = path.substring(0, path.length() - 1); // drop trailing slash
|
|
int slash = trimmed.lastIndexOf('/');
|
|
currentPath = slash <= 0 ? "/" : trimmed.substring(0, slash + 1);
|
|
refresh();
|
|
}
|
|
|
|
private void openEntry(RemoteFile file) {
|
|
if (file == null) return;
|
|
if (file.isDirectory()) {
|
|
currentPath = currentPath + file.getName() + "/";
|
|
refresh();
|
|
} else {
|
|
download(file);
|
|
}
|
|
}
|
|
|
|
/** Reloads the current directory listing from the server. */
|
|
private void refresh() {
|
|
pathLabel.setText(currentPath);
|
|
upButton.setEnabled(!currentPath.equals("/"));
|
|
final String path = currentPath;
|
|
new Thread(() -> {
|
|
try {
|
|
List<RemoteFile> files = conn.listFiles(channelId, channelPassword, path);
|
|
Collections.sort(files, DIRECTORIES_FIRST);
|
|
SwingUtilities.invokeLater(() -> {
|
|
if (path.equals(currentPath)) tableModel.setFiles(files);
|
|
updateButtons();
|
|
});
|
|
} catch (Exception ex) {
|
|
SwingUtilities.invokeLater(() -> {
|
|
if (path.equals(currentPath)) tableModel.setFiles(new ArrayList<>());
|
|
error("Could not list files", ex);
|
|
});
|
|
}
|
|
}, "ts3j-ft-list").start();
|
|
}
|
|
|
|
private void updateButtons() {
|
|
boolean hasSelection = table.getSelectedRowCount() > 0;
|
|
deleteButton.setEnabled(hasSelection);
|
|
boolean fileSelected = false;
|
|
for (int row : table.getSelectedRows()) {
|
|
if (!tableModel.get(row).isDirectory()) {
|
|
fileSelected = true;
|
|
break;
|
|
}
|
|
}
|
|
downloadButton.setEnabled(fileSelected);
|
|
}
|
|
|
|
// ---- actions ----
|
|
|
|
private void downloadSelected() {
|
|
List<RemoteFile> files = new ArrayList<>();
|
|
for (int row : table.getSelectedRows()) {
|
|
RemoteFile f = tableModel.get(row);
|
|
if (!f.isDirectory()) files.add(f);
|
|
}
|
|
if (files.isEmpty()) return;
|
|
if (files.size() == 1) {
|
|
download(files.get(0));
|
|
return;
|
|
}
|
|
// Multiple files: pick a target directory once.
|
|
JFileChooser chooser = new JFileChooser();
|
|
chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
|
|
chooser.setDialogTitle("Download " + files.size() + " files to…");
|
|
if (chooser.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) return;
|
|
File dir = chooser.getSelectedFile();
|
|
for (RemoteFile f : files) {
|
|
startDownload(f, new File(dir, f.getName()));
|
|
}
|
|
}
|
|
|
|
private void download(RemoteFile file) {
|
|
JFileChooser chooser = new JFileChooser();
|
|
chooser.setSelectedFile(new File(file.getName()));
|
|
chooser.setDialogTitle("Download " + file.getName());
|
|
if (chooser.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) return;
|
|
startDownload(file, chooser.getSelectedFile());
|
|
}
|
|
|
|
private void startDownload(RemoteFile file, File target) {
|
|
TransferRow row = new TransferRow();
|
|
FileTransfer transfer = conn.downloadFile(channelId, channelPassword, file.getFullPath(), target,
|
|
t -> SwingUtilities.invokeLater(() -> row.update(t)));
|
|
row.attach(transfer);
|
|
addTransferRow(row);
|
|
}
|
|
|
|
private void chooseUpload() {
|
|
JFileChooser chooser = new JFileChooser();
|
|
chooser.setMultiSelectionEnabled(true);
|
|
chooser.setDialogTitle("Upload to " + currentPath);
|
|
if (chooser.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) return;
|
|
for (File source : chooser.getSelectedFiles()) {
|
|
if (!source.isFile()) continue;
|
|
String remotePath = currentPath + source.getName();
|
|
TransferRow row = new TransferRow();
|
|
FileTransfer transfer = conn.uploadFile(channelId, channelPassword, remotePath, source, true,
|
|
t -> SwingUtilities.invokeLater(() -> {
|
|
row.update(t);
|
|
if (t.getState() == FileTransfer.State.COMPLETED) refresh();
|
|
}));
|
|
row.attach(transfer);
|
|
addTransferRow(row);
|
|
}
|
|
}
|
|
|
|
private void createDirectory() {
|
|
String name = JOptionPane.showInputDialog(this, "New folder name:", "New folder",
|
|
JOptionPane.PLAIN_MESSAGE);
|
|
if (name == null || name.trim().isEmpty()) return;
|
|
final String dirPath = currentPath + name.trim();
|
|
new Thread(() -> {
|
|
try {
|
|
conn.createDirectory(channelId, channelPassword, dirPath);
|
|
SwingUtilities.invokeLater(this::refresh);
|
|
} catch (Exception ex) {
|
|
SwingUtilities.invokeLater(() -> error("Could not create folder", ex));
|
|
}
|
|
}, "ts3j-ft-mkdir").start();
|
|
}
|
|
|
|
private void deleteSelected() {
|
|
List<RemoteFile> files = new ArrayList<>();
|
|
for (int row : table.getSelectedRows()) files.add(tableModel.get(row));
|
|
if (files.isEmpty()) return;
|
|
int choice = JOptionPane.showConfirmDialog(this,
|
|
"Delete " + (files.size() == 1 ? "\"" + files.get(0).getName() + "\"" : files.size() + " items")
|
|
+ "?", "Delete", JOptionPane.OK_CANCEL_OPTION, JOptionPane.WARNING_MESSAGE);
|
|
if (choice != JOptionPane.OK_OPTION) return;
|
|
new Thread(() -> {
|
|
Exception failure = null;
|
|
for (RemoteFile f : files) {
|
|
try {
|
|
conn.deleteFile(channelId, channelPassword, f.getFullPath());
|
|
} catch (Exception ex) {
|
|
failure = ex;
|
|
}
|
|
}
|
|
final Exception shown = failure;
|
|
SwingUtilities.invokeLater(() -> {
|
|
refresh();
|
|
if (shown != null) error("Could not delete some items", shown);
|
|
});
|
|
}, "ts3j-ft-delete").start();
|
|
}
|
|
|
|
private void addTransferRow(TransferRow row) {
|
|
transfersPanel.add(row);
|
|
transfersPanel.revalidate();
|
|
transfersPanel.repaint();
|
|
}
|
|
|
|
private void error(String title, Exception ex) {
|
|
JOptionPane.showMessageDialog(this, rootMessage(ex), title, JOptionPane.ERROR_MESSAGE);
|
|
}
|
|
|
|
// ---- one transfer's row ----
|
|
|
|
private final class TransferRow extends JPanel {
|
|
private final JLabel label = new JLabel();
|
|
private final JProgressBar bar = new JProgressBar(0, 1000);
|
|
private final JButton action = new JButton("Cancel");
|
|
private FileTransfer transfer;
|
|
|
|
TransferRow() {
|
|
setLayout(new BorderLayout(8, 0));
|
|
setBackground(Theme.WINDOW_BG);
|
|
setBorder(BorderFactory.createEmptyBorder(3, 2, 3, 2));
|
|
setMaximumSize(new Dimension(Integer.MAX_VALUE, 44));
|
|
label.setFont(Theme.UI_FONT);
|
|
bar.setStringPainted(true);
|
|
add(label, BorderLayout.NORTH);
|
|
add(bar, BorderLayout.CENTER);
|
|
add(action, BorderLayout.EAST);
|
|
}
|
|
|
|
void attach(FileTransfer t) {
|
|
this.transfer = t;
|
|
action.addActionListener(e -> {
|
|
if (transfer.isDone()) {
|
|
transfersPanel.remove(this);
|
|
transfersPanel.revalidate();
|
|
transfersPanel.repaint();
|
|
} else {
|
|
transfer.cancel();
|
|
}
|
|
});
|
|
update(t);
|
|
}
|
|
|
|
void update(FileTransfer t) {
|
|
String arrow = t.getDirection() == FileTransfer.Direction.UPLOAD ? "↑" : "↓";
|
|
String head = arrow + " " + t.getRemoteName();
|
|
switch (t.getState()) {
|
|
case PENDING:
|
|
label.setText(head + " — starting…");
|
|
break;
|
|
case ACTIVE:
|
|
label.setText(head + " — " + formatBytes(t.getTransferredBytes()) + " / "
|
|
+ formatBytes(t.getTotalBytes()) + " (" + formatRate(t.getAverageSpeed()) + ")");
|
|
break;
|
|
case COMPLETED:
|
|
label.setText(head + " — done (" + formatBytes(t.getTotalBytes()) + ")");
|
|
break;
|
|
case CANCELLED:
|
|
label.setText(head + " — cancelled");
|
|
break;
|
|
case FAILED:
|
|
label.setText(head + " — failed: " + orEmpty(t.getErrorMessage()));
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
int permille = (int) Math.round(t.getProgress() * 1000);
|
|
bar.setValue(permille);
|
|
bar.setString(t.getTotalBytes() > 0 ? (permille / 10) + "%" : "");
|
|
if (t.isDone()) {
|
|
bar.setValue(t.getState() == FileTransfer.State.COMPLETED ? 1000 : bar.getValue());
|
|
action.setText("Clear");
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- table model ----
|
|
|
|
private static final Comparator<RemoteFile> DIRECTORIES_FIRST = (a, b) -> {
|
|
if (a.isDirectory() != b.isDirectory()) return a.isDirectory() ? -1 : 1;
|
|
return a.getName().compareToIgnoreCase(b.getName());
|
|
};
|
|
|
|
private static final class FileTableModel extends AbstractTableModel {
|
|
private static final String[] COLUMNS = {"Name", "Size", "Type", "Modified"};
|
|
private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm");
|
|
private List<RemoteFile> files = new ArrayList<>();
|
|
|
|
void setFiles(List<RemoteFile> files) {
|
|
this.files = files;
|
|
fireTableDataChanged();
|
|
}
|
|
|
|
RemoteFile get(int row) {
|
|
return files.get(row);
|
|
}
|
|
|
|
@Override
|
|
public int getRowCount() {
|
|
return files.size();
|
|
}
|
|
|
|
@Override
|
|
public int getColumnCount() {
|
|
return COLUMNS.length;
|
|
}
|
|
|
|
@Override
|
|
public String getColumnName(int column) {
|
|
return COLUMNS[column];
|
|
}
|
|
|
|
@Override
|
|
public Object getValueAt(int rowIndex, int columnIndex) {
|
|
RemoteFile f = files.get(rowIndex);
|
|
switch (columnIndex) {
|
|
case 0:
|
|
return f.getName();
|
|
case 1:
|
|
return f.isDirectory() ? "" : formatBytes(f.getSize());
|
|
case 2:
|
|
return f.isDirectory() ? "Folder" : "File";
|
|
case 3:
|
|
long sec = f.getLastModifiedEpochSec();
|
|
return sec > 0 ? dateFormat.format(new Date(sec * 1000)) : "";
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Marks folders and files with the icon pack's own glyphs. */
|
|
private final class NameRenderer extends javax.swing.table.DefaultTableCellRenderer {
|
|
@Override
|
|
public java.awt.Component getTableCellRendererComponent(JTable t, Object value, boolean selected,
|
|
boolean focus, int row, int column) {
|
|
super.getTableCellRendererComponent(t, value, selected, focus, row, column);
|
|
boolean directory = row >= 0 && row < tableModel.getRowCount() && tableModel.get(row).isDirectory();
|
|
setIcon(Icons.of(directory ? "FOLDER" : "DEFAULT"));
|
|
return this;
|
|
}
|
|
}
|
|
|
|
// ---- formatting ----
|
|
|
|
private static String formatBytes(long bytes) {
|
|
if (bytes < 0) return "—";
|
|
if (bytes < 1024) return bytes + " B";
|
|
double kib = bytes / 1024.0;
|
|
if (kib < 1024) return new DecimalFormat("0.0").format(kib) + " KiB";
|
|
double mib = kib / 1024.0;
|
|
if (mib < 1024) return new DecimalFormat("0.00").format(mib) + " MiB";
|
|
return new DecimalFormat("0.00").format(mib / 1024.0) + " GiB";
|
|
}
|
|
|
|
private static String formatRate(double bytesPerSecond) {
|
|
if (bytesPerSecond <= 0) return "—";
|
|
return formatBytes(Math.round(bytesPerSecond)) + "/s";
|
|
}
|
|
|
|
private static String orEmpty(String s) {
|
|
return s == null ? "" : s;
|
|
}
|
|
|
|
private static String rootMessage(Throwable t) {
|
|
Throwable r = t;
|
|
while (r.getCause() != null && r.getCause() != r) r = r.getCause();
|
|
String m = r.getMessage();
|
|
return m != null ? m : r.getClass().getSimpleName();
|
|
}
|
|
}
|