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:
@@ -15,6 +15,9 @@ import com.ts3client.audio.AudioBackend;
|
|||||||
import com.ts3client.audio.VoiceInput;
|
import com.ts3client.audio.VoiceInput;
|
||||||
import com.ts3client.audio.VoiceOutput;
|
import com.ts3client.audio.VoiceOutput;
|
||||||
import com.ts3client.config.Settings;
|
import com.ts3client.config.Settings;
|
||||||
|
import com.ts3client.net.filetransfer.FileTransfer;
|
||||||
|
import com.ts3client.net.filetransfer.FileTransferManager;
|
||||||
|
import com.ts3client.net.filetransfer.RemoteFile;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
@@ -38,10 +41,12 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
private VoiceInput microphone;
|
private VoiceInput microphone;
|
||||||
private VoiceOutput playback;
|
private VoiceOutput playback;
|
||||||
private LocalIdentity identity;
|
private LocalIdentity identity;
|
||||||
|
private FileTransferManager fileTransfers;
|
||||||
|
|
||||||
private volatile boolean connected;
|
private volatile boolean connected;
|
||||||
private volatile int selfClientId = -1;
|
private volatile int selfClientId = -1;
|
||||||
private volatile long connectedAtMs;
|
private volatile long connectedAtMs;
|
||||||
|
private volatile String serverHost;
|
||||||
|
|
||||||
/** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */
|
/** Outstanding {@code getconnectioninfo} requests awaiting a server report, keyed by client id. */
|
||||||
private final Map<Integer, PendingConnInfo> pendingConnInfo = new ConcurrentHashMap<>();
|
private final Map<Integer, PendingConnInfo> pendingConnInfo = new ConcurrentHashMap<>();
|
||||||
@@ -143,6 +148,8 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
|
|
||||||
// Protocol connection established; anything past this point is best-effort.
|
// Protocol connection established; anything past this point is best-effort.
|
||||||
selfClientId = client.getClientId();
|
selfClientId = client.getClientId();
|
||||||
|
serverHost = address;
|
||||||
|
fileTransfers = new FileTransferManager(client, () -> serverHost);
|
||||||
client.setMicrophone(microphone);
|
client.setMicrophone(microphone);
|
||||||
connected = true;
|
connected = true;
|
||||||
connectedAtMs = System.currentTimeMillis();
|
connectedAtMs = System.currentTimeMillis();
|
||||||
@@ -235,12 +242,21 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
VoiceInput mic = microphone;
|
VoiceInput mic = microphone;
|
||||||
VoiceOutput out = playback;
|
VoiceOutput out = playback;
|
||||||
LocalTeamspeakClientSocket sock = client;
|
LocalTeamspeakClientSocket sock = client;
|
||||||
|
FileTransferManager ft = fileTransfers;
|
||||||
microphone = null;
|
microphone = null;
|
||||||
playback = null;
|
playback = null;
|
||||||
client = null;
|
client = null;
|
||||||
|
fileTransfers = null;
|
||||||
selfClientId = -1;
|
selfClientId = -1;
|
||||||
pendingConnInfo.clear();
|
pendingConnInfo.clear();
|
||||||
|
|
||||||
|
if (ft != null) {
|
||||||
|
try {
|
||||||
|
ft.shutdown();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (mic != null) {
|
if (mic != null) {
|
||||||
try {
|
try {
|
||||||
mic.stop();
|
mic.stop();
|
||||||
@@ -531,6 +547,59 @@ public final class TeamspeakConnection implements TS3Listener {
|
|||||||
}, "ts3j-clientinfo").start();
|
}, "ts3j-clientinfo").start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- file transfers ----
|
||||||
|
|
||||||
|
/** Whether a live connection capable of file transfers is available. */
|
||||||
|
public boolean canTransferFiles() {
|
||||||
|
return connected && fileTransfers != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists the files under {@code path} in a channel's repository. Blocking; call
|
||||||
|
* off the UI thread. Throws if not connected or the server denies the request.
|
||||||
|
*/
|
||||||
|
public java.util.List<RemoteFile> listFiles(int channelId, String channelPassword, String path)
|
||||||
|
throws Exception {
|
||||||
|
FileTransferManager ft = fileTransfers;
|
||||||
|
if (ft == null) throw new IllegalStateException("Not connected");
|
||||||
|
return ft.list(channelId, channelPassword, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void createDirectory(int channelId, String channelPassword, String dirPath) throws Exception {
|
||||||
|
FileTransferManager ft = fileTransfers;
|
||||||
|
if (ft == null) throw new IllegalStateException("Not connected");
|
||||||
|
ft.createDirectory(channelId, channelPassword, dirPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteFile(int channelId, String channelPassword, String fullPath) throws Exception {
|
||||||
|
FileTransferManager ft = fileTransfers;
|
||||||
|
if (ft == null) throw new IllegalStateException("Not connected");
|
||||||
|
ft.delete(channelId, channelPassword, fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void renameFile(int channelId, String channelPassword, String oldFullPath, String newFullPath)
|
||||||
|
throws Exception {
|
||||||
|
FileTransferManager ft = fileTransfers;
|
||||||
|
if (ft == null) throw new IllegalStateException("Not connected");
|
||||||
|
ft.rename(channelId, channelPassword, oldFullPath, newFullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begins downloading a channel file to {@code target}; progress arrives via {@code listener}. */
|
||||||
|
public FileTransfer downloadFile(int channelId, String channelPassword, String remoteFullPath,
|
||||||
|
File target, FileTransfer.Listener listener) {
|
||||||
|
FileTransferManager ft = fileTransfers;
|
||||||
|
if (ft == null) throw new IllegalStateException("Not connected");
|
||||||
|
return ft.download(channelId, channelPassword, remoteFullPath, target, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begins uploading {@code source} into a channel; progress arrives via {@code listener}. */
|
||||||
|
public FileTransfer uploadFile(int channelId, String channelPassword, String remoteFullPath,
|
||||||
|
File source, boolean overwrite, FileTransfer.Listener listener) {
|
||||||
|
FileTransferManager ft = fileTransfers;
|
||||||
|
if (ft == null) throw new IllegalStateException("Not connected");
|
||||||
|
return ft.upload(channelId, channelPassword, remoteFullPath, source, overwrite, listener);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- connection info ----
|
// ---- connection info ----
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package com.ts3client.net.filetransfer;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An observable handle for a single in-flight (or finished) file transfer. Created
|
||||||
|
* by {@link FileTransferManager}; the actual byte copy runs on a background thread
|
||||||
|
* and mutates this object, notifying a {@link Listener} on every change so a UI can
|
||||||
|
* render live progress. All mutating methods are package-private — callers only read.
|
||||||
|
*/
|
||||||
|
public final class FileTransfer {
|
||||||
|
|
||||||
|
public enum Direction {UPLOAD, DOWNLOAD}
|
||||||
|
|
||||||
|
public enum State {
|
||||||
|
/** Announced to the server, file connection not yet established. */
|
||||||
|
PENDING,
|
||||||
|
/** Bytes are actively being transferred. */
|
||||||
|
ACTIVE,
|
||||||
|
COMPLETED,
|
||||||
|
FAILED,
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notified (off the UI thread) whenever this transfer's progress or state changes. */
|
||||||
|
public interface Listener {
|
||||||
|
void onTransferChanged(FileTransfer transfer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Direction direction;
|
||||||
|
private final int channelId;
|
||||||
|
private final String remoteName;
|
||||||
|
private final File localFile;
|
||||||
|
private final Listener listener;
|
||||||
|
|
||||||
|
private volatile long totalBytes;
|
||||||
|
private volatile long transferredBytes;
|
||||||
|
private volatile State state = State.PENDING;
|
||||||
|
private volatile String errorMessage;
|
||||||
|
private volatile boolean cancelRequested;
|
||||||
|
|
||||||
|
private final long startedAtMs = System.currentTimeMillis();
|
||||||
|
private volatile long finishedAtMs;
|
||||||
|
|
||||||
|
FileTransfer(Direction direction, int channelId, String remoteName, File localFile,
|
||||||
|
long totalBytes, Listener listener) {
|
||||||
|
this.direction = direction;
|
||||||
|
this.channelId = channelId;
|
||||||
|
this.remoteName = remoteName;
|
||||||
|
this.localFile = localFile;
|
||||||
|
this.totalBytes = totalBytes;
|
||||||
|
this.listener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- read-only accessors ----
|
||||||
|
|
||||||
|
public Direction getDirection() {
|
||||||
|
return direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getChannelId() {
|
||||||
|
return channelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRemoteName() {
|
||||||
|
return remoteName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public File getLocalFile() {
|
||||||
|
return localFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTotalBytes() {
|
||||||
|
return totalBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTransferredBytes() {
|
||||||
|
return transferredBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public State getState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getErrorMessage() {
|
||||||
|
return errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDone() {
|
||||||
|
State s = state;
|
||||||
|
return s == State.COMPLETED || s == State.FAILED || s == State.CANCELLED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Completion fraction in [0, 1], or 0 when the total size is unknown. */
|
||||||
|
public double getProgress() {
|
||||||
|
long total = totalBytes;
|
||||||
|
if (total <= 0) return isDone() && state == State.COMPLETED ? 1.0 : 0.0;
|
||||||
|
double p = (double) transferredBytes / total;
|
||||||
|
return p < 0 ? 0 : (p > 1 ? 1 : p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Average speed in bytes per second over the transfer's lifetime, or 0 if not measurable. */
|
||||||
|
public double getAverageSpeed() {
|
||||||
|
long end = isDone() ? finishedAtMs : System.currentTimeMillis();
|
||||||
|
long elapsed = Math.max(1, end - startedAtMs);
|
||||||
|
return transferredBytes * 1000.0 / elapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Requests cancellation; the transfer thread aborts at the next chunk boundary. */
|
||||||
|
public void cancel() {
|
||||||
|
cancelRequested = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isCancelRequested() {
|
||||||
|
return cancelRequested;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- package-private mutators, called by FileTransferManager ----
|
||||||
|
|
||||||
|
void setTotalBytes(long totalBytes) {
|
||||||
|
this.totalBytes = totalBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
void addTransferred(long delta) {
|
||||||
|
transferredBytes += delta;
|
||||||
|
fireChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void markActive() {
|
||||||
|
state = State.ACTIVE;
|
||||||
|
fireChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void markCompleted() {
|
||||||
|
finishedAtMs = System.currentTimeMillis();
|
||||||
|
state = State.COMPLETED;
|
||||||
|
fireChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void markCancelled() {
|
||||||
|
finishedAtMs = System.currentTimeMillis();
|
||||||
|
state = State.CANCELLED;
|
||||||
|
fireChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void markFailed(String message) {
|
||||||
|
finishedAtMs = System.currentTimeMillis();
|
||||||
|
errorMessage = message;
|
||||||
|
state = State.FAILED;
|
||||||
|
fireChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fireChanged() {
|
||||||
|
if (listener != null) listener.onTransferChanged(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
package com.ts3client.net.filetransfer;
|
||||||
|
|
||||||
|
import com.github.manevolent.ts3j.api.FileTransferParameters;
|
||||||
|
import com.github.manevolent.ts3j.command.SingleCommand;
|
||||||
|
import com.github.manevolent.ts3j.command.parameter.CommandSingleParameter;
|
||||||
|
import com.github.manevolent.ts3j.event.TS3Listener;
|
||||||
|
import com.github.manevolent.ts3j.event.UnknownTeamspeakEvent;
|
||||||
|
import com.github.manevolent.ts3j.protocol.ProtocolRole;
|
||||||
|
import com.github.manevolent.ts3j.protocol.socket.client.LocalTeamspeakClientSocket;
|
||||||
|
|
||||||
|
import java.io.BufferedInputStream;
|
||||||
|
import java.io.BufferedOutputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements the TeamSpeak 3 channel file-transfer protocol on top of a connected
|
||||||
|
* {@link LocalTeamspeakClientSocket}.
|
||||||
|
*
|
||||||
|
* <p>Directory operations ({@link #list}, {@link #createDirectory}, {@link #delete},
|
||||||
|
* {@link #rename}) are ordinary control commands and run synchronously on the caller's
|
||||||
|
* thread. Byte transfers ({@link #download}, {@link #upload}) are two-phase: an
|
||||||
|
* {@code ftinit*} control command negotiates a one-time key, host and port, after which
|
||||||
|
* a raw TCP connection to the server's file port carries the bytes. That copy runs on a
|
||||||
|
* background thread and reports through the returned {@link FileTransfer} handle.
|
||||||
|
*/
|
||||||
|
public final class FileTransferManager {
|
||||||
|
|
||||||
|
private static final int CONNECT_TIMEOUT_MS = 15_000;
|
||||||
|
private static final int BUFFER_SIZE = 64 * 1024;
|
||||||
|
/** How long to wait for the server's {@code notifystart*} reply after an {@code ftinit*}. */
|
||||||
|
private static final long NEGOTIATE_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
/** TeamSpeak error id returned by {@code ftgetfilelist} for an empty directory. */
|
||||||
|
private static final int ERROR_DATABASE_EMPTY_RESULT = 0x0501;
|
||||||
|
|
||||||
|
private final LocalTeamspeakClientSocket socket;
|
||||||
|
/** Host we are connected to, used when the server reports no dedicated file-transfer host. */
|
||||||
|
private final Supplier<String> fallbackHost;
|
||||||
|
private final AtomicInteger clientTransferId = new AtomicInteger(1);
|
||||||
|
private final ExecutorService transferPool = Executors.newCachedThreadPool(r -> {
|
||||||
|
Thread t = new Thread(r, "ts3j-filetransfer");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-flight {@code ftinit*} negotiations keyed by our {@code clientftfid}. The server
|
||||||
|
* answers an init out-of-band with a {@code notifystart*} (success) or a
|
||||||
|
* {@code notifystatusfiletransfer} (failure) event, which {@link #ftEventListener}
|
||||||
|
* routes back to the waiting transfer thread.
|
||||||
|
*/
|
||||||
|
private final Map<Integer, CompletableFuture<Map<String, String>>> pendingInits = new ConcurrentHashMap<>();
|
||||||
|
private final TS3Listener ftEventListener = new TS3Listener() {
|
||||||
|
@Override
|
||||||
|
public void onUnknownEvent(UnknownTeamspeakEvent e) {
|
||||||
|
handleFileTransferEvent(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public FileTransferManager(LocalTeamspeakClientSocket socket, Supplier<String> fallbackHost) {
|
||||||
|
this.socket = socket;
|
||||||
|
this.fallbackHost = fallbackHost;
|
||||||
|
socket.addListener(ftEventListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- directory operations ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists the files and subdirectories directly under {@code path} in the given
|
||||||
|
* channel's repository. Returns an empty list for an empty directory.
|
||||||
|
*/
|
||||||
|
public List<RemoteFile> list(int channelId, String channelPassword, String path)
|
||||||
|
throws Exception {
|
||||||
|
SingleCommand cmd = new SingleCommand("ftgetfilelist", ProtocolRole.CLIENT,
|
||||||
|
new CommandSingleParameter("cid", Integer.toString(channelId)),
|
||||||
|
new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword),
|
||||||
|
new CommandSingleParameter("path", path == null || path.isEmpty() ? "/" : path));
|
||||||
|
|
||||||
|
List<RemoteFile> files = new ArrayList<>();
|
||||||
|
Iterable<SingleCommand> rows;
|
||||||
|
try {
|
||||||
|
rows = socket.executeCommand(cmd).get();
|
||||||
|
} catch (com.github.manevolent.ts3j.command.CommandException e) {
|
||||||
|
if (e.getErrorId() == ERROR_DATABASE_EMPTY_RESULT) return files;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
for (SingleCommand row : rows) {
|
||||||
|
Map<String, String> m = row.toMap();
|
||||||
|
String name = m.get("name");
|
||||||
|
if (name == null || name.isEmpty()) continue;
|
||||||
|
files.add(new RemoteFile(
|
||||||
|
name,
|
||||||
|
m.getOrDefault("path", path),
|
||||||
|
parseLong(m.get("size")),
|
||||||
|
"0".equals(m.get("type")),
|
||||||
|
parseLong(m.get("datetime"))));
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a new directory at {@code dirPath} (a full path such as {@code /new}). */
|
||||||
|
public void createDirectory(int channelId, String channelPassword, String dirPath)
|
||||||
|
throws Exception {
|
||||||
|
SingleCommand cmd = new SingleCommand("ftcreatedir", ProtocolRole.CLIENT,
|
||||||
|
new CommandSingleParameter("cid", Integer.toString(channelId)),
|
||||||
|
new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword),
|
||||||
|
new CommandSingleParameter("dirname", dirPath));
|
||||||
|
socket.executeCommand(cmd).complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes a file or (recursively) a directory at {@code fullPath}. */
|
||||||
|
public void delete(int channelId, String channelPassword, String fullPath)
|
||||||
|
throws Exception {
|
||||||
|
SingleCommand cmd = new SingleCommand("ftdeletefile", ProtocolRole.CLIENT,
|
||||||
|
new CommandSingleParameter("cid", Integer.toString(channelId)),
|
||||||
|
new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword),
|
||||||
|
new CommandSingleParameter("name", fullPath));
|
||||||
|
socket.executeCommand(cmd).complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renames or moves a file within the same channel. */
|
||||||
|
public void rename(int channelId, String channelPassword, String oldFullPath, String newFullPath)
|
||||||
|
throws Exception {
|
||||||
|
SingleCommand cmd = new SingleCommand("ftrenamefile", ProtocolRole.CLIENT,
|
||||||
|
new CommandSingleParameter("cid", Integer.toString(channelId)),
|
||||||
|
new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword),
|
||||||
|
new CommandSingleParameter("oldname", oldFullPath),
|
||||||
|
new CommandSingleParameter("newname", newFullPath));
|
||||||
|
socket.executeCommand(cmd).complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- byte transfers ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts downloading {@code remoteFullPath} from the channel to {@code target}.
|
||||||
|
* Returns immediately with a handle whose progress the caller can observe; the
|
||||||
|
* copy proceeds on a background thread.
|
||||||
|
*/
|
||||||
|
public FileTransfer download(int channelId, String channelPassword, String remoteFullPath,
|
||||||
|
File target, FileTransfer.Listener listener) {
|
||||||
|
FileTransfer transfer = new FileTransfer(FileTransfer.Direction.DOWNLOAD, channelId,
|
||||||
|
fileName(remoteFullPath), target, -1, listener);
|
||||||
|
transferPool.execute(() -> runDownload(channelId, channelPassword, remoteFullPath, transfer));
|
||||||
|
return transfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts uploading {@code source} into the channel at {@code remoteFullPath}.
|
||||||
|
* Returns immediately with a handle whose progress the caller can observe.
|
||||||
|
*/
|
||||||
|
public FileTransfer upload(int channelId, String channelPassword, String remoteFullPath,
|
||||||
|
File source, boolean overwrite, FileTransfer.Listener listener) {
|
||||||
|
FileTransfer transfer = new FileTransfer(FileTransfer.Direction.UPLOAD, channelId,
|
||||||
|
fileName(remoteFullPath), source, source.length(), listener);
|
||||||
|
transferPool.execute(() -> runUpload(channelId, channelPassword, remoteFullPath, source, overwrite, transfer));
|
||||||
|
return transfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runDownload(int channelId, String channelPassword, String remoteFullPath,
|
||||||
|
FileTransfer transfer) {
|
||||||
|
try {
|
||||||
|
int ftfid = clientTransferId.getAndIncrement();
|
||||||
|
SingleCommand cmd = new SingleCommand("ftinitdownload", ProtocolRole.CLIENT,
|
||||||
|
new CommandSingleParameter("clientftfid", Integer.toString(ftfid)),
|
||||||
|
new CommandSingleParameter("name", remoteFullPath),
|
||||||
|
new CommandSingleParameter("cid", Integer.toString(channelId)),
|
||||||
|
new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword),
|
||||||
|
new CommandSingleParameter("seekpos", "0"),
|
||||||
|
new CommandSingleParameter("proto", "0"));
|
||||||
|
|
||||||
|
FileTransferParameters params = negotiate(ftfid, cmd);
|
||||||
|
long size = params.getFileSize();
|
||||||
|
transfer.setTotalBytes(size);
|
||||||
|
|
||||||
|
try (Socket fileSocket = openFileSocket(params);
|
||||||
|
InputStream in = new BufferedInputStream(fileSocket.getInputStream());
|
||||||
|
OutputStream fileOut = new BufferedOutputStream(new FileOutputStream(transfer.getLocalFile()))) {
|
||||||
|
|
||||||
|
sendKey(fileSocket, params);
|
||||||
|
transfer.markActive();
|
||||||
|
|
||||||
|
byte[] buffer = new byte[BUFFER_SIZE];
|
||||||
|
long remaining = size;
|
||||||
|
while (remaining > 0) {
|
||||||
|
if (transfer.isCancelRequested()) {
|
||||||
|
transfer.markCancelled();
|
||||||
|
deleteQuietly(transfer.getLocalFile());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int want = (int) Math.min(buffer.length, remaining);
|
||||||
|
int read = in.read(buffer, 0, want);
|
||||||
|
if (read < 0) throw new IOException("File server closed the connection early");
|
||||||
|
fileOut.write(buffer, 0, read);
|
||||||
|
remaining -= read;
|
||||||
|
transfer.addTransferred(read);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transfer.markCompleted();
|
||||||
|
} catch (Exception e) {
|
||||||
|
transfer.markFailed(rootMessage(e));
|
||||||
|
deleteQuietly(transfer.getLocalFile());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runUpload(int channelId, String channelPassword, String remoteFullPath,
|
||||||
|
File source, boolean overwrite, FileTransfer transfer) {
|
||||||
|
try {
|
||||||
|
long size = source.length();
|
||||||
|
int ftfid = clientTransferId.getAndIncrement();
|
||||||
|
SingleCommand cmd = new SingleCommand("ftinitupload", ProtocolRole.CLIENT,
|
||||||
|
new CommandSingleParameter("clientftfid", Integer.toString(ftfid)),
|
||||||
|
new CommandSingleParameter("name", remoteFullPath),
|
||||||
|
new CommandSingleParameter("cid", Integer.toString(channelId)),
|
||||||
|
new CommandSingleParameter("cpw", channelPassword == null ? "" : channelPassword),
|
||||||
|
new CommandSingleParameter("size", Long.toString(size)),
|
||||||
|
new CommandSingleParameter("overwrite", overwrite ? "1" : "0"),
|
||||||
|
new CommandSingleParameter("resume", "0"),
|
||||||
|
new CommandSingleParameter("proto", "0"));
|
||||||
|
|
||||||
|
FileTransferParameters params = negotiate(ftfid, cmd);
|
||||||
|
// The server may already hold a prefix of the file (resume); start after it.
|
||||||
|
long seekpos = params.getMap().containsKey("seekpos") ? params.getLong("seekpos") : 0;
|
||||||
|
if (seekpos < 0 || seekpos > size) seekpos = 0;
|
||||||
|
|
||||||
|
try (Socket fileSocket = openFileSocket(params);
|
||||||
|
InputStream fileIn = new BufferedInputStream(new FileInputStream(source));
|
||||||
|
OutputStream out = new BufferedOutputStream(fileSocket.getOutputStream())) {
|
||||||
|
|
||||||
|
sendKey(fileSocket, params);
|
||||||
|
transfer.markActive();
|
||||||
|
|
||||||
|
if (seekpos > 0) {
|
||||||
|
skipFully(fileIn, seekpos);
|
||||||
|
transfer.addTransferred(seekpos);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] buffer = new byte[BUFFER_SIZE];
|
||||||
|
long remaining = size - seekpos;
|
||||||
|
while (remaining > 0) {
|
||||||
|
if (transfer.isCancelRequested()) {
|
||||||
|
transfer.markCancelled();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int want = (int) Math.min(buffer.length, remaining);
|
||||||
|
int read = fileIn.read(buffer, 0, want);
|
||||||
|
if (read < 0) break; // file shorter than announced; stop
|
||||||
|
out.write(buffer, 0, read);
|
||||||
|
remaining -= read;
|
||||||
|
transfer.addTransferred(read);
|
||||||
|
}
|
||||||
|
out.flush();
|
||||||
|
}
|
||||||
|
transfer.markCompleted();
|
||||||
|
} catch (Exception e) {
|
||||||
|
transfer.markFailed(rootMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an {@code ftinit*} command and returns the negotiated parameters. The server
|
||||||
|
* acknowledges the command itself, then reports the transfer's key/host/port
|
||||||
|
* asynchronously via a {@code notifystart*} event (or a {@code notifystatusfiletransfer}
|
||||||
|
* on refusal), which {@link #handleFileTransferEvent} feeds back through {@code pendingInits}.
|
||||||
|
*/
|
||||||
|
private FileTransferParameters negotiate(int clientTransferId, SingleCommand cmd) throws Exception {
|
||||||
|
CompletableFuture<Map<String, String>> future = new CompletableFuture<>();
|
||||||
|
pendingInits.put(clientTransferId, future);
|
||||||
|
try {
|
||||||
|
// Await the command-level acknowledgement; a hard error (e.g. bad path) throws here.
|
||||||
|
socket.executeCommand(cmd).complete();
|
||||||
|
Map<String, String> result;
|
||||||
|
try {
|
||||||
|
result = future.get(NEGOTIATE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
||||||
|
} catch (ExecutionException e) {
|
||||||
|
Throwable cause = e.getCause();
|
||||||
|
throw cause instanceof Exception ? (Exception) cause : e;
|
||||||
|
} catch (TimeoutException e) {
|
||||||
|
throw new IOException("Timed out waiting for the server to start the transfer");
|
||||||
|
}
|
||||||
|
FileTransferParameters params = new FileTransferParameters(result);
|
||||||
|
String key = params.getFileTransferKey();
|
||||||
|
if (key == null || key.isEmpty()) throw new IOException("Server refused the file transfer");
|
||||||
|
return params;
|
||||||
|
} finally {
|
||||||
|
pendingInits.remove(clientTransferId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes an incoming file-transfer notification to the negotiation that is waiting for
|
||||||
|
* it. {@code notifystartdownload}/{@code notifystartupload} carry the key, host and port;
|
||||||
|
* {@code notifystatusfiletransfer} signals a non-zero status (a refusal) before the
|
||||||
|
* transfer starts.
|
||||||
|
*/
|
||||||
|
private void handleFileTransferEvent(UnknownTeamspeakEvent e) {
|
||||||
|
String command = e.getCommand();
|
||||||
|
if (command == null) return;
|
||||||
|
Map<String, String> map = e.getMap();
|
||||||
|
Integer ftfid = tryParseInt(map.get("clientftfid"));
|
||||||
|
if (ftfid == null) return;
|
||||||
|
CompletableFuture<Map<String, String>> future = pendingInits.get(ftfid);
|
||||||
|
if (future == null) return;
|
||||||
|
|
||||||
|
if (command.equals("notifystartdownload") || command.equals("notifystartupload")) {
|
||||||
|
future.complete(map);
|
||||||
|
} else if (command.equals("notifystatusfiletransfer")) {
|
||||||
|
int status = (int) parseLong(map.get("status"));
|
||||||
|
if (status != 0) {
|
||||||
|
String msg = map.get("msg");
|
||||||
|
future.completeExceptionally(
|
||||||
|
new IOException(msg != null && !msg.isEmpty() ? msg : "File transfer failed (status " + status + ")"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Socket openFileSocket(FileTransferParameters params) throws IOException {
|
||||||
|
Socket socket = new Socket();
|
||||||
|
socket.connect(new InetSocketAddress(resolveHost(params.getFileServerHost()),
|
||||||
|
params.getFileServerPort()), CONNECT_TIMEOUT_MS);
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends the negotiated one-time key that authorises this connection to the file port. */
|
||||||
|
private static void sendKey(Socket fileSocket, FileTransferParameters params) throws IOException {
|
||||||
|
OutputStream out = fileSocket.getOutputStream();
|
||||||
|
out.write(params.getFileTransferKey().getBytes(StandardCharsets.US_ASCII));
|
||||||
|
out.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the host to reach the file server. The {@code ip} the server reports may be
|
||||||
|
* a comma-separated list, empty, or a wildcard; in those cases fall back to the host
|
||||||
|
* we are already connected to.
|
||||||
|
*/
|
||||||
|
private String resolveHost(String reported) {
|
||||||
|
if (reported != null) {
|
||||||
|
String first = reported.split(",")[0].trim();
|
||||||
|
if (!first.isEmpty() && !first.startsWith("0.0.0.0")) return first;
|
||||||
|
}
|
||||||
|
return fallbackHost.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void deleteQuietly(File file) {
|
||||||
|
if (file != null && file.isFile()) {
|
||||||
|
//noinspection ResultOfMethodCallIgnored
|
||||||
|
file.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void skipFully(InputStream in, long n) throws IOException {
|
||||||
|
long remaining = n;
|
||||||
|
while (remaining > 0) {
|
||||||
|
long skipped = in.skip(remaining);
|
||||||
|
if (skipped <= 0) {
|
||||||
|
if (in.read() < 0) throw new IOException("Unexpected end of file while seeking");
|
||||||
|
skipped = 1;
|
||||||
|
}
|
||||||
|
remaining -= skipped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void shutdown() {
|
||||||
|
socket.removeListener(ftEventListener);
|
||||||
|
for (CompletableFuture<Map<String, String>> future : pendingInits.values()) {
|
||||||
|
future.completeExceptionally(new IOException("Disconnected"));
|
||||||
|
}
|
||||||
|
pendingInits.clear();
|
||||||
|
transferPool.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private static String fileName(String fullPath) {
|
||||||
|
int slash = fullPath.lastIndexOf('/');
|
||||||
|
return slash < 0 ? fullPath : fullPath.substring(slash + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long parseLong(String s) {
|
||||||
|
if (s == null || s.isEmpty()) return 0;
|
||||||
|
try {
|
||||||
|
return Long.parseLong(s.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer tryParseInt(String s) {
|
||||||
|
if (s == null || s.isEmpty()) return null;
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(s.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.ts3client.net.filetransfer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry in a channel's file repository, as returned by {@code ftgetfilelist}.
|
||||||
|
* A UI-facing value type that keeps the reusable core free of ts3j API classes.
|
||||||
|
*/
|
||||||
|
public final class RemoteFile {
|
||||||
|
|
||||||
|
private final String name;
|
||||||
|
/** Parent directory path, always ending in {@code /} (e.g. {@code /} or {@code /sub/}). */
|
||||||
|
private final String parentPath;
|
||||||
|
private final long size;
|
||||||
|
private final boolean directory;
|
||||||
|
private final long lastModifiedEpochSec;
|
||||||
|
|
||||||
|
public RemoteFile(String name, String parentPath, long size, boolean directory, long lastModifiedEpochSec) {
|
||||||
|
this.name = name;
|
||||||
|
this.parentPath = parentPath == null || parentPath.isEmpty() ? "/" : parentPath;
|
||||||
|
this.size = size;
|
||||||
|
this.directory = directory;
|
||||||
|
this.lastModifiedEpochSec = lastModifiedEpochSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getParentPath() {
|
||||||
|
return parentPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full server path including the file name, e.g. {@code /sub/clip.ogg}. */
|
||||||
|
public String getFullPath() {
|
||||||
|
String base = parentPath.endsWith("/") ? parentPath : parentPath + "/";
|
||||||
|
return base + name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getSize() {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDirectory() {
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getLastModifiedEpochSec() {
|
||||||
|
return lastModifiedEpochSec;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
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
|
@Override
|
||||||
public boolean isClientLocallyMuted(int clientId) {
|
public boolean isClientLocallyMuted(int clientId) {
|
||||||
return conn.getPlayback() != null && conn.getPlayback().isClientMuted(clientId);
|
return conn.getPlayback() != null && conn.getPlayback().isClientMuted(clientId);
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ public final class ServerTreePanel extends JScrollPane {
|
|||||||
|
|
||||||
void showConnectionInfo(ClientEntry client);
|
void showConnectionInfo(ClientEntry client);
|
||||||
|
|
||||||
|
/** Open the file repository browser for a channel. */
|
||||||
|
void browseFiles(ChannelNode channel);
|
||||||
|
|
||||||
boolean isClientLocallyMuted(int clientId);
|
boolean isClientLocallyMuted(int clientId);
|
||||||
|
|
||||||
/** A channel or client node was selected (or {@code null} when cleared). */
|
/** 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");
|
JMenuItem join = new JMenuItem("Join channel");
|
||||||
join.addActionListener(a -> actions.joinChannel(channel.id));
|
join.addActionListener(a -> actions.joinChannel(channel.id));
|
||||||
menu.add(join);
|
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());
|
menu.show(tree, e.getX(), e.getY());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user