@samluvanda/uploadx
Version:
Modern, lightweight, and extensible JavaScript file uploader supporting chunked uploads, filtering, retry logic, progress tracking, and event hooks.
987 lines (851 loc) • 28 kB
JavaScript
import { File } from './core/File.js';
import { QueueProgress } from './core/QueueProgress.js';
export class Uploader {
static ERROR_CODES = {
GENERIC: 1000,
HTTP: 2000,
IO: 3000,
SECURITY: 4000,
INIT: 5000,
CONFIG: 5100,
FILE_SIZE: 6000,
FILE_EXTENSION: 7000,
FILE_DUPLICATE: 8000,
MEMORY: 9000,
};
static STATES = {
STARTED: 'started',
STOPPED: 'stopped',
UPLOADING: 'uploading',
QUEUED: 'queued',
FAILED: 'failed',
DONE: 'done'
};
#defaults = {
browse_button: null,
url: '',
filters: {
mime_types: [],
max_file_size: 0,
prevent_duplicates: false,
},
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
},
max_retries: 0,
chunk_size: 0,
multi_selection: true,
unique_names: false,
file_data_name: 'file',
container: null,
http_method: 'POST',
send_chunk_number: true,
send_file_name: true,
};
#events = {};
#destroyed = false;
#fileInput = null;
#maxFileSize = 0;
#browseButtonEl = null;
#chunkSize = 0;
#containerEl = null;
#extensions = [];
#browseClickHandler = null;
/**
* Constructs the Uploader instance with provided settings.
* @param {Object} options - Uploader configuration
*/
constructor(options = {}) {
this.options = structuredClone(this.#defaults);
this.state = Uploader.STATES.STOPPED;
this.files = [];
this.total = new QueueProgress();
this.startTime = null;
this.disabled = false;
this.xhr = null;
this.bindEventListeners();
this.setOption(options);
}
/**
* Initializes the file input element and binds UI interactions.
* - Creates and configures a hidden <input type="file">.
* - Applies allowed file extensions and multi-select settings.
* - Binds click handler to the browse button to trigger file input.
* - Binds change handler to add selected files to the queue.
* @private
*/
#init() {
if (this.options === null) return;
// Remove file input if present
this.#removeFileInput();
// Unbind previous click handler if it exists
if (this.#browseClickHandler) {
this.#browseButtonEl.removeEventListener('click', this.#browseClickHandler);
}
// Create file input
this.#fileInput = document.createElement('input');
this.#fileInput.type = 'file';
this.#fileInput.style.display = 'none';
// Apply file extension filters
if (this.#extensions.length) {
this.#fileInput.accept = this.#extensions.map(ext => '.' + ext).join(',');
}
// Multi-selection
this.#fileInput.multiple = !!this.options.multi_selection;
// Append to container or after browse button
if (this.#containerEl) {
this.#containerEl.appendChild(this.#fileInput);
} else {
this.#browseButtonEl.parentNode.insertBefore(this.#fileInput, this.#browseButtonEl.nextSibling);
}
// Define and bind new click handler
this.#browseClickHandler = () => {
if (!this.disabled) {
this.#fileInput.click();
}
};
this.#browseButtonEl.addEventListener('click', this.#browseClickHandler);
// On file selected
this.#fileInput.addEventListener('change', (e) => {
this.addFile(e.target);
this.#fileInput.value = ''; // Reset for next selection
});
}
/**
* Register an event listener.
* @param {string} name - Event name
* @param {Function} fn - Callback function
* @param {Object} [scope] - Scope to bind
* @param {number} [priority=0] - Not used yet
*/
bind(name, fn, scope = null, priority = 0) {
if (!this.#events[name]) this.#events[name] = [];
this.#events[name].push({ fn, scope, priority });
}
/**
* Unregister a specific event listener.
* @param {string} name - Event name
* @param {Function} fn - Callback to remove
*/
unbind(name, fn) {
if (!this.#events[name]) return;
this.#events[name] = this.#events[name].filter(e => e.fn !== fn);
}
/**
* Removes all event listeners for all events.
*/
unbindAll() {
this.#events = {};
}
/**
* Dispatches the specified event name and its arguments to all listeners.
*
* @param {string} name - Event name to trigger
* @param {...any} args - Arguments to pass to listener functions
*/
trigger(name, ...args) {
if (!this.#events[name]) return;
for (const { fn, scope } of this.#events[name]) {
const result = fn.apply(scope || this, args);
if (result === false) return false; // allow early cancel
}
return true; // default to true
}
/**
* Update uploader options
* @param {string|Object} option - Option key or object map
* @param {*} [value] - Option value
*/
setOption(option, value) {
if (typeof option === 'string') {
this.options[option] = value;
} else if (typeof option === 'object') {
this.options = { ...this.options, ...option };
}
this.#validateOptions();
}
/**
* Get the value of a specific option or all
* @param {string} [option] - Option name
* @returns {*} Value or full options
*/
getOption(option) {
return option ? this.options[option] : this.options;
}
/**
* Validates all settings and triggers an Error event if misconfigured.
*/
#validateOptions() {
const {
browse_button,
url,
filters,
headers,
max_retries,
chunk_size,
multi_selection,
unique_names,
file_data_name,
container,
http_method,
send_chunk_number,
send_file_name
} = this.options;
// Validate browse_button
let browseEl = null;
if (typeof browse_button === 'string') {
browseEl = document.getElementById(browse_button);
} else if (browse_button instanceof HTMLElement) {
browseEl = browse_button;
}
if (!browseEl) {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.CONFIG,
message: 'Invalid or missing "browse_button" element.'
});
return;
}
this.#browseButtonEl = browseEl;
// Validate URL
if (typeof url !== 'string' || url.trim() === '') {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.CONFIG,
message: 'Required setting "url" must be a non-empty string.'
});
return;
}
// Validate filters
if (typeof filters !== 'object' || filters === null || Array.isArray(filters)) {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.CONFIG,
message: '"filters" must be a plain object.'
});
} else {
// Validate max_file_size
if ('max_file_size' in filters) {
const parsedMax = this.#parseSize(filters.max_file_size);
if (isNaN(parsedMax) || parsedMax < 0) {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.CONFIG,
message: '"filters.max_file_size" must be a valid size string or number.'
});
} else {
this.#maxFileSize = parsedMax;
}
}
// Validate mime_types
if ('mime_types' in filters) {
if (!Array.isArray(filters.mime_types)) {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.CONFIG,
message: '"filters.mime_types" must be an array.'
});
} else {
const allExtensions = new Set();
for (const [index, filter] of filters.mime_types.entries()) {
if (
typeof filter !== 'object' ||
typeof filter.title !== 'string' ||
typeof filter.extensions !== 'string'
) {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.CONFIG,
message: `mime_types[${index}] must contain "title" (string) and "extensions" (comma-separated string).`
});
break;
}
const extensions = filter.extensions.split(',').map(e => e.trim().toLowerCase());
if (!extensions.length) {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.CONFIG,
message: `mime_types[${index}] contains no valid extensions.`
});
break;
}
extensions.forEach(ext => allExtensions.add(ext));
}
this.#extensions = Array.from(allExtensions);
}
}
// Validate prevent_duplicates
if ('prevent_duplicates' in filters && typeof filters.prevent_duplicates !== 'boolean') {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.CONFIG,
message: '"filters.prevent_duplicates" must be a boolean.'
});
}
}
// Validate headers
if (typeof headers !== 'object' || Array.isArray(headers)) {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.CONFIG,
message: '"headers" must be a key-value object.'
});
}
// Validate chunk_size
const parsedChunkSize = this.#parseSize(chunk_size);
if (isNaN(parsedChunkSize) || parsedChunkSize < 0) {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.CONFIG,
message: '"chunk_size" must be a non-negative number or size string like "200kb", "1mb", etc.'
});
} else {
this.#chunkSize = parsedChunkSize;
}
// Validate numeric options
if (typeof max_retries !== 'number' || max_retries < 0) {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.CONFIG,
message: '"max_retries" must be a non-negative number.'
});
}
// Validate boolean options
const booleanFields = [
{ key: 'multi_selection', value: multi_selection },
{ key: 'unique_names', value: unique_names },
{ key: 'send_chunk_number', value: send_chunk_number },
{ key: 'send_file_name', value: send_file_name }
];
booleanFields.forEach(({ key, value }) => {
if (typeof value !== 'boolean') {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.CONFIG,
message: `"${key}" must be a boolean.`
});
}
});
// Validate file_data_name
if (typeof file_data_name !== 'string' || file_data_name.trim() === '') {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.CONFIG,
message: '"file_data_name" must be a non-empty string.'
});
}
// Validate container (if provided)
if (container !== null) {
let containerEl = null;
if (typeof container === 'string') {
containerEl = document.getElementById(container);
} else if (container instanceof HTMLElement) {
containerEl = container;
}
if (!containerEl) {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.CONFIG,
message: 'Invalid "container" element.'
});
} else {
this.#containerEl = containerEl;
}
}
// Validate http_method
const validHttpMethods = ['POST', 'PUT'];
if (!validHttpMethods.includes(http_method.toUpperCase())) {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.CONFIG,
message: `"http_method" must be one of: ${validHttpMethods.join(', ')}.`
});
}
this.#init();
}
/**
* Parses human-readable size string to bytes.
* @param {string|number} size
* @returns {number} Size in bytes
*/
#parseSize(size) {
if (typeof size === 'number') return size;
if (typeof size === 'string') {
const match = size.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)(b|kb|mb|gb|tb)?$/);
if (!match) return NaN;
const [, value, unit = 'b'] = match;
const multipliers = {
b: 1,
kb: 1024,
mb: 1024 ** 2,
gb: 1024 ** 3,
tb: 1024 ** 4,
};
return parseFloat(value) * multipliers[unit];
}
return NaN;
}
/**
* Starts the upload process.
*/
start() {
if (this.state !== Uploader.STATES.STARTED) {
this.state = Uploader.STATES.STARTED;
this.trigger('StateChanged', this);
this.#uploadNext();
}
}
/**
* Stops the upload process.
*/
stop() {
if (this.state !== Uploader.STATES.STOPPED) {
this.state = Uploader.STATES.STOPPED;
this.trigger('StateChanged', this);
this.trigger('CancelUpload', this);
}
}
/**
* Disables or enables the browse (file input) button.
* @param {boolean} disable - Whether to disable (true) or enable (false)
*/
disableBrowse(disable = true) {
this.disabled = disable;
if (this.#fileInput) {
this.#fileInput.disabled = disable;
}
this.trigger('DisableBrowse', this, disable);
}
/**
* Public method to destroy the uploader instance.
*/
destroy() {
this.trigger('Destroy', this);
this.unbindAll();
this.#destroyed = true;
}
/**
* Checks if the instance has been destroyed.
* @returns {boolean}
*/
isDestroyed() {
return this.#destroyed;
}
/**
* Returns a file from the queue by ID.
* @param {string} id - The file ID.
* @returns {File|undefined}
*/
getFile(id) {
return this.files.find(file => file.id === id);
}
/**
* Adds a file (or multiple files) to the queue.
* @param {File|Blob|File[]|Blob[]|HTMLInputElement} input - Single or multiple files or an input element.
* @param {string} [fileName] - Optional override name for a single file.
*/
addFile(input, fileName) {
const inputType = Object.prototype.toString.call(input);
const handleSingleFile = (nativeFile) => {
const file = new File(nativeFile);
if (fileName) file.name = fileName;
if (this.options.filters.prevent_duplicates) {
const duplicate = this.files.some(f => f.name === file.name && f.size === file.size);
if (duplicate) {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.FILE_DUPLICATE,
message: 'Duplicate file detected.',
file: file,
});
return;
}
}
if (this.#extensions.length) {
const fileExt = file.name?.split('.').pop().toLowerCase();
if (!fileExt || !this.#extensions.includes(fileExt)) {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.FILE_EXTENSION,
message: 'Invalid file extension.',
file: file,
});
return;
}
}
if (this.#maxFileSize && file.size > this.#maxFileSize) {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.FILE_SIZE,
message: 'File size exceeds limit.',
file: file,
});
return;
}
this.files.push(file);
this.trigger('FileFiltered', this, file);
this.trigger('FilesAdded', this, [file]);
};
if (inputType === '[object File]' || inputType === '[object Blob]') {
handleSingleFile(input);
} else if (Array.isArray(input)) {
input.forEach(file => handleSingleFile(file));
} else if (input instanceof HTMLInputElement && input.files?.length) {
[...input.files].forEach(file => handleSingleFile(file));
} else {
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.CONFIG,
message: 'Unsupported input type for addFile().'
});
}
}
/**
* Removes a file from the queue by ID or object reference.
* Triggers FilesRemoved.
* @param {File|string} file - File object or ID
* @returns {File|undefined}
*/
removeFile(file) {
const id = typeof file === 'string' ? file : file.id;
const index = this.files.findIndex(f => f.id === id);
if (index !== -1) {
const [removed] = this.files.splice(index, 1);
removed.destroy();
this.trigger('FilesRemoved', this, [removed]);
return removed;
}
}
/**
* Bind core uploader event listeners.
*/
bindEventListeners() {
this.bind('FilesAdded', () => {
this.trigger('QueueChanged', this);
});
this.bind('FilesRemoved', () => {
this.trigger('QueueChanged', this);
});
this.bind('CancelUpload', this.#onCancelUpload.bind(this));
this.bind('BeforeUpload', this.#onBeforeUpload.bind(this));
this.bind('UploadFile', this.#onUploadFile.bind(this));
this.bind('UploadProgress', this.#onUploadProgress.bind(this));
this.bind('StateChanged', this.#onStateChanged.bind(this));
this.bind('QueueChanged', this.#calc.bind(this));
this.bind('Error', this.#onError.bind(this));
this.bind('FileUploaded', this.#onFileUploaded.bind(this));
this.bind('Destroy', this.#onDestroy.bind(this));
}
/**
* Aborts any ongoing XHR upload request.
* Triggered when user stops the upload or an error requires cancellation.
*/
#onCancelUpload() {
// If an active upload request exists, abort it
if (this.xhr) {
this.xhr.abort();
this.xhr = null; // Clear the reference
}
}
/**
* Prepares the file for upload by assigning a unique target name if required.
* Triggered by the 'BeforeUpload' event.
*/
#onBeforeUpload(uploader, file) {
if (this.options.unique_names) {
const match = file.name.match(/\.([^.]+)$/);
const ext = match ? match[1] : 'part';
file.target_name = `${file.id}.${ext}`;
}
}
/**
* Handles the actual upload of a file, including chunked upload logic and retries.
* Triggered by the 'UploadFile' event.
*/
#onUploadFile(uploader, file) {
const url = this.options.url;
const chunkSize = this.#chunkSize;
let retries = this.options.max_retries;
let offset = 0;
let blob = file.getSource();
let xhr;
if (file.loaded) {
offset = file.loaded = chunkSize ? chunkSize * Math.floor(file.loaded / chunkSize) : 0;
}
const handleError = () => {
if (retries-- > 0) {
setTimeout(uploadNextChunk, 1000);
} else {
file.loaded = offset;
this.trigger('Error', this, {
code: Uploader.ERROR_CODES.HTTP,
message: 'HTTP Error.',
file: file,
response: xhr?.responseText,
status: xhr?.status,
responseHeaders: xhr?.getAllResponseHeaders?.()
});
}
};
const uploadNextChunk = () => {
if (file.status !== Uploader.STATES.UPLOADING || this.state === Uploader.STATES.STOPPED) return;
let chunkBlob;
let curChunkSize;
const args = {};
if (this.options.send_file_name) {
args.name = file.target_name || file.name;
}
if (chunkSize && blob.size > chunkSize) {
curChunkSize = Math.min(chunkSize, blob.size - offset);
chunkBlob = blob.slice(offset, offset + curChunkSize);
} else {
curChunkSize = blob.size;
chunkBlob = blob;
}
if (chunkSize) {
if (this.options.send_chunk_number) {
args.chunk = Math.ceil(offset / chunkSize);
args.chunks = Math.ceil(blob.size / chunkSize);
} else {
args.offset = offset;
args.total = blob.size;
}
}
if (this.trigger('BeforeChunkUpload', this, file, args, chunkBlob, offset) !== false) {
uploadChunk(args, chunkBlob, curChunkSize);
}
};
const uploadChunk = (args, chunkBlob, curChunkSize) => {
xhr = new XMLHttpRequest();
this.xhr = xhr;
if (xhr.upload) {
xhr.upload.onprogress = (e) => {
file.loaded = Math.min(file.size, offset + e.loaded);
this.trigger('UploadProgress', this, file);
};
}
xhr.onload = () => {
if (xhr.status < 200 || xhr.status >= 400) {
handleError();
return;
}
retries = this.options.max_retries;
if (curChunkSize < blob.size) {
offset += curChunkSize;
file.loaded = Math.min(offset, blob.size);
this.trigger('ChunkUploaded', this, file, {
offset: file.loaded,
total: blob.size,
response: xhr.responseText,
status: xhr.status,
responseHeaders: xhr.getAllResponseHeaders()
});
if (navigator.userAgent.includes('Android')) {
this.trigger('UploadProgress', this, file);
}
} else {
file.loaded = file.size;
}
chunkBlob = null;
if (!offset || offset >= blob.size) {
if (file.size !== file.origSize) {
blob = null;
}
this.trigger('UploadProgress', this, file);
file.status = Uploader.STATES.DONE;
file.completeTimestamp = Date.now();
this.trigger('FileUploaded', this, file, {
response: xhr.responseText,
status: xhr.status,
responseHeaders: xhr.getAllResponseHeaders()
});
} else {
setTimeout(uploadNextChunk, 1);
}
};
xhr.onerror = handleError;
xhr.onloadend = function () {
this.destroy?.();
};
xhr.open(this.options.http_method, url, true);
Object.entries(this.options.headers).forEach(([name, value]) => {
xhr.setRequestHeader(name, value);
});
const formData = new FormData();
for (const [key, value] of Object.entries(args)) {
formData.append(key, value);
}
formData.append(this.options.file_data_name, chunkBlob);
xhr.send(formData);
};
uploadNextChunk();
}
#calcFile(file) {
// Calculate upload progress percentage for the file
file.percent = file.size > 0 ? Math.ceil((file.loaded / file.size) * 100) : 100;
// Recalculate total queue progress
this.#calc();
}
/**
* Updates progress state and recalculates file and total progress.
* Triggered by the 'UploadProgress' event.
*/
#onUploadProgress(uploader, file) {
this.#calcFile(file);
}
/**
* Finds the next queued file and starts its upload.
* If all files are DONE or FAILED, stops the uploader and triggers completion.
* @private
*/
#uploadNext() {
if (this.state !== Uploader.STATES.STARTED) return;
let fileToUpload = null;
let processedCount = 0;
for (const file of this.files) {
if (!fileToUpload && file.status === Uploader.STATES.QUEUED) {
// Allow BeforeUpload to cancel upload
const proceed = this.trigger('BeforeUpload', this, file);
if (proceed === false) {
file.status = Uploader.STATES.FAILED;
continue;
}
file.status = Uploader.STATES.UPLOADING;
file.startTimestamp = Date.now();
fileToUpload = file;
this.trigger('UploadFile', this, file);
} else {
processedCount++;
}
}
// If all files are either DONE or FAILED
if (processedCount === this.files.length && !fileToUpload) {
this.state = Uploader.STATES.STOPPED;
this.trigger('StateChanged', this);
this.trigger('UploadComplete', this, this.files);
}
}
/**
* Handles changes in uploader state (STARTED or STOPPED).
* When started, it sets a timestamp to track upload speed.
* When stopped, it resets the status of files that were uploading.
* This helps to resume incomplete uploads later.
*/
#onStateChanged() {
if (this.state === Uploader.STATES.STARTED) {
// Set the start time to now, used for calculating bytes/sec
this.startTime = Date.now();
} else if (this.state === Uploader.STATES.STOPPED) {
// Loop through all files in the queue
for (let i = this.files.length - 1; i >= 0; i--) {
const file = this.files[i];
// If the file was uploading when stopped, mark it as queued
if (file.status === Uploader.STATES.UPLOADING) {
file.status = Uploader.STATES.QUEUED;
// Recalculate progress
this.#calc();
}
}
}
}
/**
* Logs errors, marks files as failed, and triggers recovery or cancel logic.
* Triggered by the 'Error' event.
*/
#onError(uploader, err) {
const log = {
code: err.code,
message: err.message,
...(err.file && { file: err.file.name }),
...(err.status && { status: err.status }),
...(err.response && { response: err.response }),
...(err.responseHeaders && { headers: err.responseHeaders }),
};
console.error('[Uploader Error]', log);
if (err.code === Uploader.ERROR_CODES.INIT || err.code === Uploader.ERROR_CODES.CONFIG) {
// For config/init issues, destroy uploader instance
this.destroy();
return;
}
if (err.code === Uploader.ERROR_CODES.HTTP && err.file) {
// Mark file as failed
err.file.status = Uploader.STATES.FAILED;
err.file.completeTimestamp = Date.now();
// Recalculate progress for the failed file
this.#calcFile(err.file);
// Cancel current upload (abort xhr if any)
if (this.state === Uploader.STATES.STARTED) {
this.trigger('CancelUpload', this);
// Resume the next upload in the queue after a short delay
setTimeout(() => {
this.#uploadNext();
}, 1);
}
}
}
/**
* Marks file as uploaded, recalculates progress, and initiates next upload.
* Triggered by the 'FileUploaded' event.
*/
#onFileUploaded() {
this.#calc(); // Recalculate total progress
// Delay next file upload to allow custom listeners to react
setTimeout(() => {
this.#uploadNext(); // Start uploading the next file in queue
}, 1);
}
/**
* Removes the hidden file input element if it exists.
* - Detaches the <input type="file"> element from the DOM.
* - Clears the internal reference to allow clean reinitialization.
* - Prevents memory leaks and dangling listeners.
* @private
*/
#removeFileInput() {
if (this.#fileInput) {
this.#fileInput.remove();
this.#fileInput = null;
}
}
/**
* Internal cleanup handler for 'Destroy' event.
*/
#onDestroy() {
this.stop();
// Clean file queue
this.files.forEach(file => file.destroy?.());
this.files = [];
// Remove file input if present
this.#removeFileInput();
// Reset progress
this.total.reset();
// Nullify options reference
this.options = null;
}
/**
* Calculates the overall upload progress for all files in the queue.
* Updates this.total with cumulative metrics.
*/
#calc() {
let loadedDuringSession = 0;
this.total.reset(); // Reset previous stats
for (const file of this.files) {
if (typeof file.size !== 'undefined') {
// Accumulate total expected size
this.total.size += file.origSize ?? file.size;
// Estimate proportionally if resized/compressed
const origSize = file.origSize ?? file.size;
const loaded = (file.loaded * origSize) / file.size;
if (!file.completeTimestamp || file.completeTimestamp > this.startTime) {
loadedDuringSession += loaded;
}
this.total.loaded += loaded;
} else {
this.total.size = undefined;
}
// Tally file statuses
if (file.status === Uploader.STATES.DONE) {
this.total.uploaded++;
} else if (file.status === Uploader.STATES.FAILED) {
this.total.failed++;
} else {
this.total.queued++;
}
}
// Calculate percent and bytes/sec
if (typeof this.total.size === 'undefined') {
const count = this.files.length;
this.total.percent = count > 0
? Math.ceil((this.total.uploaded / count) * 100)
: 0;
} else {
const elapsedSeconds = (Date.now() - this.startTime || 1) / 1000;
this.total.bytesPerSec = Math.ceil(loadedDuringSession / elapsedSeconds);
this.total.percent = this.total.size > 0
? Math.ceil((this.total.loaded / this.total.size) * 100)
: 0;
}
}
}