Implement channel file transfers
Add file up/downloads and directory operations for channel file repositories, reachable from a channel's right-click "Browse files". Core (com.ts3client.net.filetransfer): - FileTransferManager drives the TS3 file protocol on the ts3j socket. ftinitupload/ftinitdownload results arrive out-of-band as notifystartupload/notifystartdownload (success) or notifystatusfiletransfer (failure) events, not as command responses, so negotiation correlates them by clientftfid via a CompletableFuture. The byte phase opens a raw TCP socket, sends the ftkey, then streams. - FileTransfer: observable per-transfer handle (state, progress, cancel). - RemoteFile: UI-facing listing DTO, keeping core free of ts3j API types. TeamspeakConnection exposes list/mkdir/delete/rename and up/download and manages the manager's lifecycle. Swing FileBrowserDialog provides the browser UI with a live per-transfer progress strip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,467 @@
|
||||
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");
|
||||
private final JButton downloadButton = new JButton("Download");
|
||||
private final JButton deleteButton = new JButton("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");
|
||||
refresh.addActionListener(e -> refresh());
|
||||
JButton mkdir = new JButton("New folder");
|
||||
mkdir.addActionListener(e -> createDirectory());
|
||||
JButton upload = new JButton("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(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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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();
|
||||
}
|
||||
}
|
||||
@@ -469,6 +469,12 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
|
||||
new ConnectionInfoDialog(this, conn, client.id, client.nickname).setVisible(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void browseFiles(ChannelNode channel) {
|
||||
if (!conn.canTransferFiles()) return;
|
||||
new FileBrowserDialog(this, conn, channel).setVisible(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isClientLocallyMuted(int clientId) {
|
||||
return conn.getPlayback() != null && conn.getPlayback().isClientMuted(clientId);
|
||||
|
||||
@@ -37,6 +37,9 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
|
||||
void showConnectionInfo(ClientEntry client);
|
||||
|
||||
/** Open the file repository browser for a channel. */
|
||||
void browseFiles(ChannelNode channel);
|
||||
|
||||
boolean isClientLocallyMuted(int clientId);
|
||||
|
||||
/** A channel or client node was selected (or {@code null} when cleared). */
|
||||
@@ -152,6 +155,10 @@ public final class ServerTreePanel extends JScrollPane {
|
||||
JMenuItem join = new JMenuItem("Join channel");
|
||||
join.addActionListener(a -> actions.joinChannel(channel.id));
|
||||
menu.add(join);
|
||||
menu.addSeparator();
|
||||
JMenuItem files = new JMenuItem("Browse files");
|
||||
files.addActionListener(a -> actions.browseFiles(channel));
|
||||
menu.add(files);
|
||||
menu.show(tree, e.getX(), e.getY());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user