@uppy/status-bar
Version:
A progress bar for Uppy, with many bells and whistles.
210 lines (209 loc) • 8.69 kB
JavaScript
import { UIPlugin } from '@uppy/core';
import { emaFilter, getTextDirection } from '@uppy/utils';
import packageJson from '../package.json' with { type: 'json' };
import locale from './locale.js';
import statusBarStates from './StatusBarStates.js';
import StatusBarUI, {} from './StatusBarUI.js';
const speedFilterHalfLife = 2000;
const ETAFilterHalfLife = 2000;
function getUploadingState(error, isAllComplete, recoveredState, files) {
if (error) {
return statusBarStates.STATE_ERROR;
}
if (isAllComplete) {
return statusBarStates.STATE_COMPLETE;
}
if (recoveredState) {
return statusBarStates.STATE_WAITING;
}
let state = statusBarStates.STATE_WAITING;
const fileIDs = Object.keys(files);
for (let i = 0; i < fileIDs.length; i++) {
const { progress } = files[fileIDs[i]];
// If ANY files are being uploaded right now, show the uploading state.
if (progress.uploadStarted && !progress.uploadComplete) {
return statusBarStates.STATE_UPLOADING;
}
// If files are being preprocessed AND postprocessed at this time, we show the
// preprocess state. If any files are being uploaded we show uploading.
if (progress.preprocess) {
state = statusBarStates.STATE_PREPROCESSING;
}
// If NO files are being preprocessed or uploaded right now, but some files are
// being postprocessed, show the postprocess state.
if (progress.postprocess && state !== statusBarStates.STATE_PREPROCESSING) {
state = statusBarStates.STATE_POSTPROCESSING;
}
}
return state;
}
const defaultOptions = {
hideUploadButton: false,
hideRetryButton: false,
hidePauseResumeButton: false,
hideCancelButton: false,
showProgressDetails: false,
hideAfterFinish: true,
doneButtonHandler: null,
};
/**
* StatusBar: renders a status bar with upload/pause/resume/cancel/retry buttons,
* progress percentage and time remaining.
*/
export default class StatusBar extends UIPlugin {
static VERSION = packageJson.version;
#lastUpdateTime;
#previousUploadedBytes;
#previousSpeed;
#previousETA;
constructor(uppy, opts) {
super(uppy, { ...defaultOptions, ...opts });
this.id = this.opts.id || 'StatusBar';
this.title = 'StatusBar';
this.type = 'progressindicator';
this.defaultLocale = locale;
this.i18nInit();
this.render = this.render.bind(this);
this.install = this.install.bind(this);
}
#computeSmoothETA(totalBytes) {
if (totalBytes.total == null || totalBytes.total === 0) {
return null;
}
const remaining = totalBytes.total - totalBytes.uploaded;
if (remaining <= 0) {
return null;
}
// When state is restored, lastUpdateTime is still nullish at this point.
this.#lastUpdateTime ??= performance.now();
const dt = performance.now() - this.#lastUpdateTime;
if (dt === 0) {
return Math.round((this.#previousETA ?? 0) / 100) / 10;
}
const uploadedBytesSinceLastTick = totalBytes.uploaded - this.#previousUploadedBytes;
this.#previousUploadedBytes = totalBytes.uploaded;
// uploadedBytesSinceLastTick can be negative in some cases (packet loss?)
// in which case, we wait for next tick to update ETA.
if (uploadedBytesSinceLastTick <= 0) {
return Math.round((this.#previousETA ?? 0) / 100) / 10;
}
const currentSpeed = uploadedBytesSinceLastTick / dt;
const filteredSpeed = this.#previousSpeed == null
? currentSpeed
: emaFilter(currentSpeed, this.#previousSpeed, speedFilterHalfLife, dt);
this.#previousSpeed = filteredSpeed;
const instantETA = remaining / filteredSpeed;
const updatedPreviousETA = Math.max(this.#previousETA - dt, 0);
const filteredETA = this.#previousETA == null
? instantETA
: emaFilter(instantETA, updatedPreviousETA, ETAFilterHalfLife, dt);
this.#previousETA = filteredETA;
this.#lastUpdateTime = performance.now();
return Math.round(filteredETA / 100) / 10;
}
startUpload = () => {
return this.uppy.upload().catch((() => {
// Error logged in Core
}));
};
render(state) {
const { capabilities, files, allowNewUpload, totalProgress, error, recoveredState, } = state;
const { newFiles, startedFiles, completeFiles, isUploadStarted, isAllComplete, isAllPaused, isUploadInProgress, isSomeGhost, } = this.uppy.getObjectOfFilesPerState();
// If some state was recovered, we want to show Upload button/counter
// for all the files, because in this case it’s not an Upload button,
// but “Confirm Restore Button”
const newFilesOrRecovered = recoveredState ? Object.values(files) : newFiles;
const resumableUploads = !!capabilities.resumableUploads;
const supportsUploadProgress = capabilities.uploadProgress !== false;
let totalSize = null;
let totalUploadedSize = 0;
// Only if all files have a known size, does it make sense to display a total size
if (startedFiles.every((f) => f.progress.bytesTotal != null && f.progress.bytesTotal !== 0)) {
totalSize = 0;
startedFiles.forEach((file) => {
totalSize += file.progress.bytesTotal || 0;
totalUploadedSize += file.progress.bytesUploaded || 0;
});
}
else {
// however uploaded size we will always have
startedFiles.forEach((file) => {
totalUploadedSize += file.progress.bytesUploaded || 0;
});
}
const totalETA = this.#computeSmoothETA({
uploaded: totalUploadedSize,
total: totalSize,
});
return StatusBarUI({
error,
uploadState: getUploadingState(error, isAllComplete, recoveredState, state.files || {}),
allowNewUpload,
totalProgress,
totalSize,
totalUploadedSize,
isAllComplete: false,
isAllPaused,
isUploadStarted,
isUploadInProgress,
isSomeGhost,
recoveredState,
complete: completeFiles.length,
newFiles: newFilesOrRecovered.length,
numUploads: startedFiles.length,
totalETA,
files,
i18n: this.i18n,
uppy: this.uppy,
startUpload: this.startUpload,
doneButtonHandler: this.opts.doneButtonHandler,
resumableUploads,
supportsUploadProgress,
showProgressDetails: this.opts.showProgressDetails,
hideUploadButton: this.opts.hideUploadButton,
hideRetryButton: this.opts.hideRetryButton,
hidePauseResumeButton: this.opts.hidePauseResumeButton,
hideCancelButton: this.opts.hideCancelButton,
hideAfterFinish: this.opts.hideAfterFinish,
});
}
onMount() {
// Set the text direction if the page has not defined one.
const element = this.el;
const direction = getTextDirection(element);
if (!direction) {
element.dir = 'ltr';
}
}
#onUploadStart = () => {
const { recoveredState } = this.uppy.getState();
this.#previousSpeed = null;
this.#previousETA = null;
if (recoveredState) {
this.#previousUploadedBytes = Object.values(recoveredState.files).reduce((pv, { progress }) => pv + progress.bytesUploaded, 0);
// We don't set `#lastUpdateTime` at this point because the upload won't
// actually resume until the user asks for it.
this.uppy.emit('restore-confirmed');
return;
}
this.#lastUpdateTime = performance.now();
this.#previousUploadedBytes = 0;
};
install() {
const { target } = this.opts;
if (target) {
this.mount(target, this);
}
this.uppy.on('upload', this.#onUploadStart);
// To cover the use case where the status bar is installed while the upload
// has started, we set `lastUpdateTime` right away.
this.#lastUpdateTime = performance.now();
this.#previousUploadedBytes = this.uppy
.getFiles()
.reduce((pv, file) => pv + file.progress.bytesUploaded, 0);
}
uninstall() {
this.unmount();
this.uppy.off('upload', this.#onUploadStart);
}
}