diff --git a/src/webui/www/private/css/style.css b/src/webui/www/private/css/style.css
index 28ff3e910..fd363cd90 100644
--- a/src/webui/www/private/css/style.css
+++ b/src/webui/www/private/css/style.css
@@ -508,19 +508,16 @@ td.generalLabel {
padding: 2px;
}
-#progress {
- border: 1px solid #999;
- padding: 0;
+.piecesbarWrapper {
position: relative;
width: 100%;
}
-#progress canvas {
+.piecesbarCanvas {
height: 100%;
image-rendering: pixelated;
- left: 0;
+ inset: 0;
position: absolute;
- top: 0;
width: 100%;
}
diff --git a/src/webui/www/private/index.html b/src/webui/www/private/index.html
index 67095b6c0..964ae7dd2 100644
--- a/src/webui/www/private/index.html
+++ b/src/webui/www/private/index.html
@@ -27,6 +27,7 @@
+
diff --git a/src/webui/www/private/scripts/piecesbar.js b/src/webui/www/private/scripts/piecesbar.js
new file mode 100644
index 000000000..4a7fc83c7
--- /dev/null
+++ b/src/webui/www/private/scripts/piecesbar.js
@@ -0,0 +1,278 @@
+/*
+ * Bittorrent Client using Qt and libtorrent.
+ * Copyright (C) 2022 Jesse Smick
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * In addition, as a special exception, the copyright holders give permission to
+ * link this program with the OpenSSL project's "OpenSSL" library (or with
+ * modified versions of it that use the same license as the "OpenSSL" library),
+ * and distribute the linked executables. You must obey the GNU General Public
+ * License in all respects for all of the code used other than "OpenSSL". If you
+ * modify file(s), you may extend this exception to your version of the file(s),
+ * but you are not obligated to do so. If you do not wish to do so, delete this
+ * exception statement from your version.
+ */
+
+'use strict';
+
+if (window.qBittorrent === undefined) {
+ window.qBittorrent = {};
+}
+
+window.qBittorrent.PiecesBar = (() => {
+ const exports = () => {
+ return {
+ PiecesBar: PiecesBar
+ };
+ };
+
+ const STATUS_DOWNLOADING = 1;
+ const STATUS_DOWNLOADED = 2;
+
+ // absolute max width of 4096
+ // this is to support all browsers for size of canvas elements
+ // see https://github.com/jhildenbiddle/canvas-size#test-results
+ const MAX_CANVAS_WIDTH = 4096;
+
+ let piecesBarUniqueId = 0;
+ const PiecesBar = new Class({
+ initialize(pieces, parameters) {
+ const vals = {
+ 'id': 'piecesbar_' + (piecesBarUniqueId++),
+ 'width': 0,
+ 'height': 0,
+ 'downloadingColor': 'green',
+ 'haveColor': 'blue',
+ 'borderSize': 1,
+ 'borderColor': '#999'
+ };
+
+ if (parameters && ($type(parameters) === 'object'))
+ $extend(vals, parameters);
+ vals.height = Math.max(vals.height, 12);
+
+ const obj = new Element('div', {
+ 'id': vals.id,
+ 'class': 'piecesbarWrapper',
+ 'styles': {
+ 'border': vals.borderSize.toString() + 'px solid ' + vals.borderColor,
+ 'height': vals.height.toString() + 'px',
+ }
+ });
+ obj.vals = vals;
+ obj.vals.pieces = $pick(pieces, []);
+
+ obj.vals.canvas = new Element('canvas', {
+ 'id': vals.id + '_canvas',
+ 'class': 'piecesbarCanvas',
+ 'width': (vals.width - (2 * vals.borderSize)).toString(),
+ 'height': '1' // will stretch vertically to take up the height of the parent
+ });
+ obj.appendChild(obj.vals.canvas);
+
+ obj.setPieces = setPieces;
+ obj.refresh = refresh;
+ obj.clear = setPieces.bind(obj, []);
+ obj._drawStatus = drawStatus;
+
+ if (vals.width > 0)
+ obj.setPieces(vals.pieces);
+ else
+ setTimeout(() => { checkForParent(obj.id); }, 1);
+
+ return obj;
+ }
+ });
+
+ function setPieces(pieces) {
+ if (!Array.isArray(pieces))
+ pieces = [];
+
+ this.vals.pieces = pieces;
+ this.refresh(true);
+ }
+
+ function refresh(force) {
+ const start = Date.now();
+ if (!this.parentNode)
+ return;
+
+ const pieces = this.vals.pieces;
+
+ // if the number of pieces is small, use that for the width,
+ // and have it stretch horizontally.
+ // this also limits the ratio below to >= 1
+ const width = Math.min(this.offsetWidth, pieces.length, MAX_CANVAS_WIDTH);
+ if ((this.vals.width === width) && !force)
+ return;
+
+ this.vals.width = width;
+
+ // change canvas size to fit exactly in the space
+ this.vals.canvas.width = width - (2 * this.vals.borderSize);
+
+ const canvas = this.vals.canvas;
+ const ctx = canvas.getContext('2d');
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ const imageWidth = canvas.width;
+
+ if (imageWidth.length === 0)
+ return;
+
+ let minStatus = Infinity;
+ let maxStatus = 0;
+
+ for (const status of pieces) {
+ if (status > maxStatus) {
+ maxStatus = status;
+ }
+
+ if (status < minStatus) {
+ minStatus = status;
+ }
+ }
+
+ // if no progress then don't do anything
+ if (maxStatus === 0)
+ return;
+
+ // if all pieces are downloaded, fill entire image at once
+ if (minStatus === STATUS_DOWNLOADED) {
+ ctx.fillStyle = this.vals.haveColor;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ return;
+ }
+
+ /* Linear transformation from pieces to pixels.
+ *
+ * The canvas size can vary in width so this figures out what to draw at each pixel.
+ * Inspired by the GUI code here https://github.com/qbittorrent/qBittorrent/blob/25b3f2d1a6b14f0fe098fb79a3d034607e52deae/src/gui/properties/downloadedpiecesbar.cpp#L54
+ *
+ * example ratio > 1 (at least 2 pieces per pixel)
+ * +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+ * pieces | 2 | 1 | 2 | 0 | 2 | 0 | 1 | 0 | 1 | 2 |
+ * +---------+---------+---------+---------+---------+---------+
+ * pixels | | | | | | |
+ * +---------+---------+---------+---------+---------+---------+
+ *
+ * example ratio < 1 (at most 2 pieces per pixel)
+ * This case shouldn't happen since the max pixels are limited to the number of pieces
+ * +---------+---------+---------+---------+----------+--------+
+ * pieces | 2 | 1 | 1 | 0 | 2 | 2 |
+ * +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+ * pixels | | | | | | | | | | |
+ * +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
+ */
+
+ const ratio = pieces.length / imageWidth;
+
+ let lastValue = null;
+ let rectangleStart = 0;
+
+ // for each pixel compute its status based on the pieces
+ for (let x = 0; x < imageWidth; ++x) {
+ // find positions in the pieces array
+ const piecesFrom = x * ratio;
+ const piecesTo = (x + 1) * ratio;
+ const piecesToInt = Math.ceil(piecesTo);
+
+ const statusValues = {
+ [STATUS_DOWNLOADING]: 0,
+ [STATUS_DOWNLOADED]: 0
+ };
+
+ // aggregate the status of each piece that contributes to this pixel
+ for (let p = piecesFrom; p < piecesToInt; ++p) {
+ const piece = Math.floor(p);
+ const pieceStart = Math.max(piecesFrom, piece);
+ const pieceEnd = Math.min(piece + 1, piecesTo);
+
+ const amount = pieceEnd - pieceStart;
+ const status = pieces[piece];
+
+ if (status in statusValues)
+ statusValues[status] += amount;
+ }
+
+ // normalize to interval [0, 1]
+ statusValues[STATUS_DOWNLOADING] /= ratio;
+ statusValues[STATUS_DOWNLOADED] /= ratio;
+
+ // floats accumulate small errors, so smooth it out by rounding to hundredths place
+ // this effectively limits each status to a value 1 in 100
+ statusValues[STATUS_DOWNLOADING] = Math.round(statusValues[STATUS_DOWNLOADING] * 100) / 100;
+ statusValues[STATUS_DOWNLOADED] = Math.round(statusValues[STATUS_DOWNLOADED] * 100) / 100;
+
+ // float precision sometimes _still_ gives > 1
+ statusValues[STATUS_DOWNLOADING] = Math.min(statusValues[STATUS_DOWNLOADING], 1);
+ statusValues[STATUS_DOWNLOADED] = Math.min(statusValues[STATUS_DOWNLOADED], 1);
+
+ if (!lastValue) {
+ lastValue = statusValues;
+ }
+
+ // group contiguous colors together and draw as a single rectangle
+ if ((lastValue[STATUS_DOWNLOADING] === statusValues[STATUS_DOWNLOADING])
+ && (lastValue[STATUS_DOWNLOADED] === statusValues[STATUS_DOWNLOADED])) {
+ continue;
+ }
+
+ const rectangleWidth = x - rectangleStart;
+ this._drawStatus(ctx, rectangleStart, rectangleWidth, lastValue);
+
+ lastValue = statusValues;
+ rectangleStart = x;
+ }
+
+ // fill a rect at the end of the canvas
+ if (rectangleStart < imageWidth) {
+ const rectangleWidth = imageWidth - rectangleStart;
+ this._drawStatus(ctx, rectangleStart, rectangleWidth, lastValue);
+ }
+ }
+
+ function drawStatus(ctx, start, width, statusValues) {
+ // mix the colors by using transparency and a composite mode
+ ctx.globalCompositeOperation = 'lighten';
+
+ if (statusValues[STATUS_DOWNLOADING]) {
+ ctx.globalAlpha = statusValues[STATUS_DOWNLOADING];
+ ctx.fillStyle = this.vals.downloadingColor;
+ ctx.fillRect(start, 0, width, ctx.canvas.height);
+ }
+
+ if (statusValues[STATUS_DOWNLOADED]) {
+ ctx.globalAlpha = statusValues[STATUS_DOWNLOADED];
+ ctx.fillStyle = this.vals.haveColor;
+ ctx.fillRect(start, 0, width, ctx.canvas.height);
+ }
+ }
+
+ function checkForParent(id) {
+ const obj = $(id);
+ if (!obj)
+ return;
+ if (!obj.parentNode)
+ return setTimeout(function() { checkForParent(id); }, 1);
+
+ obj.refresh();
+ }
+
+ return exports();
+})();
+
+Object.freeze(window.qBittorrent.PiecesBar);
diff --git a/src/webui/www/private/scripts/prop-general.js b/src/webui/www/private/scripts/prop-general.js
index 5e1d604fb..d98a41165 100644
--- a/src/webui/www/private/scripts/prop-general.js
+++ b/src/webui/www/private/scripts/prop-general.js
@@ -39,6 +39,11 @@ window.qBittorrent.PropGeneral = (function() {
};
};
+ const piecesBar = new window.qBittorrent.PiecesBar.PiecesBar([], {
+ height: 16
+ });
+ $('progress').appendChild(piecesBar);
+
const clearData = function() {
$('time_elapsed').set('html', '');
$('eta').set('html', '');
@@ -65,9 +70,7 @@ window.qBittorrent.PropGeneral = (function() {
$('torrent_hash_v2').set('html', '');
$('save_path').set('html', '');
$('comment').set('html', '');
-
- const canvas = $('progress').getFirst('canvas');
- canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
+ piecesBar.clear();
};
let loadTorrentDataTimer;
@@ -231,43 +234,7 @@ window.qBittorrent.PropGeneral = (function() {
$('error_div').set('html', '');
if (data) {
- const canvas = $('progress').getFirst('canvas');
- canvas.width = data.length;
- const ctx = canvas.getContext('2d');
- ctx.clearRect(0, 0, canvas.width, canvas.height);
-
- // Group contiguous colors together and draw as a single rectangle
- let color = '';
- let rectWidth = 1;
-
- for (let i = 0; i < data.length; ++i) {
- const status = data[i];
- let newColor = '';
-
- if (status === 1)
- newColor = 'green';
- else if (status === 2)
- newColor = 'blue';
-
- if (newColor === color) {
- ++rectWidth;
- continue;
- }
-
- if (color !== '') {
- ctx.fillStyle = color;
- ctx.fillRect((i - rectWidth), 0, rectWidth, canvas.height);
- }
-
- rectWidth = 1;
- color = newColor;
- }
-
- // Fill a rect at the end of the canvas if one is needed
- if (color !== '') {
- ctx.fillStyle = color;
- ctx.fillRect((data.length - rectWidth), 0, rectWidth, canvas.height);
- }
+ piecesBar.setPieces(data);
}
else {
clearData();
diff --git a/src/webui/www/private/views/properties.html b/src/webui/www/private/views/properties.html
index 171e54196..bd505f318 100644
--- a/src/webui/www/private/views/properties.html
+++ b/src/webui/www/private/views/properties.html
@@ -2,9 +2,7 @@