Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions flottform/forms/src/flottform-channel-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export class FlottformChannelClient extends EventEmitter<Listeners> {
this.rtcConfiguration = DEFAULT_WEBRTC_CONFIG;
this.pollTimeForIceInMs = pollTimeForIceInMs;
this.logger = logger;
Promise.resolve().then(() => {
this.changeState('init');
});
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
263 changes: 207 additions & 56 deletions flottform/forms/src/flottform-file-input-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,21 @@ import { FlottformChannelClient } from './flottform-channel-client';
import { EventEmitter, Logger, POLL_TIME_IN_MS } from './internal';

type Listeners = {
init: [];
connected: [];
'webrtc:connection-impossible': [];
sending: []; // Emitted to signal the start of sending the file(s)
progress: [{ fileIndex: number; fileName: string; progress: number }]; // Emitted to signal the progress of sending the file(s)
done: [];
'file-sending-progress': [
{ fileIndex: number; totalFileCount: number; fileName: string; currentFileProgress: number }
];
'single-file-transfered': [{ name: string; type: string; size: number }];
'file-receiving-progress': {
fileIndex: number;
totalFileCount: number;
fileName: string;
currentFileProgress: number;
overallProgress: number;
}[];
'single-file-received': [file: File];
disconnected: [];
error: [e: string];
};
Expand All @@ -17,26 +27,52 @@ type MetaData = {
totalSize: number;
};

type FileSendState = {
metaData: { name: string; type: string; size: number }[];
arrayBuffers: ArrayBuffer[];
currentFileIndex: number;
currentChunkIndex: number;
};

type FileReceiveState = {
metaData: { name: string; type: string; size: number }[];
currentFile: { index: number; receivedSize: number; arrayBuffer: ArrayBuffer[] } | null;
totalSize: number;
receivedSize: number;
};

export class FlottformFileInputClient extends EventEmitter<Listeners> {
private channel: FlottformChannelClient | null = null;
private inputField: HTMLInputElement;
private outgoingInputField?: HTMLInputElement; // Contains files to send to the other peer.
private incomingInputField?: HTMLInputElement; // Contains files received from the other peer.
// Sending files state (Client -> Host)
private sendState: FileSendState = {
metaData: [],
arrayBuffers: [],
currentFileIndex: 0,
currentChunkIndex: 0
};
// Receiving files state (Host -> Client)
private receiveState: FileReceiveState = {
metaData: [],
currentFile: null,
receivedSize: 0,
totalSize: 0
};
private chunkSize: number = 16384; // 16 KB chunks
private filesMetaData: { name: string; type: string; size: number }[] = [];
private filesArrayBuffer: ArrayBuffer[] = [];
private currentFileIndex = 0;
private currentChunkIndex = 0;
private allFilesSent = false;
private logger: Logger;

constructor({
endpointId,
fileInput,
outgoingInputField,
incomingInputField,
flottformApi,
pollTimeForIceInMs = POLL_TIME_IN_MS,
logger = console
}: {
endpointId: string;
fileInput: HTMLInputElement;
outgoingInputField?: HTMLInputElement;
incomingInputField?: HTMLInputElement;
flottformApi: string;
pollTimeForIceInMs?: number;
logger?: Logger;
Expand All @@ -48,7 +84,8 @@ export class FlottformFileInputClient extends EventEmitter<Listeners> {
pollTimeForIceInMs,
logger
});
this.inputField = fileInput;
this.outgoingInputField = outgoingInputField;
this.incomingInputField = incomingInputField;
this.logger = logger;
this.registerListeners();
}
Expand All @@ -60,6 +97,14 @@ export class FlottformFileInputClient extends EventEmitter<Listeners> {
this.channel?.close();
};

setIncomingInputField = (incomingInputField: HTMLInputElement) => {
this.incomingInputField = incomingInputField;
};

setOutgoingInputField = (outgoingInputField: HTMLInputElement) => {
this.outgoingInputField = outgoingInputField;
};

private createMetaData = (inputElement: HTMLInputElement): MetaData | null => {
if (!inputElement.files) return null;

Expand All @@ -86,17 +131,19 @@ export class FlottformFileInputClient extends EventEmitter<Listeners> {
};

sendFiles = async () => {
const metaData = this.createMetaData(this.inputField);
const filesArrayBuffer = await this.createArrayBuffers(this.inputField);
if (!this.outgoingInputField) {
throw new Error('Input Field containing the files to send is not provided!');
}
const metaData = this.createMetaData(this.outgoingInputField);
const filesArrayBuffer = await this.createArrayBuffers(this.outgoingInputField);
if (!metaData || !filesArrayBuffer)
throw new Error("Can't find the files that you want to send!");

this.filesMetaData = metaData.filesQueue;
this.filesArrayBuffer = filesArrayBuffer;
this.sendState.metaData = metaData.filesQueue;
this.sendState.arrayBuffers = filesArrayBuffer;

this.channel?.sendData(JSON.stringify(metaData));

this.emit('sending');
this.startSendingFiles();
};

Expand All @@ -105,88 +152,192 @@ export class FlottformFileInputClient extends EventEmitter<Listeners> {
};

private sendNextChunk = async () => {
const totalNumberOfFiles = this.filesMetaData.length;
if (this.allFilesSent || this.currentFileIndex >= totalNumberOfFiles) {
// All files are sent
const totalNumberOfFiles = this.sendState.metaData.length;
if (this.sendState.currentFileIndex >= totalNumberOfFiles) {
this.logger.log('All files are sent');
this.channel?.sendData(JSON.stringify({ type: 'transfer-complete' }));
this.allFilesSent = true;
this.channel?.off('bufferedamountlow', this.startSendingFiles);
this.emit('done');
this.channel?.sendData(JSON.stringify({ type: 'files-batch-transfer-complete' }));
this.sendState = {
metaData: [],
arrayBuffers: [],
currentFileIndex: 0,
currentChunkIndex: 0
};
return;
}
const currentFileArrayBuffer = this.filesArrayBuffer[this.currentFileIndex];
const currentFileArrayBuffer = this.sendState.arrayBuffers[this.sendState.currentFileIndex];
if (!currentFileArrayBuffer) {
throw new Error(`Can't find the ArrayBuffer for the file number ${this.currentFileIndex}`);
throw new Error(
`Can't find the ArrayBuffer for the file number ${this.sendState.currentFileIndex}`
);
}
const currentFileSize = currentFileArrayBuffer.byteLength;
const fileName = this.filesMetaData[this.currentFileIndex]!.name;
const fileName = this.sendState.metaData[this.sendState.currentFileIndex]!.name;

while (this.currentChunkIndex * this.chunkSize < currentFileSize) {
while (this.sendState.currentChunkIndex * this.chunkSize < currentFileSize) {
// The file still has some chunks left
if (!this.channel?.canSendMoreData()) {
// Buffer is full. Pause sending the chunks
this.logger.log('Buffer is full. Pausing sending chunks!');
break;
}
const progress = ((this.currentChunkIndex * this.chunkSize) / currentFileSize).toFixed(2);
const progress = (
(this.sendState.currentChunkIndex * this.chunkSize) /
currentFileSize
).toFixed(2);

this.emit('progress', {
fileIndex: this.currentFileIndex,
this.emit('file-sending-progress', {
fileIndex: this.sendState.currentFileIndex,
fileName,
progress: parseFloat(progress)
currentFileProgress: parseFloat(progress),
totalFileCount: this.sendState.arrayBuffers.length
});

const start = this.currentChunkIndex * this.chunkSize;
const end = Math.min((this.currentChunkIndex + 1) * this.chunkSize, currentFileSize);
const start = this.sendState.currentChunkIndex * this.chunkSize;
const end = Math.min(
(this.sendState.currentChunkIndex + 1) * this.chunkSize,
currentFileSize
);
this.channel?.sendData(currentFileArrayBuffer.slice(start, end));
this.currentChunkIndex++;
this.sendState.currentChunkIndex++;
}
// Now either: the buffer is full OR all the chunks of the file have been sent.
if (this.currentChunkIndex * this.chunkSize >= currentFileSize) {
if (this.sendState.currentChunkIndex * this.chunkSize >= currentFileSize) {
// File is fully sent move to the next one
this.logger.log(`File ${fileName} fully sent. Moving to next file.`);
this.currentFileIndex++;
this.currentChunkIndex = 0;
const fileTransferedMetaData = this.sendState.metaData[this.sendState.currentFileIndex];
this.emit('single-file-transfered', fileTransferedMetaData!);
this.sendState.currentFileIndex++;
this.sendState.currentChunkIndex = 0;
this.sendNextChunk(); // Recursion used to send the chunks of the next file
} else {
// Paused waiting for buffer space. Will try again shortly.
setTimeout(this.sendNextChunk, 100); // Retry after a short delay
}
};

private handleIncomingData = (e: MessageEvent) => {
if (typeof e.data === 'string') {
// string can be either metadata or end transfer marker.
const message = JSON.parse(e.data);
if (message.type === 'file-transfer-meta') {
// Handle file metadata
this.receiveState.metaData = message.filesQueue;
this.receiveState.currentFile = { index: 0, receivedSize: 0, arrayBuffer: [] };
this.receiveState.totalSize = message.totalSize;
} else if (message.type === 'files-batch-transfer-complete') {
this.logger.log('Current batch of files received successfully!');
this.receiveState = {
metaData: [],
currentFile: null,
receivedSize: 0,
totalSize: 0
};
}
} else if (e.data instanceof ArrayBuffer) {
// Handle file chunk
if (this.receiveState.currentFile) {
this.receiveState.currentFile.arrayBuffer.push(e.data);
this.receiveState.currentFile.receivedSize += e.data.byteLength;
this.receiveState.receivedSize += e.data.byteLength;

const currentFileName = this.receiveState.metaData[this.receiveState.currentFile.index]
?.name as string;

const currentFileTotalSize = this.receiveState.metaData[this.receiveState.currentFile.index]
?.size as number;

const currentFileProgress = (
this.receiveState.currentFile.receivedSize / currentFileTotalSize
).toFixed(2);
const overallProgress = (
this.receiveState.receivedSize / this.receiveState.totalSize
).toFixed(2);

this.emit('file-receiving-progress', {
fileIndex: this.receiveState.currentFile.index,
totalFileCount: this.receiveState.metaData.length,
fileName: currentFileName,
currentFileProgress: parseFloat(currentFileProgress),
overallProgress: parseFloat(overallProgress)
});

if (this.receiveState.currentFile.receivedSize === currentFileTotalSize) {
// Attach the current file to the given input field
this.appendFileToInputField(this.receiveState.currentFile.index);
// Initialize the values of receiveState.currentFile to receive the next file
this.receiveState.currentFile = {
index: this.receiveState.currentFile.index + 1,
receivedSize: 0,
arrayBuffer: []
};
}
}
}
};

private appendFileToInputField = (fileIndex: number) => {
const fileName = this.receiveState.metaData[fileIndex]?.name ?? 'no-name';
const fileType = this.receiveState.metaData[fileIndex]?.type ?? 'application/octet-stream';

const receivedFile = new File(
this.receiveState.currentFile?.arrayBuffer as ArrayBuffer[],
fileName,
{
type: fileType
}
);
this.emit('single-file-received', receivedFile);

if (!this.incomingInputField) {
this.logger.warn(
"No input field provided!! You can listen to the 'single-file-transferred' event to handle the newly received file!"
);
return;
}

const dt = new DataTransfer();

// Add existing files from the input field to the DataTransfer object to avoid loosing them.
if (this.incomingInputField.files) {
for (const file of Array.from(this.incomingInputField.files)) {
dt.items.add(file);
}
}

if (!this.incomingInputField.multiple) {
this.logger.warn(
"The Host's input field only supports one file. Incoming files from the Host will overwrite any existing file, and only the last file received will remain attached."
);
dt.items.clear();
}

dt.items.add(receivedFile);
this.incomingInputField.files = dt.files;
};

private registerListeners = () => {
this.channel?.on('init', () => {
// TODO: Implement Default UI.
});
this.channel?.on('retrieving-info-from-endpoint', () => {
// TODO: Implement Default UI.
});
this.channel?.on('sending-client-info', () => {
// TODO: Implement Default UI.
});
this.channel?.on('connecting-to-host', () => {
// TODO: Implement Default UI.
this.emit('init');
});
this.channel?.on('retrieving-info-from-endpoint', () => {});
this.channel?.on('sending-client-info', () => {});
this.channel?.on('connecting-to-host', () => {});
this.channel?.on('connected', () => {
this.emit('connected');
// TODO: Implement Default UI.
});
this.channel?.on('receiving-data', (e) => {
//Handle file(s) reception
this.handleIncomingData(e);
});
this.channel?.on('connection-impossible', () => {
this.emit('webrtc:connection-impossible');
// TODO: Implement Default UI.
});
this.channel?.on('done', () => {
this.emit('done');
// TODO: Implement Default UI.
});
this.channel?.on('done', () => {});
this.channel?.on('disconnected', () => {
this.emit('disconnected');
// TODO: Implement Default UI.
});
this.channel?.on('error', (e) => {
this.emit('error', e);
// TODO: Implement Default UI.
});
this.channel?.on('bufferedamountlow', this.startSendingFiles);
};
Expand Down
Loading